From 3711283de53bfc7950ab56e6312f76578ee2d344 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 6 May 2020 23:43:46 -0400
Subject: [PATCH 001/300] Extend ViewTestCases to get and list objects as a
non-authenticated user
---
netbox/utilities/testing/testcases.py | 14 ++++++++++++++
1 file changed, 14 insertions(+)
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.
From 5c1adf9e3775968842f6c0deddd7291ac924b554 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 6 May 2020 23:44:06 -0400
Subject: [PATCH 002/300] Fixes #4593: Fix AttributeError exception when
viewing object lists as a non-authenticated user
---
docs/release-notes/version-2.8.md | 8 ++++++++
netbox/extras/views.py | 14 ++++++++++----
netbox/templates/utilities/obj_list.html | 2 +-
netbox/utilities/paginator.py | 7 +++++--
netbox/utilities/views.py | 8 +++++++-
5 files changed, 31 insertions(+), 8 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index e75bf4ab9..62d5e9920 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,5 +1,13 @@
# NetBox v2.8
+## v2.8.3 (FUTURE)
+
+### Bug Fixes
+
+* [#4593](https://github.com/netbox-community/netbox/issues/4593) - Fix AttributeError exception when viewing object lists as a non-authenticated user
+
+---
+
## v2.8.2 (2020-05-06)
### Enhancements
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 613e45132..1bfbb7abf 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -124,9 +124,12 @@ class ConfigContextView(PermissionRequiredMixin, View):
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format')
- request.user.config.set('extras.configcontext.format', format, commit=True)
- else:
+ if request.user.is_authenticated:
+ request.user.config.set('extras.configcontext.format', format, commit=True)
+ elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json')
+ else:
+ format = 'json'
return render(request, 'extras/configcontext.html', {
'configcontext': configcontext,
@@ -181,9 +184,12 @@ class ObjectConfigContextView(View):
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
format = request.GET.get('format')
- request.user.config.set('extras.configcontext.format', format, commit=True)
- else:
+ if request.user.is_authenticated:
+ request.user.config.set('extras.configcontext.format', format, commit=True)
+ elif request.user.is_authenticated:
format = request.user.config.get('extras.configcontext.format', 'json')
+ else:
+ format = 'json'
return render(request, 'extras/object_configcontext.html', {
model_name: obj,
diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html
index 4cfa8b1ce..85ff050ed 100644
--- a/netbox/templates/utilities/obj_list.html
+++ b/netbox/templates/utilities/obj_list.html
@@ -5,7 +5,7 @@
{% block content %}
{% block buttons %}{% endblock %}
- {% if table_config_form %}
+ {% if request.user.is_authenticated and table_config_form %}
{% endif %}
{% if permissions.add and 'add' in action_buttons %}
diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py
index cef7c941f..cdad1f230 100644
--- a/netbox/utilities/paginator.py
+++ b/netbox/utilities/paginator.py
@@ -50,9 +50,12 @@ def get_paginate_count(request):
if 'per_page' in request.GET:
try:
per_page = int(request.GET.get('per_page'))
- request.user.config.set('pagination.per_page', per_page, commit=True)
+ if request.user.is_authenticated:
+ request.user.config.set('pagination.per_page', per_page, commit=True)
return per_page
except ValueError:
pass
- return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
+ 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/views.py b/netbox/utilities/views.py
index 3064abe4e..4b5993c5f 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -3,6 +3,7 @@ import sys
from copy import deepcopy
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
@@ -164,7 +166,10 @@ class ObjectListView(View):
permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions
- columns = request.user.config.get(f"tables.{self.table.__name__}.columns")
+ 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')
@@ -188,6 +193,7 @@ class ObjectListView(View):
return render(request, self.template_name, context)
+ @method_decorator(login_required)
def post(self, request):
# Update the user's table configuration
From af96ffb3e9d0ceb0dfa1e67b77d8628b466c3abf Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 6 May 2020 23:46:52 -0400
Subject: [PATCH 003/300] Release v2.8.3
---
docs/release-notes/version-2.8.md | 2 +-
netbox/netbox/settings.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 62d5e9920..e3bd6b512 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,6 +1,6 @@
# NetBox v2.8
-## v2.8.3 (FUTURE)
+## v2.8.3 (2020-05-06)
### Bug Fixes
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index ce352ebda..415c556e9 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.3-dev'
+VERSION = '2.8.3'
# Hostname
HOSTNAME = platform.node()
From 7c6faff405d4c4a0877362a048ccc9313e671cbf Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 6 May 2020 23:50:41 -0400
Subject: [PATCH 004/300] Post-release version bump
---
netbox/netbox/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 415c556e9..f928ca71e 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.3'
+VERSION = '2.8.4-dev'
# Hostname
HOSTNAME = platform.node()
From b7a96a33efe25e84a0e8ffebc3d3280dda0ae9c9 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 7 May 2020 10:34:33 -0400
Subject: [PATCH 005/300] Fixes #4598: Display error message when invalid cable
length is specified
---
docs/release-notes/version-2.8.md | 8 ++++++++
netbox/dcim/forms.py | 5 +++++
netbox/templates/dcim/inc/cable_form.html | 14 ++++++++++++++
3 files changed, 27 insertions(+)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index e3bd6b512..e1dfaddbb 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,5 +1,13 @@
# NetBox v2.8
+v2.8.4 (FUTURE)
+
+### Bug Fixes
+
+* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
+
+---
+
## v2.8.3 (2020-05-06)
### Bug Fixes
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index b104124b4..2116d0948 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -3659,6 +3659,11 @@ class CableForm(BootstrapMixin, forms.ModelForm):
'type': StaticSelect2,
'length_unit': StaticSelect2,
}
+ error_messages = {
+ 'length': {
+ 'max_value': 'Maximum length is 32767 (any unit)'
+ }
+ }
class CableCSVForm(CSVModelForm):
diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html
index 0799eb130..a52cc302e 100644
--- a/netbox/templates/dcim/inc/cable_form.html
+++ b/netbox/templates/dcim/inc/cable_form.html
@@ -10,9 +10,23 @@
{{ form.length }}
+ {% if form.length.errors %}
+
+ {% for error in form.length.errors %}
+
{{ error }}
+ {% endfor %}
+
+ {% endif %}
{{ form.length_unit }}
+ {% if form.length_unit.errors %}
+
+ {% for error in form.length_unit.errors %}
+
{{ error }}
+ {% endfor %}
+
+ {% endif %}
From e14e217fcd2544441fce3028b7ea2f37fa55cc18 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 7 May 2020 16:22:04 -0400
Subject: [PATCH 006/300] Fixes #4604: Multi-position rear ports may only be
connected to other rear ports
---
docs/release-notes/version-2.8.md | 1 +
netbox/dcim/models/__init__.py | 30 ++++++++++++++++++------------
2 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index e1dfaddbb..ff6ba4e50 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -5,6 +5,7 @@ v2.8.4 (FUTURE)
### Bug Fixes
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
+* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
---
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index b0da352da..490667153 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -2182,23 +2182,29 @@ class Cable(ChangeLoggedModel):
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
- raise ValidationError("Incompatible termination types: {} and {}".format(
- self.termination_a_type, self.termination_b_type
- ))
+ raise ValidationError(
+ f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}"
+ )
- # A RearPort with multiple positions must be connected to a component with an equal number of positions
- if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort):
- if self.termination_a.positions != self.termination_b.positions:
- raise ValidationError(
- "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
- self.termination_a, self.termination_a.positions,
- self.termination_b, self.termination_b.positions
+ # A RearPort with multiple positions must be connected to a RearPort with an equal number of positions
+ for term_a, term_b in [
+ (self.termination_a, self.termination_b),
+ (self.termination_b, self.termination_a)
+ ]:
+ if isinstance(term_a, RearPort) and term_a.positions > 1:
+ if not isinstance(term_b, RearPort):
+ raise ValidationError(
+ "Rear ports with multiple positions may only be connected to other rear ports"
+ )
+ elif term_a.positions != term_b.positions:
+ raise ValidationError(
+ f"{term_a} has {term_a.positions} position(s) but {term_b} has {term_b.positions}. "
+ f"Both terminations must have the same number of positions."
)
- )
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
- raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
+ raise ValidationError(f"Cannot connect {self.termination_a_type} to itself")
# A front port cannot be connected to its corresponding rear port
if (
From da8380c62cd9ef256dc3017fc991d7061d1e2199 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 7 May 2020 16:59:27 -0400
Subject: [PATCH 007/300] Refactor extras.models
---
.../migrations/0006_add_imageattachments.py | 4 +-
.../migrations/0007_unicode_literals.py | 4 +-
netbox/extras/models/__init__.py | 25 ++
netbox/extras/models/customfields.py | 297 ++++++++++++++
netbox/extras/{ => models}/models.py | 374 +-----------------
netbox/extras/models/tags.py | 44 +++
netbox/extras/utils.py | 16 +
7 files changed, 390 insertions(+), 374 deletions(-)
create mode 100644 netbox/extras/models/__init__.py
create mode 100644 netbox/extras/models/customfields.py
rename netbox/extras/{ => models}/models.py (63%)
create mode 100644 netbox/extras/models/tags.py
diff --git a/netbox/extras/migrations/0006_add_imageattachments.py b/netbox/extras/migrations/0006_add_imageattachments.py
index 6842cced0..b25327c33 100644
--- a/netbox/extras/migrations/0006_add_imageattachments.py
+++ b/netbox/extras/migrations/0006_add_imageattachments.py
@@ -2,7 +2,7 @@
# Generated by Django 1.11 on 2017-04-04 19:58
from django.db import migrations, models
import django.db.models.deletion
-import extras.models
+import extras.utils
class Migration(migrations.Migration):
@@ -18,7 +18,7 @@ class Migration(migrations.Migration):
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField()),
- ('image', models.ImageField(height_field=b'image_height', upload_to=extras.models.image_upload, width_field=b'image_width')),
+ ('image', models.ImageField(height_field=b'image_height', upload_to=extras.utils.image_upload, width_field=b'image_width')),
('image_height', models.PositiveSmallIntegerField()),
('image_width', models.PositiveSmallIntegerField()),
('name', models.CharField(blank=True, max_length=50)),
diff --git a/netbox/extras/migrations/0007_unicode_literals.py b/netbox/extras/migrations/0007_unicode_literals.py
index fecb33b7b..88525a24a 100644
--- a/netbox/extras/migrations/0007_unicode_literals.py
+++ b/netbox/extras/migrations/0007_unicode_literals.py
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-05-24 15:34
from django.db import migrations, models
-import extras.models
+import extras.utils
class Migration(migrations.Migration):
@@ -74,7 +74,7 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name='imageattachment',
name='image',
- field=models.ImageField(height_field='image_height', upload_to=extras.models.image_upload, width_field='image_width'),
+ field=models.ImageField(height_field='image_height', upload_to=extras.utils.image_upload, width_field='image_width'),
),
migrations.AlterField(
model_name='topologymap',
diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py
new file mode 100644
index 000000000..2942bfa48
--- /dev/null
+++ b/netbox/extras/models/__init__.py
@@ -0,0 +1,25 @@
+from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
+from .models import (
+ ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
+ Script, Webhook,
+)
+from .tags import Tag, TaggedItem
+
+__all__ = (
+ 'ConfigContext',
+ 'ConfigContextModel',
+ 'CustomField',
+ 'CustomFieldChoice',
+ 'CustomFieldModel',
+ 'CustomFieldValue',
+ 'CustomLink',
+ 'ExportTemplate',
+ 'Graph',
+ 'ImageAttachment',
+ 'ObjectChange',
+ 'ReportResult',
+ 'Script',
+ 'Tag',
+ 'TaggedItem',
+ 'Webhook',
+)
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
new file mode 100644
index 000000000..47bccd98a
--- /dev/null
+++ b/netbox/extras/models/customfields.py
@@ -0,0 +1,297 @@
+from collections import OrderedDict
+from datetime import date
+
+from django import forms
+from django.contrib.contenttypes.fields import GenericForeignKey
+from django.contrib.contenttypes.models import ContentType
+from django.core.validators import ValidationError
+from django.db import models
+
+from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
+from extras.choices import *
+from extras.utils import FeatureQuery
+
+
+#
+# Custom fields
+#
+
+class CustomFieldModel(models.Model):
+ _cf = None
+
+ class Meta:
+ abstract = True
+
+ def cache_custom_fields(self):
+ """
+ Cache all custom field values for this instance
+ """
+ self._cf = {
+ field.name: value for field, value in self.get_custom_fields().items()
+ }
+
+ @property
+ def cf(self):
+ """
+ Name-based CustomFieldValue accessor for use in templates
+ """
+ if self._cf is None:
+ self.cache_custom_fields()
+ return self._cf
+
+ def get_custom_fields(self):
+ """
+ Return a dictionary of custom fields for a single object in the form {: value}.
+ """
+
+ # Find all custom fields applicable to this type of object
+ content_type = ContentType.objects.get_for_model(self)
+ fields = CustomField.objects.filter(obj_type=content_type)
+
+ # If the object exists, populate its custom fields with values
+ if hasattr(self, 'pk'):
+ values = self.custom_field_values.all()
+ values_dict = {cfv.field_id: cfv.value for cfv in values}
+ return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
+ else:
+ return OrderedDict([(field, None) for field in fields])
+
+
+class CustomField(models.Model):
+ obj_type = models.ManyToManyField(
+ to=ContentType,
+ related_name='custom_fields',
+ verbose_name='Object(s)',
+ limit_choices_to=FeatureQuery('custom_fields'),
+ help_text='The object(s) to which this field applies.'
+ )
+ type = models.CharField(
+ max_length=50,
+ choices=CustomFieldTypeChoices,
+ default=CustomFieldTypeChoices.TYPE_TEXT
+ )
+ name = models.CharField(
+ max_length=50,
+ unique=True
+ )
+ label = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text='Name of the field as displayed to users (if not provided, '
+ 'the field\'s name will be used)'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True
+ )
+ required = models.BooleanField(
+ default=False,
+ help_text='If true, this field is required when creating new objects '
+ 'or editing an existing object.'
+ )
+ filter_logic = models.CharField(
+ max_length=50,
+ choices=CustomFieldFilterLogicChoices,
+ default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
+ help_text='Loose matches any instance of a given string; exact '
+ 'matches the entire field.'
+ )
+ default = models.CharField(
+ max_length=100,
+ blank=True,
+ help_text='Default value for the field. Use "true" or "false" for booleans.'
+ )
+ weight = models.PositiveSmallIntegerField(
+ default=100,
+ help_text='Fields with higher weights appear lower in a form.'
+ )
+
+ class Meta:
+ ordering = ['weight', 'name']
+
+ def __str__(self):
+ return self.label or self.name.replace('_', ' ').capitalize()
+
+ def serialize_value(self, value):
+ """
+ Serialize the given value to a string suitable for storage as a CustomFieldValue
+ """
+ if value is None:
+ return ''
+ if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+ return str(int(bool(value)))
+ if self.type == CustomFieldTypeChoices.TYPE_DATE:
+ # Could be date/datetime object or string
+ try:
+ return value.strftime('%Y-%m-%d')
+ except AttributeError:
+ return value
+ if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+ # Could be ModelChoiceField or TypedChoiceField
+ return str(value.id) if hasattr(value, 'id') else str(value)
+ return value
+
+ def deserialize_value(self, serialized_value):
+ """
+ Convert a string into the object it represents depending on the type of field
+ """
+ if serialized_value == '':
+ return None
+ if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+ return int(serialized_value)
+ if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+ return bool(int(serialized_value))
+ if self.type == CustomFieldTypeChoices.TYPE_DATE:
+ # Read date as YYYY-MM-DD
+ return date(*[int(n) for n in serialized_value.split('-')])
+ if self.type == CustomFieldTypeChoices.TYPE_SELECT:
+ return self.choices.get(pk=int(serialized_value))
+ return serialized_value
+
+ def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
+ """
+ Return a form field suitable for setting a CustomField's value for an object.
+
+ set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
+ enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
+ for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
+ """
+ initial = self.default if set_initial else None
+ required = self.required if enforce_required else False
+
+ # Integer
+ if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
+ field = forms.IntegerField(required=required, initial=initial)
+
+ # Boolean
+ elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
+ choices = (
+ (None, '---------'),
+ (1, 'True'),
+ (0, 'False'),
+ )
+ if initial is not None and initial.lower() in ['true', 'yes', '1']:
+ initial = 1
+ elif initial is not None and initial.lower() in ['false', 'no', '0']:
+ initial = 0
+ else:
+ initial = None
+ field = forms.NullBooleanField(
+ required=required, initial=initial, widget=StaticSelect2(choices=choices)
+ )
+
+ # Date
+ elif self.type == CustomFieldTypeChoices.TYPE_DATE:
+ field = forms.DateField(required=required, initial=initial, widget=DatePicker())
+
+ # Select
+ elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
+ choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
+
+ if not required:
+ choices = add_blank_choice(choices)
+
+ # Set the initial value to the PK of the default choice, if any
+ if set_initial:
+ default_choice = self.choices.filter(value=self.default).first()
+ if default_choice:
+ initial = default_choice.pk
+
+ field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
+ field = field_class(
+ choices=choices, required=required, initial=initial, widget=StaticSelect2()
+ )
+
+ # URL
+ elif self.type == CustomFieldTypeChoices.TYPE_URL:
+ field = LaxURLField(required=required, initial=initial)
+
+ # Text
+ else:
+ field = forms.CharField(max_length=255, required=required, initial=initial)
+
+ field.model = self
+ field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
+ if self.description:
+ field.help_text = self.description
+
+ return field
+
+
+class CustomFieldValue(models.Model):
+ field = models.ForeignKey(
+ to='extras.CustomField',
+ on_delete=models.CASCADE,
+ related_name='values'
+ )
+ obj_type = models.ForeignKey(
+ to=ContentType,
+ on_delete=models.PROTECT,
+ related_name='+'
+ )
+ obj_id = models.PositiveIntegerField()
+ obj = GenericForeignKey(
+ ct_field='obj_type',
+ fk_field='obj_id'
+ )
+ serialized_value = models.CharField(
+ max_length=255
+ )
+
+ class Meta:
+ ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
+ unique_together = ('field', 'obj_type', 'obj_id')
+
+ def __str__(self):
+ return '{} {}'.format(self.obj, self.field)
+
+ @property
+ def value(self):
+ return self.field.deserialize_value(self.serialized_value)
+
+ @value.setter
+ def value(self, value):
+ self.serialized_value = self.field.serialize_value(value)
+
+ def save(self, *args, **kwargs):
+ # Delete this object if it no longer has a value to store
+ if self.pk and self.value is None:
+ self.delete()
+ else:
+ super().save(*args, **kwargs)
+
+
+class CustomFieldChoice(models.Model):
+ field = models.ForeignKey(
+ to='extras.CustomField',
+ on_delete=models.CASCADE,
+ related_name='choices',
+ limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
+ )
+ value = models.CharField(
+ max_length=100
+ )
+ weight = models.PositiveSmallIntegerField(
+ default=100,
+ help_text='Higher weights appear lower in the list'
+ )
+
+ class Meta:
+ ordering = ['field', 'weight', 'value']
+ unique_together = ['field', 'value']
+
+ def __str__(self):
+ return self.value
+
+ def clean(self):
+ if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
+ raise ValidationError("Custom field choices can only be assigned to selection fields.")
+
+ def delete(self, using=None, keep_parents=False):
+ # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
+ pk = self.pk
+ super().delete(using, keep_parents)
+ CustomFieldValue.objects.filter(
+ field__type=CustomFieldTypeChoices.TYPE_SELECT,
+ serialized_value=str(pk)
+ ).delete()
diff --git a/netbox/extras/models.py b/netbox/extras/models/models.py
similarity index 63%
rename from netbox/extras/models.py
rename to netbox/extras/models/models.py
index 488554596..f98a7b34f 100644
--- a/netbox/extras/models.py
+++ b/netbox/extras/models/models.py
@@ -1,8 +1,6 @@
import json
from collections import OrderedDict
-from datetime import date
-from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -12,37 +10,13 @@ from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
-from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder
-from taggit.models import TagBase, GenericTaggedItemBase
-from utilities.fields import ColorField
-from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice
from utilities.utils import deepmerge, render_jinja2
-from .choices import *
-from .constants import *
-from .querysets import ConfigContextQuerySet
-from .utils import FeatureQuery
-
-
-__all__ = (
- 'ConfigContext',
- 'ConfigContextModel',
- 'CustomField',
- 'CustomFieldChoice',
- 'CustomFieldModel',
- 'CustomFieldValue',
- 'CustomLink',
- 'ExportTemplate',
- 'Graph',
- 'ImageAttachment',
- 'ObjectChange',
- 'ReportResult',
- 'Script',
- 'Tag',
- 'TaggedItem',
- 'Webhook',
-)
+from extras.choices import *
+from extras.constants import *
+from extras.querysets import ConfigContextQuerySet
+from extras.utils import FeatureQuery, image_upload
#
@@ -174,291 +148,6 @@ class Webhook(models.Model):
return json.dumps(context, cls=JSONEncoder)
-#
-# Custom fields
-#
-
-class CustomFieldModel(models.Model):
- _cf = None
-
- class Meta:
- abstract = True
-
- def cache_custom_fields(self):
- """
- Cache all custom field values for this instance
- """
- self._cf = {
- field.name: value for field, value in self.get_custom_fields().items()
- }
-
- @property
- def cf(self):
- """
- Name-based CustomFieldValue accessor for use in templates
- """
- if self._cf is None:
- self.cache_custom_fields()
- return self._cf
-
- def get_custom_fields(self):
- """
- Return a dictionary of custom fields for a single object in the form {: value}.
- """
-
- # Find all custom fields applicable to this type of object
- content_type = ContentType.objects.get_for_model(self)
- fields = CustomField.objects.filter(obj_type=content_type)
-
- # If the object exists, populate its custom fields with values
- if hasattr(self, 'pk'):
- values = self.custom_field_values.all()
- values_dict = {cfv.field_id: cfv.value for cfv in values}
- return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
- else:
- return OrderedDict([(field, None) for field in fields])
-
-
-class CustomField(models.Model):
- obj_type = models.ManyToManyField(
- to=ContentType,
- related_name='custom_fields',
- verbose_name='Object(s)',
- limit_choices_to=FeatureQuery('custom_fields'),
- help_text='The object(s) to which this field applies.'
- )
- type = models.CharField(
- max_length=50,
- choices=CustomFieldTypeChoices,
- default=CustomFieldTypeChoices.TYPE_TEXT
- )
- name = models.CharField(
- max_length=50,
- unique=True
- )
- label = models.CharField(
- max_length=50,
- blank=True,
- help_text='Name of the field as displayed to users (if not provided, '
- 'the field\'s name will be used)'
- )
- description = models.CharField(
- max_length=200,
- blank=True
- )
- required = models.BooleanField(
- default=False,
- help_text='If true, this field is required when creating new objects '
- 'or editing an existing object.'
- )
- filter_logic = models.CharField(
- max_length=50,
- choices=CustomFieldFilterLogicChoices,
- default=CustomFieldFilterLogicChoices.FILTER_LOOSE,
- help_text='Loose matches any instance of a given string; exact '
- 'matches the entire field.'
- )
- default = models.CharField(
- max_length=100,
- blank=True,
- help_text='Default value for the field. Use "true" or "false" for booleans.'
- )
- weight = models.PositiveSmallIntegerField(
- default=100,
- help_text='Fields with higher weights appear lower in a form.'
- )
-
- class Meta:
- ordering = ['weight', 'name']
-
- def __str__(self):
- return self.label or self.name.replace('_', ' ').capitalize()
-
- def serialize_value(self, value):
- """
- Serialize the given value to a string suitable for storage as a CustomFieldValue
- """
- if value is None:
- return ''
- if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- return str(int(bool(value)))
- if self.type == CustomFieldTypeChoices.TYPE_DATE:
- # Could be date/datetime object or string
- try:
- return value.strftime('%Y-%m-%d')
- except AttributeError:
- return value
- if self.type == CustomFieldTypeChoices.TYPE_SELECT:
- # Could be ModelChoiceField or TypedChoiceField
- return str(value.id) if hasattr(value, 'id') else str(value)
- return value
-
- def deserialize_value(self, serialized_value):
- """
- Convert a string into the object it represents depending on the type of field
- """
- if serialized_value == '':
- return None
- if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
- return int(serialized_value)
- if self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- return bool(int(serialized_value))
- if self.type == CustomFieldTypeChoices.TYPE_DATE:
- # Read date as YYYY-MM-DD
- return date(*[int(n) for n in serialized_value.split('-')])
- if self.type == CustomFieldTypeChoices.TYPE_SELECT:
- return self.choices.get(pk=int(serialized_value))
- return serialized_value
-
- def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
- """
- Return a form field suitable for setting a CustomField's value for an object.
-
- set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
- enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
- for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
- """
- initial = self.default if set_initial else None
- required = self.required if enforce_required else False
-
- # Integer
- if self.type == CustomFieldTypeChoices.TYPE_INTEGER:
- field = forms.IntegerField(required=required, initial=initial)
-
- # Boolean
- elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
- choices = (
- (None, '---------'),
- (1, 'True'),
- (0, 'False'),
- )
- if initial is not None and initial.lower() in ['true', 'yes', '1']:
- initial = 1
- elif initial is not None and initial.lower() in ['false', 'no', '0']:
- initial = 0
- else:
- initial = None
- field = forms.NullBooleanField(
- required=required, initial=initial, widget=StaticSelect2(choices=choices)
- )
-
- # Date
- elif self.type == CustomFieldTypeChoices.TYPE_DATE:
- field = forms.DateField(required=required, initial=initial, widget=DatePicker())
-
- # Select
- elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
- choices = [(cfc.pk, cfc.value) for cfc in self.choices.all()]
-
- if not required:
- choices = add_blank_choice(choices)
-
- # Set the initial value to the PK of the default choice, if any
- if set_initial:
- default_choice = self.choices.filter(value=self.default).first()
- if default_choice:
- initial = default_choice.pk
-
- field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
- field = field_class(
- choices=choices, required=required, initial=initial, widget=StaticSelect2()
- )
-
- # URL
- elif self.type == CustomFieldTypeChoices.TYPE_URL:
- field = LaxURLField(required=required, initial=initial)
-
- # Text
- else:
- field = forms.CharField(max_length=255, required=required, initial=initial)
-
- field.model = self
- field.label = self.label if self.label else self.name.replace('_', ' ').capitalize()
- if self.description:
- field.help_text = self.description
-
- return field
-
-
-class CustomFieldValue(models.Model):
- field = models.ForeignKey(
- to='extras.CustomField',
- on_delete=models.CASCADE,
- related_name='values'
- )
- obj_type = models.ForeignKey(
- to=ContentType,
- on_delete=models.PROTECT,
- related_name='+'
- )
- obj_id = models.PositiveIntegerField()
- obj = GenericForeignKey(
- ct_field='obj_type',
- fk_field='obj_id'
- )
- serialized_value = models.CharField(
- max_length=255
- )
-
- class Meta:
- ordering = ('obj_type', 'obj_id', 'pk') # (obj_type, obj_id) may be non-unique
- unique_together = ('field', 'obj_type', 'obj_id')
-
- def __str__(self):
- return '{} {}'.format(self.obj, self.field)
-
- @property
- def value(self):
- return self.field.deserialize_value(self.serialized_value)
-
- @value.setter
- def value(self, value):
- self.serialized_value = self.field.serialize_value(value)
-
- def save(self, *args, **kwargs):
- # Delete this object if it no longer has a value to store
- if self.pk and self.value is None:
- self.delete()
- else:
- super().save(*args, **kwargs)
-
-
-class CustomFieldChoice(models.Model):
- field = models.ForeignKey(
- to='extras.CustomField',
- on_delete=models.CASCADE,
- related_name='choices',
- limit_choices_to={'type': CustomFieldTypeChoices.TYPE_SELECT}
- )
- value = models.CharField(
- max_length=100
- )
- weight = models.PositiveSmallIntegerField(
- default=100,
- help_text='Higher weights appear lower in the list'
- )
-
- class Meta:
- ordering = ['field', 'weight', 'value']
- unique_together = ['field', 'value']
-
- def __str__(self):
- return self.value
-
- def clean(self):
- if self.field.type != CustomFieldTypeChoices.TYPE_SELECT:
- raise ValidationError("Custom field choices can only be assigned to selection fields.")
-
- def delete(self, using=None, keep_parents=False):
- # When deleting a CustomFieldChoice, delete all CustomFieldValues which point to it
- pk = self.pk
- super().delete(using, keep_parents)
- CustomFieldValue.objects.filter(
- field__type=CustomFieldTypeChoices.TYPE_SELECT,
- serialized_value=str(pk)
- ).delete()
-
-
#
# Custom links
#
@@ -663,20 +352,6 @@ class ExportTemplate(models.Model):
# Image attachments
#
-def image_upload(instance, filename):
-
- path = 'image-attachments/'
-
- # Rename the file to the provided name, if any. Attempt to preserve the file extension.
- extension = filename.rsplit('.')[-1].lower()
- if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
- filename = '.'.join([instance.name, extension])
- elif instance.name:
- filename = instance.name
-
- return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
-
-
class ImageAttachment(models.Model):
"""
An uploaded image which is associated with an object.
@@ -1038,44 +713,3 @@ class ObjectChange(models.Model):
self.object_repr,
self.object_data,
)
-
-
-#
-# Tags
-#
-
-# TODO: figure out a way around this circular import for ObjectChange
-from utilities.models import ChangeLoggedModel # noqa: E402
-
-
-class Tag(TagBase, ChangeLoggedModel):
- color = ColorField(
- default='9e9e9e'
- )
- description = models.CharField(
- max_length=200,
- blank=True,
- )
-
- def get_absolute_url(self):
- return reverse('extras:tag', args=[self.slug])
-
- def slugify(self, tag, i=None):
- # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
- slug = slugify(tag, allow_unicode=True)
- if i is not None:
- slug += "_%d" % i
- return slug
-
-
-class TaggedItem(GenericTaggedItemBase):
- tag = models.ForeignKey(
- to=Tag,
- related_name="%(app_label)s_%(class)s_items",
- on_delete=models.CASCADE
- )
-
- class Meta:
- index_together = (
- ("content_type", "object_id")
- )
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
new file mode 100644
index 000000000..3bad7fa8b
--- /dev/null
+++ b/netbox/extras/models/tags.py
@@ -0,0 +1,44 @@
+from django.db import models
+from django.urls import reverse
+from django.utils.text import slugify
+from taggit.models import TagBase, GenericTaggedItemBase
+
+from utilities.fields import ColorField
+from utilities.models import ChangeLoggedModel
+
+
+#
+# Tags
+#
+
+class Tag(TagBase, ChangeLoggedModel):
+ color = ColorField(
+ default='9e9e9e'
+ )
+ description = models.CharField(
+ max_length=200,
+ blank=True,
+ )
+
+ def get_absolute_url(self):
+ return reverse('extras:tag', args=[self.slug])
+
+ def slugify(self, tag, i=None):
+ # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
+ slug = slugify(tag, allow_unicode=True)
+ if i is not None:
+ slug += "_%d" % i
+ return slug
+
+
+class TaggedItem(GenericTaggedItemBase):
+ tag = models.ForeignKey(
+ to=Tag,
+ related_name="%(app_label)s_%(class)s_items",
+ on_delete=models.CASCADE
+ )
+
+ class Meta:
+ index_together = (
+ ("content_type", "object_id")
+ )
diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py
index 78214fe41..edbd509f1 100644
--- a/netbox/extras/utils.py
+++ b/netbox/extras/utils.py
@@ -22,6 +22,22 @@ def is_taggable(obj):
return False
+def image_upload(instance, filename):
+ """
+ Return a path for uploading image attchments.
+ """
+ path = 'image-attachments/'
+
+ # Rename the file to the provided name, if any. Attempt to preserve the file extension.
+ extension = filename.rsplit('.')[-1].lower()
+ if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
+ filename = '.'.join([instance.name, extension])
+ elif instance.name:
+ filename = instance.name
+
+ return '{}{}_{}_{}'.format(path, instance.content_type.name, instance.object_id, filename)
+
+
@deconstructible
class FeatureQuery:
"""
From 2c19390d7c69c3c930def43153567b3cd807a379 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 7 May 2020 17:20:32 -0400
Subject: [PATCH 008/300] Introduce CustomFieldManager (WIP)
---
.../migrations/0042_customfield_manager.py | 20 ++++++++++
netbox/extras/models/customfields.py | 38 +++++++++++++++++--
2 files changed, 54 insertions(+), 4 deletions(-)
create mode 100644 netbox/extras/migrations/0042_customfield_manager.py
diff --git a/netbox/extras/migrations/0042_customfield_manager.py b/netbox/extras/migrations/0042_customfield_manager.py
new file mode 100644
index 000000000..7d80b567a
--- /dev/null
+++ b/netbox/extras/migrations/0042_customfield_manager.py
@@ -0,0 +1,20 @@
+# Generated by Django 3.0.5 on 2020-05-07 21:06
+
+from django.db import migrations
+import extras.models.customfields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('extras', '0041_tag_description'),
+ ]
+
+ operations = [
+ migrations.AlterModelManagers(
+ name='customfield',
+ managers=[
+ ('objects', extras.models.customfields.CustomFieldManager()),
+ ],
+ ),
+ ]
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 47bccd98a..f00a52a5d 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -43,10 +43,7 @@ class CustomFieldModel(models.Model):
"""
Return a dictionary of custom fields for a single object in the form {: value}.
"""
-
- # Find all custom fields applicable to this type of object
- content_type = ContentType.objects.get_for_model(self)
- fields = CustomField.objects.filter(obj_type=content_type)
+ fields = CustomField.objects.get_for_model(self)
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
@@ -57,6 +54,37 @@ class CustomFieldModel(models.Model):
return OrderedDict([(field, None) for field in fields])
+class CustomFieldManager(models.Manager):
+ use_in_migrations = True
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Initialize a cache for fetched CustomFields
+ self._cache = {}
+
+ def get_for_model(self, model):
+ """
+ Return all CustomFields assigned to the given model.
+ """
+ model = model._meta.concrete_model
+
+ # First try to return from cache
+ try:
+ return self._cache[model]
+ except KeyError:
+ pass
+
+ # Fetch from the database if the model's CustomFields have not been cached
+ content_type = ContentType.objects.get_for_model(model)
+ customfields = CustomField.objects.filter(obj_type=content_type)
+
+ # Cache the retrieved CustomFields
+ self._cache[model] = customfields
+
+ return customfields
+
+
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
@@ -106,6 +134,8 @@ class CustomField(models.Model):
help_text='Fields with higher weights appear lower in a form.'
)
+ objects = CustomFieldManager()
+
class Meta:
ordering = ['weight', 'name']
From e3be5f84684b9c0f032d6aea830a1792b38db411 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 8 May 2020 10:05:05 -0400
Subject: [PATCH 009/300] Remove local caching attempt
---
netbox/extras/models/customfields.py | 16 +---------------
1 file changed, 1 insertion(+), 15 deletions(-)
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index f00a52a5d..f3e217039 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -1,3 +1,4 @@
+import logging
from collections import OrderedDict
from datetime import date
@@ -57,31 +58,16 @@ class CustomFieldModel(models.Model):
class CustomFieldManager(models.Manager):
use_in_migrations = True
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Initialize a cache for fetched CustomFields
- self._cache = {}
-
def get_for_model(self, model):
"""
Return all CustomFields assigned to the given model.
"""
model = model._meta.concrete_model
- # First try to return from cache
- try:
- return self._cache[model]
- except KeyError:
- pass
-
# Fetch from the database if the model's CustomFields have not been cached
content_type = ContentType.objects.get_for_model(model)
customfields = CustomField.objects.filter(obj_type=content_type)
- # Cache the retrieved CustomFields
- self._cache[model] = customfields
-
return customfields
From 745c9a9c2b8cb1286dad19314f3b08d17c56f057 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 8 May 2020 12:18:08 -0400
Subject: [PATCH 010/300] Add test for CustomFieldManager.get_for_model()
---
netbox/extras/models/customfields.py | 9 ++-------
netbox/extras/tests/test_customfields.py | 13 +++++++++++++
2 files changed, 15 insertions(+), 7 deletions(-)
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index f3e217039..62e2ca4df 100644
--- a/netbox/extras/models/customfields.py
+++ b/netbox/extras/models/customfields.py
@@ -62,13 +62,8 @@ class CustomFieldManager(models.Manager):
"""
Return all CustomFields assigned to the given model.
"""
- model = model._meta.concrete_model
-
- # Fetch from the database if the model's CustomFields have not been cached
- content_type = ContentType.objects.get_for_model(model)
- customfields = CustomField.objects.filter(obj_type=content_type)
-
- return customfields
+ content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
+ return self.get_queryset().filter(obj_type=content_type)
class CustomField(models.Model):
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index d76532437..c94d8cd3f 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -99,6 +99,19 @@ class CustomFieldTest(TestCase):
cf.delete()
+class CustomFieldManagerTest(TestCase):
+
+ def setUp(self):
+ content_type = ContentType.objects.get_for_model(Site)
+ custom_field = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo')
+ custom_field.save()
+ custom_field.obj_type.set([content_type])
+
+ def test_get_for_model(self):
+ self.assertEqual(CustomField.objects.get_for_model(Site).count(), 1)
+ self.assertEqual(CustomField.objects.get_for_model(VirtualMachine).count(), 0)
+
+
class CustomFieldAPITest(APITestCase):
@classmethod
From 6624fc607602502bd40f7124cc98bb050d96c01c Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 8 May 2020 17:30:25 -0400
Subject: [PATCH 011/300] Initial work on #554 (WIP)
---
netbox/netbox/settings.py | 1 +
netbox/users/admin.py | 9 ++-
.../users/migrations/0007_objectpermission.py | 36 +++++++++++
netbox/users/models.py | 55 +++++++++++++++-
netbox/users/tests/test_permissions.py | 62 +++++++++++++++++++
netbox/utilities/auth_backends.py | 42 +++++++++++++
6 files changed, 203 insertions(+), 2 deletions(-)
create mode 100644 netbox/users/migrations/0007_objectpermission.py
create mode 100644 netbox/users/tests/test_permissions.py
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index bf660696b..5c48ee620 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -335,6 +335,7 @@ TEMPLATES = [
AUTHENTICATION_BACKENDS = [
REMOTE_AUTH_BACKEND,
'utilities.auth_backends.ViewExemptModelBackend',
+ 'utilities.auth_backends.ObjectPermissionBackend',
]
# Internationalization
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 42e651712..fcaeb4ef0 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -3,7 +3,7 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import User
-from .models import Token, UserConfig
+from .models import ObjectPermission, Token, UserConfig
# Unregister the built-in UserAdmin so that we can use our custom admin view below
admin.site.unregister(User)
@@ -43,3 +43,10 @@ class TokenAdmin(admin.ModelAdmin):
list_display = [
'key', 'user', 'created', 'expires', 'write_enabled', 'description'
]
+
+
+@admin.register(ObjectPermission)
+class ObjectPermissionAdmin(admin.ModelAdmin):
+ list_display = [
+ 'model', 'can_view', 'can_add', 'can_change', 'can_delete'
+ ]
diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py
new file mode 100644
index 000000000..d805c3379
--- /dev/null
+++ b/netbox/users/migrations/0007_objectpermission.py
@@ -0,0 +1,36 @@
+# Generated by Django 3.0.6 on 2020-05-08 20:18
+
+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),
+ ('auth', '0011_update_proxy_permissions'),
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('users', '0006_create_userconfigs'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ObjectPermission',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('attrs', django.contrib.postgres.fields.jsonb.JSONField()),
+ ('can_view', models.BooleanField(default=False)),
+ ('can_add', models.BooleanField(default=False)),
+ ('can_change', models.BooleanField(default=False)),
+ ('can_delete', models.BooleanField(default=False)),
+ ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
+ ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+ ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'unique_together': {('model', 'attrs')},
+ },
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index ea5762232..f2002ae95 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -1,8 +1,10 @@
import binascii
import os
-from django.contrib.auth.models import User
+from django.contrib.auth.models import Group, User
+from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
+from django.core.exceptions import FieldError, ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models.signals import post_save
@@ -190,3 +192,54 @@ class Token(models.Model):
if self.expires is None or timezone.now() < self.expires:
return False
return True
+
+
+class ObjectPermission(models.Model):
+ """
+ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
+ identified by ORM query parameters.
+ """
+ users = models.ManyToManyField(
+ to=User,
+ blank=True,
+ related_name='object_permissions'
+ )
+ groups = models.ManyToManyField(
+ to=Group,
+ blank=True,
+ related_name='object_permissions'
+ )
+ model = models.ForeignKey(
+ to=ContentType,
+ on_delete=models.CASCADE
+ )
+ attrs = JSONField(
+ verbose_name='Attributes'
+ )
+ can_view = models.BooleanField(
+ default=False
+ )
+ can_add = models.BooleanField(
+ default=False
+ )
+ can_change = models.BooleanField(
+ default=False
+ )
+ can_delete = models.BooleanField(
+ default=False
+ )
+
+ class Meta:
+ unique_together = ('model', 'attrs')
+
+ def clean(self):
+
+ # Validate the specified model attributes by attempting to execute a query. We don't care whether the query
+ # returns anything; we just want to make sure the specified attributes are valid.
+ model = self.model.model_class()
+ try:
+ model.objects.filter(**self.attrs).exists()
+ except FieldError as e:
+ raise ValidationError({
+ 'attrs': f'Invalid attributes for {model}: {e}'
+ })
diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py
new file mode 100644
index 000000000..f73fd8f43
--- /dev/null
+++ b/netbox/users/tests/test_permissions.py
@@ -0,0 +1,62 @@
+from django.contrib.contenttypes.models import ContentType
+from django.contrib.auth.models import Permission, User
+from django.test import TestCase, override_settings
+
+from dcim.models import Site
+from tenancy.models import Tenant
+from users.models import ObjectPermission
+
+
+class UserConfigTest(TestCase):
+
+ def setUp(self):
+
+ self.user = User.objects.create_user(username='testuser')
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+ Site.objects.bulk_create((
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2', tenant=tenant),
+ Site(name='Site 3', slug='site-3'),
+ ))
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_permission_view_object(self):
+
+ # Sanity check to ensure the user has no model-level permission
+ self.assertFalse(self.user.has_perm('dcim.view_site'))
+
+ # The permission check for a specific object should fail.
+ sites = Site.objects.all()
+ self.assertFalse(self.user.has_perm('dcim.view_site', sites[0]))
+
+ # Create and assign a new ObjectPermission specifying the first site by name.
+ ct = ContentType.objects.get_for_model(sites[0])
+ object_perm = ObjectPermission(
+ model=ct,
+ attrs={'name': 'Site 1'},
+ can_view=True
+ )
+ object_perm.save()
+ self.user.object_permissions.add(object_perm)
+
+ # The test user should have permission to view only the first site.
+ self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
+ self.assertFalse(self.user.has_perm('dcim.view_site', sites[1]))
+
+ # Create a second ObjectPermission matching sites by assigned tenant.
+ object_perm = ObjectPermission(
+ model=ct,
+ attrs={'tenant__name': 'Tenant 1'},
+ can_view=True
+ )
+ object_perm.save()
+ self.user.object_permissions.add(object_perm)
+
+ # The user should now able to view the first two sites, but not the third.
+ self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
+ self.assertTrue(self.user.has_perm('dcim.view_site', sites[1]))
+ self.assertFalse(self.user.has_perm('dcim.view_site', sites[2]))
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 6342bad2b..0d20fe02f 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -3,6 +3,10 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+
+from users.models import ObjectPermission
class ViewExemptModelBackend(ModelBackend):
@@ -31,6 +35,44 @@ class ViewExemptModelBackend(ModelBackend):
return super().has_perm(user_obj, perm, obj)
+class ObjectPermissionBackend(ModelBackend):
+ """
+ Evaluates permission of a user to access or modify a specific object based on the assignment of ObjectPermissions
+ either to the user directly or to a group of which the user is a member. Model-level permissions supersede this
+ check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend
+ will grant permission before this backend is evaluated for permission to view a specific site.
+ """
+ def has_perm(self, user_obj, perm, obj=None):
+
+ # This backend only checks for permissions on specific objects
+ if obj is None:
+ return
+
+ app, codename = perm.split('.')
+ action, model_name = codename.split('_')
+ model = obj._meta.model
+
+ # Check that the requested permission applies to the specified object
+ if model._meta.model_name != model_name:
+ raise ValueError(f"Invalid permission {perm} for model {model}")
+
+ # Retrieve user's permissions for this model
+ # This can probably be cached
+ obj_permissions = ObjectPermission.objects.filter(
+ Q(users=user_obj) | Q(groups__user=user_obj),
+ model=ContentType.objects.get_for_model(obj),
+ **{f'can_{action}': True}
+ )
+
+ for perm in obj_permissions:
+
+ # Attempt to retrieve the model from the database using the
+ # attributes defined in the ObjectPermission. If we have a
+ # match, assert that the user has permission.
+ if model.objects.filter(pk=obj.pk, **perm.attrs).exists():
+ return True
+
+
class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
"""
Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
From 465d3ae1af4e4129d091776ed1e68d64e12bceeb Mon Sep 17 00:00:00 2001
From: kobayashi
Date: Sat, 9 May 2020 23:06:24 -0400
Subject: [PATCH 012/300] Fix: 4607 Missing token context help
---
docs/api/authentication.md | 13 +------------
docs/models/users/token.md | 12 ++++++++++++
docs/release-notes/version-2.8.md | 1 +
3 files changed, 14 insertions(+), 12 deletions(-)
create mode 100644 docs/models/users/token.md
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
index 8e38c4de9..e8e6ddc96 100644
--- a/docs/api/authentication.md
+++ b/docs/api/authentication.md
@@ -2,18 +2,7 @@
The NetBox API employs token-based authentication. For convenience, cookie authentication can also be used when navigating the browsable API.
-## Tokens
-
-A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
-
-!!! note
- The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
-
-Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
-
-By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
-
-Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
+{!docs/models/users/token.md!}
## Authenticating to the API
diff --git a/docs/models/users/token.md b/docs/models/users/token.md
new file mode 100644
index 000000000..bbeb2284b
--- /dev/null
+++ b/docs/models/users/token.md
@@ -0,0 +1,12 @@
+## Tokens
+
+A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
+
+!!! note
+ The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
+
+Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
+
+By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.
+
+Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox.
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index ff6ba4e50..aea825ce3 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -6,6 +6,7 @@ v2.8.4 (FUTURE)
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
+* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
---
From cea01e037a1e9f2e6ec3006fb1b3c24119e427ab Mon Sep 17 00:00:00 2001
From: weisdd <46579601+weisdd@users.noreply.github.com>
Date: Mon, 11 May 2020 16:14:25 +0300
Subject: [PATCH 013/300] Fix: incorrect DeviceConnectionsReport in reports.md
(#4606)
Since the CONNECTION_STATUS_PLANNED constant is gone from dcim.constants, the DeviceConnectionsReport script is no longer correct.
The suggested fix is based on the fact that console_port.connection_status and power_port.connection_status currently have the following set of values:
* None = A cable is not connected to a Console Server Port or it's connected to a Rear/Front Port;
* False = A cable is connected to a Console Server Port and marked as Planned;
* True = A cable is connected to a Console Server Port and marked as Installed.
---
docs/additional-features/reports.md | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md
index 6deddc140..e845117c0 100644
--- a/docs/additional-features/reports.md
+++ b/docs/additional-features/reports.md
@@ -33,7 +33,6 @@ Within each report class, we'll create a number of test methods to execute our r
```
from dcim.choices import DeviceStatusChoices
-from dcim.constants import CONNECTION_STATUS_PLANNED
from dcim.models import ConsolePort, Device, PowerPort
from extras.reports import Report
@@ -51,7 +50,7 @@ class DeviceConnectionsReport(Report):
console_port.device,
"No console connection defined for {}".format(console_port.name)
)
- elif console_port.connection_status == CONNECTION_STATUS_PLANNED:
+ elif not console_port.connection_status:
self.log_warning(
console_port.device,
"Console connection for {} marked as planned".format(console_port.name)
@@ -67,7 +66,7 @@ class DeviceConnectionsReport(Report):
for power_port in PowerPort.objects.filter(device=device):
if power_port.connected_endpoint is not None:
connected_ports += 1
- if power_port.connection_status == CONNECTION_STATUS_PLANNED:
+ if not power_port.connection_status:
self.log_warning(
device,
"Power connection for {} marked as planned".format(power_port.name)
From 4b5d64939df2b187306e58dcf313915968dbb3b8 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 11 May 2020 11:51:11 -0400
Subject: [PATCH 014/300] Introduced ObjectPermissionRequiredMixin
---
netbox/dcim/views.py | 3 ++-
netbox/netbox/authentication.py | 40 +++++++++++++++++++++++++++++++++
2 files changed, 42 insertions(+), 1 deletion(-)
create mode 100644 netbox/netbox/authentication.py
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index cd1b4edf4..5afa46295 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -21,6 +21,7 @@ from extras.models import Graph
from extras.views import ObjectConfigContextView
from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from netbox.authentication import ObjectPermissionRequiredMixin
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import csv_format
@@ -185,7 +186,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Sites
#
-class SiteListView(PermissionRequiredMixin, ObjectListView):
+class SiteListView(ObjectPermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_site'
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
new file mode 100644
index 000000000..58fd4380a
--- /dev/null
+++ b/netbox/netbox/authentication.py
@@ -0,0 +1,40 @@
+from django.contrib.auth.mixins import AccessMixin
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
+
+from users.models import ObjectPermission
+
+
+class ObjectPermissionRequiredMixin(AccessMixin):
+ permission_required = None
+
+ def has_permission(self):
+
+ # First, check whether the user has a model-level permission assigned
+ if self.request.user.has_perm(self.permission_required):
+ return True
+
+ # If not, check for an object-level permission
+ app, codename = self.permission_required.split('.')
+ action, model_name = codename.split('_')
+ model = self.queryset.model
+ obj_permissions = ObjectPermission.objects.filter(
+ Q(users=self.request.user) | Q(groups__user=self.request.user),
+ model=ContentType.objects.get_for_model(model),
+ **{f'can_{action}': True}
+ )
+ if obj_permissions:
+
+ # Update the view's QuerySet to filter only the permitted objects
+ # TODO: Do this more efficiently
+ for perm in obj_permissions:
+ self.queryset = self.queryset.filter(**perm.attrs)
+
+ return True
+
+ return False
+
+ def dispatch(self, request, *args, **kwargs):
+ if not self.has_permission():
+ return self.handle_no_permission()
+ return super().dispatch(request, *args, **kwargs)
From 63f842c7db791e68221e888e0c16403a0281ff93 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 11 May 2020 14:32:10 -0400
Subject: [PATCH 015/300] Implement ObjectPermissionManager
---
netbox/dcim/views.py | 9 ++---
netbox/netbox/authentication.py | 16 +++------
netbox/users/models.py | 35 +++++++++++++++++++
netbox/utilities/auth_backends.py | 21 ++++-------
netbox/utilities/views.py | 58 +++++++++++++++++++------------
5 files changed, 85 insertions(+), 54 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index b3f97995c..03e375d35 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -194,12 +194,13 @@ class SiteListView(ObjectPermissionRequiredMixin, ObjectListView):
table = tables.SiteTable
-class SiteView(PermissionRequiredMixin, View):
+class SiteView(ObjectPermissionRequiredMixin, View):
permission_required = 'dcim.view_site'
+ queryset = Site.objects.prefetch_related('region', 'tenant__group')
def get(self, request, slug):
- site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug)
+ site = get_object_or_404(self.queryset, slug=slug)
stats = {
'rack_count': Rack.objects.filter(site=site).count(),
'device_count': Device.objects.filter(site=site).count(),
@@ -219,7 +220,7 @@ class SiteView(PermissionRequiredMixin, View):
})
-class SiteCreateView(PermissionRequiredMixin, ObjectEditView):
+class SiteCreateView(ObjectPermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_site'
queryset = Site.objects.all()
model_form = forms.SiteForm
@@ -231,7 +232,7 @@ class SiteEditView(SiteCreateView):
permission_required = 'dcim.change_site'
-class SiteDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
queryset = Site.objects.all()
default_return_url = 'dcim:site_list'
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 58fd4380a..850189a83 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -14,22 +14,14 @@ class ObjectPermissionRequiredMixin(AccessMixin):
if self.request.user.has_perm(self.permission_required):
return True
- # If not, check for an object-level permission
+ # If not, check for object-level permissions
app, codename = self.permission_required.split('.')
action, model_name = codename.split('_')
model = self.queryset.model
- obj_permissions = ObjectPermission.objects.filter(
- Q(users=self.request.user) | Q(groups__user=self.request.user),
- model=ContentType.objects.get_for_model(model),
- **{f'can_{action}': True}
- )
- if obj_permissions:
-
+ attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, model, action)
+ if attrs:
# Update the view's QuerySet to filter only the permitted objects
- # TODO: Do this more efficiently
- for perm in obj_permissions:
- self.queryset = self.queryset.filter(**perm.attrs)
-
+ self.queryset = self.queryset.filter(**attrs)
return True
return False
diff --git a/netbox/users/models.py b/netbox/users/models.py
index f2002ae95..bb2093f05 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -7,6 +7,7 @@ from django.contrib.postgres.fields import JSONField
from django.core.exceptions import FieldError, ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
+from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
@@ -194,6 +195,38 @@ class Token(models.Model):
return True
+class ObjectPermissionManager(models.Manager):
+
+ def get_attr_constraints(self, user, model, action):
+ """
+ Compile all ObjectPermission attributes applicable to a specific combination of user, model, and action. Returns
+ a dictionary that can be passed directly to .filter() on a QuerySet.
+ """
+ assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
+
+ qs = self.get_queryset().filter(
+ Q(users=user) | Q(groups__user=user),
+ model=ContentType.objects.get_for_model(model),
+ **{f'can_{action}': True}
+ )
+
+ attrs = {}
+ for perm in qs:
+ attrs.update(perm.attrs)
+
+ return attrs
+
+ def validate_queryset(self, queryset, user, action):
+ """
+ Check that the specified user has permission to perform the specified action on all objects in the QuerySet.
+ """
+ assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
+
+ model = queryset.model
+ attrs = self.get_attr_constraints(user, model, action)
+ return queryset.count() == model.objects.filter(**attrs).count()
+
+
class ObjectPermission(models.Model):
"""
A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
@@ -229,6 +262,8 @@ class ObjectPermission(models.Model):
default=False
)
+ objects = ObjectPermissionManager()
+
class Meta:
unique_together = ('model', 'attrs')
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 0d20fe02f..7deb9b0de 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -56,21 +56,12 @@ class ObjectPermissionBackend(ModelBackend):
if model._meta.model_name != model_name:
raise ValueError(f"Invalid permission {perm} for model {model}")
- # Retrieve user's permissions for this model
- # This can probably be cached
- obj_permissions = ObjectPermission.objects.filter(
- Q(users=user_obj) | Q(groups__user=user_obj),
- model=ContentType.objects.get_for_model(obj),
- **{f'can_{action}': True}
- )
-
- for perm in obj_permissions:
-
- # Attempt to retrieve the model from the database using the
- # attributes defined in the ObjectPermission. If we have a
- # match, assert that the user has permission.
- if model.objects.filter(pk=obj.pk, **perm.attrs).exists():
- return True
+ # Attempt to retrieve the model from the database using the
+ # attributes defined in the ObjectPermission. If we have a
+ # match, assert that the user has permission.
+ attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action)
+ if model.objects.filter(pk=obj.pk, **attrs).exists():
+ return True
class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 076f2ad14..d9eace90b 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -4,7 +4,7 @@ from copy import deepcopy
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldDoesNotExist, ValidationError
+from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
@@ -23,6 +23,7 @@ from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
+from users.models import ObjectPermission
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
from utilities.utils import csv_format, prepare_cloned_fields
@@ -262,32 +263,43 @@ class ObjectEditView(GetReturnURLMixin, View):
if form.is_valid():
logger.debug("Form validation was successful")
- obj = form.save()
- msg = '{} {}'.format(
- 'Created' if not form.instance.pk else 'Modified',
- self.queryset.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:
- msg = '{} {}'.format(msg, escape(obj))
- messages.success(request, mark_safe(msg))
+ try:
+ with transaction.atomic():
+ obj = form.save()
- if '_addanother' in request.POST:
+ # Check that the new object conforms with any assigned object-level permissions
+ self.queryset.get(pk=obj.pk)
- # If the object has clone_fields, pre-populate a new instance of the form
- if hasattr(obj, 'clone_fields'):
- url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
- return redirect(url)
+ msg = '{} {}'.format(
+ 'Created' if not form.instance.pk else 'Modified',
+ self.queryset.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:
+ msg = '{} {}'.format(msg, escape(obj))
+ messages.success(request, mark_safe(msg))
- return redirect(request.get_full_path())
+ if '_addanother' in request.POST:
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
+ # If the object has clone_fields, pre-populate a new instance of the form
+ if hasattr(obj, 'clone_fields'):
+ url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
+ return redirect(url)
+
+ return redirect(request.get_full_path())
+
+ return_url = form.cleaned_data.get('return_url')
+ if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
+ return redirect(return_url)
+ else:
+ return redirect(self.get_return_url(request, obj))
+
+ except ObjectDoesNotExist:
+ logger.debug("Object save failed due to object-level permissions violation")
+ # TODO: Link user to personal permissions view
+ form.add_error(None, "Object save failed due to object-level permissions violation")
else:
logger.debug("Form validation failed")
From 41361ce2a2cd8ace762be10d891f31572dcbec51 Mon Sep 17 00:00:00 2001
From: Daniel Sheppard
Date: Mon, 11 May 2020 16:10:23 -0500
Subject: [PATCH 016/300] Fixes: #4618 - Add group creation and correct user
creation group syntax
---
docs/installation/3-netbox.md | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md
index 5237e617e..c583d08fe 100644
--- a/docs/installation/3-netbox.md
+++ b/docs/installation/3-netbox.md
@@ -78,7 +78,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s
CentOS users may need to create the `netbox` group first.
```
-# adduser --system --group netbox
+# groupadd --system netbox
+# adduser --system --gid netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/
```
From daa2c6ff215cdef5f9f99b74cd12838a1a8a5a9b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 11 May 2020 17:19:11 -0400
Subject: [PATCH 017/300] Always pass obj=None to ModelBackend
---
netbox/utilities/auth_backends.py | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 7deb9b0de..65154a6f8 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -3,8 +3,6 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
from django.contrib.auth.models import Group, Permission
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
from users.models import ObjectPermission
@@ -26,13 +24,16 @@ class ViewExemptModelBackend(ModelBackend):
'*' in settings.EXEMPT_VIEW_PERMISSIONS
) or (
# This specific model is exempt from view permission enforcement
- '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS
+ '.'.join((app, model)) in settings.EXEMPT_VIEW_PERMISSIONS
):
return True
except ValueError:
pass
- return super().has_perm(user_obj, perm, obj)
+ # Fall back to ModelBackend's default behavior, with one exception: Set obj to None. Model-level permissions
+ # override object-level permissions, so if a user has the model-level permission we can ignore any specified
+ # object. (By default, ModelBackend will return False if an object is specified.)
+ return super().has_perm(user_obj, perm, None)
class ObjectPermissionBackend(ModelBackend):
@@ -56,9 +57,8 @@ class ObjectPermissionBackend(ModelBackend):
if model._meta.model_name != model_name:
raise ValueError(f"Invalid permission {perm} for model {model}")
- # Attempt to retrieve the model from the database using the
- # attributes defined in the ObjectPermission. If we have a
- # match, assert that the user has permission.
+ # Attempt to retrieve the model from the database using the attributes defined in the
+ # ObjectPermission. If we have a match, assert that the user has permission.
attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action)
if model.objects.filter(pk=obj.pk, **attrs).exists():
return True
From c90f680284838475b1ed8dec45d33a4c10f47c22 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 12 May 2020 15:42:44 -0400
Subject: [PATCH 018/300] Cache object-level permissions on the User instance
for evaluation
---
netbox/netbox/authentication.py | 48 +++++++++++++++++++++----------
netbox/users/models.py | 17 ++++-------
netbox/utilities/auth_backends.py | 43 +++++++++++++++++++++++----
3 files changed, 76 insertions(+), 32 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 850189a83..0b896969b 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -1,32 +1,50 @@
from django.contrib.auth.mixins import AccessMixin
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
+from django.core.exceptions import ImproperlyConfigured
from users.models import ObjectPermission
class ObjectPermissionRequiredMixin(AccessMixin):
+ """
+ Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
+ permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
+ to return only those objects on which the user is permitted to perform the specified action.
+ """
permission_required = None
def has_permission(self):
+ # First, check whether the user is granted the requested permissions from any backend.
+ if not self.request.user.has_perm(self.permission_required):
+ return False
- # First, check whether the user has a model-level permission assigned
- if self.request.user.has_perm(self.permission_required):
+ # Next, determine whether the permission is model-level or object-level. Model-level permissions grant the
+ # specified action to *all* objects, so no further action is needed.
+ if self.permission_required in self.request.user._perm_cache:
return True
- # If not, check for object-level permissions
- app, codename = self.permission_required.split('.')
- action, model_name = codename.split('_')
- model = self.queryset.model
- attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, model, action)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(**attrs)
- return True
-
- return False
+ # If the permission is granted only at the object level, filter the view's queryset to return only objects
+ # on which the user is permitted to perform the specified action.
+ if self.permission_required in self.request.user._obj_perm_cache:
+ attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required)
+ if attrs:
+ # Update the view's QuerySet to filter only the permitted objects
+ self.queryset = self.queryset.filter(**attrs)
+ return True
def dispatch(self, request, *args, **kwargs):
+ if self.permission_required is None:
+ raise ImproperlyConfigured(
+ '{0} is missing the permission_required attribute. Define {0}.permission_required, or override '
+ '{0}.get_permission_required().'.format(self.__class__.__name__)
+ )
+
+ if not hasattr(self, 'queryset'):
+ raise ImproperlyConfigured(
+ '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
+ 'a base queryset'.format(self.__class__.__name__)
+ )
+
if not self.has_permission():
return self.handle_no_permission()
+
return super().dispatch(request, *args, **kwargs)
diff --git a/netbox/users/models.py b/netbox/users/models.py
index bb2093f05..452e91c21 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -197,16 +197,19 @@ class Token(models.Model):
class ObjectPermissionManager(models.Manager):
- def get_attr_constraints(self, user, model, action):
+ def get_attr_constraints(self, user, perm):
"""
Compile all ObjectPermission attributes applicable to a specific combination of user, model, and action. Returns
a dictionary that can be passed directly to .filter() on a QuerySet.
"""
+ app_label, codename = perm.split('.')
+ action, model_name = codename.split('_')
assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
+ content_type = ContentType.objects.get(app_label=app_label, model=model_name)
qs = self.get_queryset().filter(
Q(users=user) | Q(groups__user=user),
- model=ContentType.objects.get_for_model(model),
+ model=content_type,
**{f'can_{action}': True}
)
@@ -216,16 +219,6 @@ class ObjectPermissionManager(models.Manager):
return attrs
- def validate_queryset(self, queryset, user, action):
- """
- Check that the specified user has permission to perform the specified action on all objects in the QuerySet.
- """
- assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
-
- model = queryset.model
- attrs = self.get_attr_constraints(user, model, action)
- return queryset.count() == model.objects.filter(**attrs).count()
-
class ObjectPermission(models.Model):
"""
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 65154a6f8..f4290e917 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -3,6 +3,8 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
from users.models import ObjectPermission
@@ -43,23 +45,54 @@ class ObjectPermissionBackend(ModelBackend):
check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend
will grant permission before this backend is evaluated for permission to view a specific site.
"""
+ def _get_all_permissions(self, user_obj):
+ """
+ Retrieve all ObjectPermissions assigned to this User (either directly or through a Group) and return the model-
+ level equivalent codenames.
+ """
+ perm_names = set()
+ for obj_perm in ObjectPermission.objects.filter(
+ Q(users=user_obj) | Q(groups__user=user_obj)
+ ).prefetch_related('model'):
+ for action in ['view', 'add', 'change', 'delete']:
+ if getattr(obj_perm, f"can_{action}"):
+ perm_names.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}")
+ return perm_names
+
+ def get_all_permissions(self, user_obj, obj=None):
+ """
+ Get all model-level permissions assigned by this backend. Permissions are cached on the User instance.
+ """
+ if not user_obj.is_active or user_obj.is_anonymous:
+ return set()
+ if not hasattr(user_obj, '_obj_perm_cache'):
+ user_obj._obj_perm_cache = self._get_all_permissions(user_obj)
+ return user_obj._obj_perm_cache
+
def has_perm(self, user_obj, perm, obj=None):
- # This backend only checks for permissions on specific objects
+ # If no object is specified, look for any matching ObjectPermissions. If one or more are found, this indicates
+ # that the user has permission to perform the requested action on at least *some* objects, but not necessarily
+ # on all of them.
if obj is None:
+ return perm in self.get_all_permissions(user_obj)
+
+ attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm)
+
+ # No ObjectPermissions found for this combination of user, model, and action
+ if not attrs:
return
- app, codename = perm.split('.')
- action, model_name = codename.split('_')
model = obj._meta.model
# Check that the requested permission applies to the specified object
- if model._meta.model_name != model_name:
+ app_label, codename = perm.split('.')
+ action, model_name = codename.split('_')
+ if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}")
# Attempt to retrieve the model from the database using the attributes defined in the
# ObjectPermission. If we have a match, assert that the user has permission.
- attrs = ObjectPermission.objects.get_attr_constraints(user_obj, obj, action)
if model.objects.filter(pk=obj.pk, **attrs).exists():
return True
From a275a30dcae507d42a1da0c319c44d73691e1de3 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 12 May 2020 16:07:07 -0400
Subject: [PATCH 019/300] Reimplement the ViewExemptModelBackend to explicitly
cache all exempted view permissions on the User instance
---
netbox/utilities/auth_backends.py | 39 ++++++++++++++-----------------
1 file changed, 18 insertions(+), 21 deletions(-)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index f4290e917..49dd8d0aa 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -3,7 +3,6 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
from django.contrib.auth.models import Group, Permission
-from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from users.models import ObjectPermission
@@ -14,28 +13,26 @@ class ViewExemptModelBackend(ModelBackend):
Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view
permission enforcement.
"""
- def has_perm(self, user_obj, perm, obj=None):
+ def _get_user_permissions(self, user_obj):
- # If this is a view permission, check whether the model has been exempted from enforcement
- try:
- app, codename = perm.split('.')
- action, model = codename.split('_')
- if action == 'view':
- if (
- # All models are exempt from view permission enforcement
- '*' in settings.EXEMPT_VIEW_PERMISSIONS
- ) or (
- # This specific model is exempt from view permission enforcement
- '.'.join((app, model)) in settings.EXEMPT_VIEW_PERMISSIONS
- ):
- return True
- except ValueError:
- pass
+ if not settings.EXEMPT_VIEW_PERMISSIONS:
+ # No view permissions have been exempted from enforcement, so fall back to the built-in logic.
+ return super()._get_user_permissions(user_obj)
- # Fall back to ModelBackend's default behavior, with one exception: Set obj to None. Model-level permissions
- # override object-level permissions, so if a user has the model-level permission we can ignore any specified
- # object. (By default, ModelBackend will return False if an object is specified.)
- return super().has_perm(user_obj, perm, None)
+ if '*' in settings.EXEMPT_VIEW_PERMISSIONS:
+ # All view permissions have been exempted from enforcement, so include all view permissions when fetching
+ # User permissions.
+ return Permission.objects.filter(
+ Q(user=user_obj) | Q(codename__startswith='view_')
+ )
+
+ # Return all Permissions that are either assigned to the user or that are view permissions listed in
+ # EXEMPT_VIEW_PERMISSIONS.
+ qs_filter = Q(user=user_obj)
+ for model in settings.EXEMPT_VIEW_PERMISSIONS:
+ app, name = model.split('.')
+ qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}')
+ return Permission.objects.filter(qs_filter)
class ObjectPermissionBackend(ModelBackend):
From 94d0ebbd7df8f45c7206edadeac02fa9fcfb9266 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 12 May 2020 16:40:04 -0400
Subject: [PATCH 020/300] Fix ObjectPermission attribute consolidation
---
netbox/netbox/authentication.py | 2 +-
netbox/users/models.py | 4 ++--
netbox/users/tests/test_permissions.py | 8 ++++----
netbox/utilities/auth_backends.py | 2 +-
4 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 0b896969b..2854d4cb9 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -28,7 +28,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required)
if attrs:
# Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(**attrs)
+ self.queryset = self.queryset.filter(attrs)
return True
def dispatch(self, request, *args, **kwargs):
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 452e91c21..70e7254e6 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -213,9 +213,9 @@ class ObjectPermissionManager(models.Manager):
**{f'can_{action}': True}
)
- attrs = {}
+ attrs = Q()
for perm in qs:
- attrs.update(perm.attrs)
+ attrs |= Q(**perm.attrs)
return attrs
diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py
index f73fd8f43..487543bd3 100644
--- a/netbox/users/tests/test_permissions.py
+++ b/netbox/users/tests/test_permissions.py
@@ -1,5 +1,5 @@
from django.contrib.contenttypes.models import ContentType
-from django.contrib.auth.models import Permission, User
+from django.contrib.auth.models import User
from django.test import TestCase, override_settings
from dcim.models import Site
@@ -7,7 +7,7 @@ from tenancy.models import Tenant
from users.models import ObjectPermission
-class UserConfigTest(TestCase):
+class ObjectPermissionTest(TestCase):
def setUp(self):
@@ -41,7 +41,7 @@ class UserConfigTest(TestCase):
can_view=True
)
object_perm.save()
- self.user.object_permissions.add(object_perm)
+ object_perm.users.add(self.user)
# The test user should have permission to view only the first site.
self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
@@ -54,7 +54,7 @@ class UserConfigTest(TestCase):
can_view=True
)
object_perm.save()
- self.user.object_permissions.add(object_perm)
+ object_perm.users.add(self.user)
# The user should now able to view the first two sites, but not the third.
self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 49dd8d0aa..9e56fd16c 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -90,7 +90,7 @@ class ObjectPermissionBackend(ModelBackend):
# Attempt to retrieve the model from the database using the attributes defined in the
# ObjectPermission. If we have a match, assert that the user has permission.
- if model.objects.filter(pk=obj.pk, **attrs).exists():
+ if model.objects.filter(attrs, pk=obj.pk).exists():
return True
From be5962fb3a409b12fcc768fdff7c0aec17739e27 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 12 May 2020 17:00:03 -0400
Subject: [PATCH 021/300] ObjectPermissionRequiredMixin should exempt
superusers
---
netbox/netbox/authentication.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 2854d4cb9..d85b2f124 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -19,7 +19,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
# Next, determine whether the permission is model-level or object-level. Model-level permissions grant the
# specified action to *all* objects, so no further action is needed.
- if self.permission_required in self.request.user._perm_cache:
+ if self.request.user.is_superuser or self.permission_required in self.request.user._perm_cache:
return True
# If the permission is granted only at the object level, filter the view's queryset to return only objects
From 1d93d9a63ad8f105d18bbac2a26dff4699fbb92f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 08:53:29 -0400
Subject: [PATCH 022/300] Fixes #4633: Bump django-rq to v2.3.2 to fix
ImportError with rq 1.4.0
---
docs/release-notes/version-2.8.md | 1 +
requirements.txt | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index aea825ce3..416ac2bc6 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -7,6 +7,7 @@ v2.8.4 (FUTURE)
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
+* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
---
diff --git a/requirements.txt b/requirements.txt
index c9f51cff0..79e4fdd9f 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,7 +6,7 @@ django-filter==2.2.0
django-mptt==0.11.0
django-pglocks==1.0.4
django-prometheus==2.0.0
-django-rq==2.3.1
+django-rq==2.3.2
django-tables2==2.3.1
django-taggit==1.2.0
django-taggit-serializer==0.1.7
From 569d4ee201bb61d0310283014cbe7a9a3d10f04f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 09:20:24 -0400
Subject: [PATCH 023/300] Closes #4632: Extend email configuration parameters
to support SSL/TLS
---
docs/configuration/optional-settings.md | 20 ++++++++++++--------
docs/release-notes/version-2.8.md | 4 ++++
netbox/netbox/configuration.example.py | 2 ++
netbox/netbox/settings.py | 8 ++++++--
4 files changed, 24 insertions(+), 10 deletions(-)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 3f2b29b87..4d5251f25 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -108,16 +108,20 @@ The file path to NetBox's documentation. This is used when presenting context-se
## EMAIL
-In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` setting:
+In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter:
-* SERVER - Host name or IP address of the email server (use `localhost` if running locally)
-* PORT - TCP port to use for the connection (default: 25)
-* USERNAME - Username with which to authenticate
-* PASSSWORD - Password with which to authenticate
-* TIMEOUT - Amount of time to wait for a connection (seconds)
-* FROM_EMAIL - Sender address for emails sent by NetBox
+* `SERVER` - Host name or IP address of the email server (use `localhost` if running locally)
+* `PORT` - TCP port to use for the connection (default: `25`)
+* `USERNAME` - Username with which to authenticate
+* `PASSSWORD` - Password with which to authenticate
+* `USE_SSL` - Use SSL when connecting to the server (default: `False`). Mutually exclusive with `USE_TLS`.
+* `USE_TLS` - Use TLS when connecting to the server (default: `False`). Mutually exclusive with `USE_SSL`.
+* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)
+* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional)
+* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`)
+* `FROM_EMAIL` - Sender address for emails sent by NetBox (default: `root@localhost`)
-Email is sent from NetBox only for critical events. If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
+Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration please use the django function [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail):
```
# python ./manage.py nbshell
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 416ac2bc6..e15df481d 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -2,6 +2,10 @@
v2.8.4 (FUTURE)
+### Enhancements
+
+* [#4632](https://github.com/netbox-community/netbox/issues/4632) - Extend email configuration parameters to support SSL/TLS
+
### Bug Fixes
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py
index 94497f3cd..a020c4322 100644
--- a/netbox/netbox/configuration.example.py
+++ b/netbox/netbox/configuration.example.py
@@ -108,6 +108,8 @@ EMAIL = {
'PORT': 25,
'USERNAME': '',
'PASSWORD': '',
+ 'USE_SSL': False,
+ 'USE_TLS': False,
'TIMEOUT': 10, # seconds
'FROM_EMAIL': '',
}
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index f928ca71e..0162fabd0 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -246,12 +246,16 @@ if SESSION_FILE_PATH is not None:
#
EMAIL_HOST = EMAIL.get('SERVER')
-EMAIL_PORT = EMAIL.get('PORT', 25)
EMAIL_HOST_USER = EMAIL.get('USERNAME')
EMAIL_HOST_PASSWORD = EMAIL.get('PASSWORD')
+EMAIL_PORT = EMAIL.get('PORT', 25)
+EMAIL_SSL_CERTFILE = EMAIL.get('SSL_CERTFILE')
+EMAIL_SSL_KEYFILE = EMAIL.get('SSL_KEYFILE')
+EMAIL_SUBJECT_PREFIX = '[NetBox] '
+EMAIL_USE_SSL = EMAIL.get('USE_SSL', False)
+EMAIL_USE_TLS = EMAIL.get('USE_TLS', False)
EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
-EMAIL_SUBJECT_PREFIX = '[NetBox] '
#
From 1461be20041b520b98166cf3129f94ab45acbe0f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 10:28:48 -0400
Subject: [PATCH 024/300] Fixes #4613: Fix tag assignment on config contexts
(regression from #4527)
---
docs/release-notes/version-2.8.md | 1 +
netbox/circuits/forms.py | 2 +-
netbox/dcim/forms.py | 3 +--
netbox/extras/forms.py | 11 ++++++++++-
netbox/ipam/forms.py | 2 +-
netbox/project-static/js/forms.js | 16 ++++++++--------
netbox/secrets/forms.py | 2 +-
netbox/tenancy/forms.py | 2 +-
netbox/virtualization/forms.py | 2 +-
9 files changed, 25 insertions(+), 16 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index e15df481d..7cc05a9f7 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -11,6 +11,7 @@ v2.8.4 (FUTURE)
* [#4598](https://github.com/netbox-community/netbox/issues/4598) - Display error message when invalid cable length is specified
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
+* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
---
diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py
index 427dc2e89..2185d1eab 100644
--- a/netbox/circuits/forms.py
+++ b/netbox/circuits/forms.py
@@ -1,9 +1,9 @@
from django import forms
-from taggit.forms import TagField
from dcim.models import Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+ TagField,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 2116d0948..5d3ec1019 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -9,13 +9,12 @@ from django.utils.safestring import mark_safe
from mptt.forms import TreeNodeChoiceField
from netaddr import EUI
from netaddr.core import AddrFormatError
-from taggit.forms import TagField
from timezone_field import TimeZoneFormField
from circuits.models import Circuit, Provider
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm,
- LocalConfigContextFilterForm,
+ LocalConfigContextFilterForm, TagField,
)
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
from ipam.models import IPAddress, VLAN
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index 384b3563b..469b55efd 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -2,7 +2,7 @@ from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField
-from taggit.forms import TagField
+from taggit.forms import TagField as TagField_
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
@@ -142,6 +142,15 @@ class CustomFieldFilterForm(forms.Form):
# Tags
#
+class TagField(TagField_):
+
+ def widget_attrs(self, widget):
+ # Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
+ return {
+ 'class': 'tagfield'
+ }
+
+
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 4e5a413dc..f5fd6e5f8 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -1,10 +1,10 @@
from django import forms
from django.core.validators import MaxValueValidator, MinValueValidator
-from taggit.forms import TagField
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+ TagField,
)
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js
index 06d4a742a..b97981f0e 100644
--- a/netbox/project-static/js/forms.js
+++ b/netbox/project-static/js/forms.js
@@ -292,9 +292,9 @@ $(document).ready(function() {
});
// API backed tags
- var tags = $('#id_tags');
+ var tags = $('#id_tags.tagfield');
if (tags.length > 0 && tags.val().length > 0){
- tags = $('#id_tags').val().split(/,\s*/);
+ tags = $('#id_tags.tagfield').val().split(/,\s*/);
} else {
tags = [];
}
@@ -306,8 +306,8 @@ $(document).ready(function() {
}
});
// Replace the django issued text input with a select element
- $('#id_tags').replaceWith('');
- $('#id_tags').select2({
+ $('#id_tags.tagfield').replaceWith('');
+ $('#id_tags.tagfield').select2({
tags: true,
data: tag_objs,
multiple: true,
@@ -354,14 +354,14 @@ $(document).ready(function() {
}
}
});
- $('#id_tags').closest('form').submit(function(event){
+ $('#id_tags.tagfield').closest('form').submit(function(event){
// django-taggit can only accept a single comma seperated string value
- var value = $('#id_tags').val();
+ var value = $('#id_tags.tagfield').val();
if (value.length > 0){
var final_tags = value.join(', ');
- $('#id_tags').val(null).trigger('change');
+ $('#id_tags.tagfield').val(null).trigger('change');
var option = new Option(final_tags, final_tags, true, true);
- $('#id_tags').append(option).trigger('change');
+ $('#id_tags.tagfield').append(option).trigger('change');
}
});
diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py
index 368a47590..089771bd8 100644
--- a/netbox/secrets/forms.py
+++ b/netbox/secrets/forms.py
@@ -1,11 +1,11 @@
from Crypto.Cipher import PKCS1_OAEP
from Crypto.PublicKey import RSA
from django import forms
-from taggit.forms import TagField
from dcim.models import Device
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
+ TagField,
)
from utilities.forms import (
APISelectMultiple, BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py
index 700d88b1d..bf100f43a 100644
--- a/netbox/tenancy/forms.py
+++ b/netbox/tenancy/forms.py
@@ -1,8 +1,8 @@
from django import forms
-from taggit.forms import TagField
from extras.forms import (
AddRemoveTagsForm, CustomFieldModelForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelCSVForm,
+ TagField,
)
from utilities.forms import (
APISelect, APISelectMultiple, BootstrapMixin, CommentField, CSVModelChoiceField, CSVModelForm,
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 0983b2432..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,6 +7,7 @@ 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, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
From fd0be35d99df6376736f5ced1a08984be4519f87 Mon Sep 17 00:00:00 2001
From: Daniel Sheppard
Date: Wed, 13 May 2020 09:26:56 -0500
Subject: [PATCH 025/300] #4634 - Correct inventory item table accessor
definition on manufacturer column
---
docs/release-notes/version-2.8.md | 1 +
netbox/dcim/tables.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index e15df481d..a3249915b 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -12,6 +12,7 @@ v2.8.4 (FUTURE)
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
+* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
---
diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py
index 3fef86394..9018625a0 100644
--- a/netbox/dcim/tables.py
+++ b/netbox/dcim/tables.py
@@ -1195,7 +1195,7 @@ class InventoryItemTable(BaseTable):
args=[Accessor('device.pk')]
)
manufacturer = tables.Column(
- accessor=Accessor('manufacturer.name')
+ accessor=Accessor('manufacturer')
)
discovered = BooleanColumn()
From 07fd92cd4c97b3535cf30cb7314737ad50bb0d24 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 16:25:22 -0400
Subject: [PATCH 026/300] Fixes #4629: Replicate assigned interface when
cloning IP addresses
---
docs/release-notes/version-2.8.md | 1 +
netbox/ipam/models.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 2c8ac7927..914f17cf7 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -12,6 +12,7 @@ v2.8.4 (FUTURE)
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
+* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py
index 84720845e..eeb985b7c 100644
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -640,7 +640,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
'dns_name', 'description',
]
clone_fields = [
- 'vrf', 'tenant', 'status', 'role', 'description',
+ 'vrf', 'tenant', 'status', 'role', 'description', 'interface',
]
STATUS_CLASS_MAP = {
From 96e05fb12d73badfd32865de3cd8f6542352df26 Mon Sep 17 00:00:00 2001
From: Tyler Bigler
Date: Mon, 11 May 2020 11:18:20 -0400
Subject: [PATCH 027/300] Notes on multiprocessing and gunicorn vs uwsgi
---
docs/additional-features/prometheus-metrics.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md
index 0aa944b74..448f925e0 100644
--- a/docs/additional-features/prometheus-metrics.md
+++ b/docs/additional-features/prometheus-metrics.md
@@ -32,3 +32,7 @@ This can be setup by first creating a shared directory and then adding this line
```
environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
```
+
+#### Accuracy
+
+If having long-term-accurate metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox w/ `gunicorn` in a containerized enviroment following the 1-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [this issue](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
\ No newline at end of file
From e0ebb8e7d894ab57594f37baceb9ee1b6624927a Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 17:08:48 -0400
Subject: [PATCH 028/300] Fixes #4617: Restore IP prefix depth notation in list
view
---
docs/release-notes/version-2.8.md | 1 +
netbox/ipam/tables.py | 2 ++
netbox/utilities/tables.py | 9 +++++++--
3 files changed, 10 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 914f17cf7..17e04dad3 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -12,6 +12,7 @@ v2.8.4 (FUTURE)
* [#4604](https://github.com/netbox-community/netbox/issues/4604) - Multi-position rear ports may only be connected to other rear ports
* [#4607](https://github.com/netbox-community/netbox/issues/4607) - Missing Contextual help for API Tokens
* [#4613](https://github.com/netbox-community/netbox/issues/4613) - Fix tag assignment on config contexts (regression from #4527)
+* [#4617](https://github.com/netbox-community/netbox/issues/4617) - Restore IP prefix depth notation in list view
* [#4629](https://github.com/netbox-community/netbox/issues/4629) - Replicate assigned interface when cloning IP addresses
* [#4633](https://github.com/netbox-community/netbox/issues/4633) - Bump django-rq to v2.3.2 to fix ImportError with rq 1.4.0
* [#4634](https://github.com/netbox-community/netbox/issues/4634) - Inventory Item List view exception caused by incorrect accessor definition
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index 23bf14653..d8b50c11d 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -378,6 +378,8 @@ class PrefixTable(BaseTable):
verbose_name='Pool'
)
+ add_prefetch = False
+
class Meta(BaseTable.Meta):
model = Prefix
fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'is_pool', 'description')
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index 0702936b5..97108b5b2 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -1,6 +1,5 @@
import django_tables2 as tables
from django.core.exceptions import FieldDoesNotExist
-from django.db.models import ForeignKey
from django.db.models.fields.related import RelatedField
from django.utils.safestring import mark_safe
from django_tables2.data import TableQuerysetData
@@ -9,7 +8,13 @@ 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()).
"""
+ add_prefetch = True
+
class Meta:
attrs = {
'class': 'table table-hover table-headings',
@@ -50,7 +55,7 @@ class BaseTable(tables.Table):
self.sequence.append('actions')
# Dynamically update the table's QuerySet to ensure related fields are pre-fetched
- if isinstance(self.data, TableQuerysetData):
+ if self.add_prefetch and isinstance(self.data, TableQuerysetData):
model = getattr(self.Meta, 'model')
prefetch_fields = []
for column in self.columns:
From 29abcbced8780fab47e8cd85c50e2b012953e0aa Mon Sep 17 00:00:00 2001
From: Tyler Bigler
Date: Wed, 13 May 2020 17:13:41 -0400
Subject: [PATCH 029/300] Grammar improvements
---
docs/additional-features/prometheus-metrics.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md
index 448f925e0..1429fb0a7 100644
--- a/docs/additional-features/prometheus-metrics.md
+++ b/docs/additional-features/prometheus-metrics.md
@@ -35,4 +35,4 @@ environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
#### Accuracy
-If having long-term-accurate metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox w/ `gunicorn` in a containerized enviroment following the 1-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [this issue](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
\ No newline at end of file
+If having accurate long-term metrics in a multiprocess environment is important to you then it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
\ No newline at end of file
From 2900013118021b48010e6b05595ee85a7f106f3c Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 17:24:25 -0400
Subject: [PATCH 030/300] Release v2.8.4
---
docs/release-notes/version-2.8.md | 2 +-
netbox/netbox/settings.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 17e04dad3..5d8687588 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,6 +1,6 @@
# NetBox v2.8
-v2.8.4 (FUTURE)
+v2.8.4 (2020-05-13)
### Enhancements
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 0162fabd0..f06a27980 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.4-dev'
+VERSION = '2.8.4'
# Hostname
HOSTNAME = platform.node()
From 422eeddbef30369cb6630027d94162423e8c2c6f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 13 May 2020 17:32:27 -0400
Subject: [PATCH 031/300] Post-release version bump
---
netbox/netbox/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index f06a27980..56fd9bb0f 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.4'
+VERSION = '2.8.5-dev'
# Hostname
HOSTNAME = platform.node()
From f54fb67efc621a5f0198dc7ac525e44476a5381a Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 14 May 2020 13:49:13 -0400
Subject: [PATCH 032/300] Add object-level support to TokenPermissions
---
netbox/netbox/api.py | 26 ++++++++++++++++++++------
1 file changed, 20 insertions(+), 6 deletions(-)
diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py
index 0e04719f9..a67a5d60a 100644
--- a/netbox/netbox/api.py
+++ b/netbox/netbox/api.py
@@ -2,7 +2,7 @@ from django.conf import settings
from django.db.models import QuerySet
from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
-from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
+from rest_framework.permissions import DjangoObjectPermissions, SAFE_METHODS
from rest_framework.renderers import BrowsableAPIRenderer
from rest_framework.utils import formatting
@@ -51,7 +51,7 @@ class TokenAuthentication(authentication.TokenAuthentication):
return token.user, token
-class TokenPermissions(DjangoModelPermissions):
+class TokenPermissions(DjangoObjectPermissions):
"""
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
for unsafe requests (POST/PUT/PATCH/DELETE).
@@ -74,15 +74,29 @@ class TokenPermissions(DjangoModelPermissions):
super().__init__()
+ def _verify_write_permission(self, request):
+ # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
+ if request.method in SAFE_METHODS:
+ return True
+ if isinstance(request.auth, Token) and request.auth.write_enabled:
+ return True
+
def has_permission(self, request, view):
- # If token authentication is in use, verify that the token allows write operations (for unsafe methods).
- if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
- if not request.auth.write_enabled:
- return False
+ # Enforce Token write ability
+ if not self._verify_write_permission(request):
+ return False
return super().has_permission(request, view)
+ def has_object_permission(self, request, view, obj):
+
+ # Enforce Token write ability
+ if not self._verify_write_permission(request):
+ return False
+
+ return super().has_object_permission(request, view, obj)
+
#
# Pagination
From 73895b1c88fdfe4f15de9045884ceee05cae6b52 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 14 May 2020 17:44:15 -0400
Subject: [PATCH 033/300] Bypass permission caching for anonymous users
---
netbox/utilities/auth_backends.py | 22 ++++++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 9e56fd16c..46ec69458 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -34,6 +34,28 @@ class ViewExemptModelBackend(ModelBackend):
qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}')
return Permission.objects.filter(qs_filter)
+ def has_perm(self, user_obj, perm, obj=None):
+
+ # Authenticated users need to have the view permissions cached for assessment
+ if user_obj.is_authenticated:
+ return super().has_perm(user_obj, perm, obj)
+
+ # If this is a view permission, check whether the model has been exempted from enforcement
+ try:
+ app, codename = perm.split('.')
+ action, model = codename.split('_')
+ if action == 'view':
+ if (
+ # All models are exempt from view permission enforcement
+ '*' in settings.EXEMPT_VIEW_PERMISSIONS
+ ) or (
+ # This specific model is exempt from view permission enforcement
+ '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS
+ ):
+ return True
+ except ValueError:
+ pass
+
class ObjectPermissionBackend(ModelBackend):
"""
From aeb32104a46c32797380c80e2549e4583377d58d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 14 May 2020 17:44:46 -0400
Subject: [PATCH 034/300] Enforce object-level permissions for API views
---
netbox/utilities/api.py | 20 ++++++++++++++++++--
1 file changed, 18 insertions(+), 2 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 205055669..405c26878 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -6,15 +6,15 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
from django.db.models import ManyToManyField, ProtectedError
-from django.http import Http404
from django.urls import reverse
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
-from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet
+from rest_framework.viewsets import ModelViewSet as _ModelViewSet
+from users.models import ObjectPermission
from .utils import dict_to_filter_params, dynamic_import
@@ -323,6 +323,22 @@ class ModelViewSet(_ModelViewSet):
logger.debug(f"Using serializer {self.serializer_class}")
return self.serializer_class
+ def initial(self, request, *args, **kwargs):
+ super().initial(request, *args, **kwargs)
+
+ if not request.user.is_authenticated or request.user.is_superuser:
+ return
+
+ permission_required = 'dcim.view_site'
+
+ # Enforce object-level permissions
+ if permission_required not in self.request.user._perm_cache:
+ attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, permission_required)
+ if attrs:
+ # Update the view's QuerySet to filter only the permitted objects
+ self.queryset = self.queryset.filter(attrs)
+ return True
+
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
From 2c2d6c6d47b68a659a527bc2aeb45c01e1e97083 Mon Sep 17 00:00:00 2001
From: John Anderson
Date: Fri, 15 May 2020 02:31:45 -0400
Subject: [PATCH 035/300] fixes #3304 - primary IP address caching invalidation
---
docs/release-notes/version-2.8.md | 8 ++++++++
netbox/dcim/views.py | 2 +-
2 files changed, 9 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 5d8687588..cb611f25f 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,5 +1,13 @@
# NetBox v2.8
+v2.8.5 (FUTURE)
+
+### Bug Fixes
+
+* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
+
+---
+
v2.8.4 (2020-05-13)
### Enhancements
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index cd1b4edf4..d141f93c6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1105,7 +1105,7 @@ class DeviceView(PermissionRequiredMixin, View):
def get(self, request, pk):
device = get_object_or_404(Device.objects.prefetch_related(
- 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
+ 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6'
), pk=pk)
# VirtualChassis members
From 14744da8f6f045dfe38b16ba40a6f1e96fe9b114 Mon Sep 17 00:00:00 2001
From: John Anderson
Date: Fri, 15 May 2020 02:45:48 -0400
Subject: [PATCH 036/300] fixes #4647 - caching invalidation related to
assinging new IP addresses to interfaces
---
docs/release-notes/version-2.8.md | 2 ++
netbox/ipam/forms.py | 7 ++++++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index cb611f25f..ed5f01709 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -5,6 +5,8 @@ v2.8.5 (FUTURE)
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
+* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assinging new IP addresses to interfaces
+
---
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index f5fd6e5f8..5906e19a4 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -618,7 +618,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
- )
+ ).prefetch_related(
+ 'device__primary_ip4',
+ 'device__primary_ip6',
+ 'virtual_machine__primary_ip4',
+ 'virtual_machine__primary_ip6',
+ ) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
else:
self.fields['interface'].choices = []
From 8394ff55371d658012e769f03e3aeb22f308ba8d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 15 May 2020 09:02:56 -0400
Subject: [PATCH 037/300] Fixes #4644: Fix ordering of services table by parent
---
docs/release-notes/version-2.8.md | 4 ++--
netbox/ipam/tables.py | 3 +++
2 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index ed5f01709..6165e6a15 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -5,8 +5,8 @@ v2.8.5 (FUTURE)
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
-* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assinging new IP addresses to interfaces
-
+* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
+* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
---
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index d8b50c11d..ca48c2951 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -667,6 +667,9 @@ class ServiceTable(BaseTable):
viewname='ipam:service',
args=[Accessor('pk')]
)
+ parent = tables.LinkColumn(
+ order_by=('device', 'virtual_machine')
+ )
tags = TagColumn(
url_name='ipam:service_list'
)
From ba91b3aa2e1a85a481f163506409189187dd8921 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 15 May 2020 09:13:51 -0400
Subject: [PATCH 038/300] Fixes #4646: Correct UI link for reports with custom
name
---
docs/release-notes/version-2.8.md | 1 +
netbox/extras/reports.py | 4 ++--
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 6165e6a15..32d20d700 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -6,6 +6,7 @@ v2.8.5 (FUTURE)
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
+* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
---
diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py
index 373acdde7..d4db12daa 100644
--- a/netbox/extras/reports.py
+++ b/netbox/extras/reports.py
@@ -92,7 +92,7 @@ class Report(object):
self.active_test = None
self.failed = False
- self.logger = logging.getLogger(f"netbox.reports.{self.module}.{self.name}")
+ self.logger = logging.getLogger(f"netbox.reports.{self.full_name}")
# Compile test methods and initialize results skeleton
test_methods = []
@@ -120,7 +120,7 @@ class Report(object):
@property
def full_name(self):
- return '.'.join([self.module, self.name])
+ return '.'.join([self.__module__, self.__class__.__name__])
def _log(self, obj, message, level=LOG_DEFAULT):
"""
From a64351279ddc883a5b90838f4977b22f40ec3503 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 15 May 2020 09:36:16 -0400
Subject: [PATCH 039/300] Fixes #4648: Fix bulk CSV import of child devices
---
docs/release-notes/version-2.8.md | 1 +
netbox/dcim/forms.py | 16 +++++++++++++++-
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 32d20d700..6d1907eb0 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -8,6 +8,7 @@ v2.8.5 (FUTURE)
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
+* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
---
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 5d3ec1019..cdd42ddae 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -1956,7 +1956,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
help_text='Parent device'
)
device_bay = CSVModelChoiceField(
- queryset=Device.objects.all(),
+ queryset=DeviceBay.objects.all(),
to_field_name='name',
help_text='Device bay in which this device is installed'
)
@@ -1976,6 +1976,20 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
params = {f"device__{self.fields['parent'].to_field_name}": data.get('parent')}
self.fields['device_bay'].queryset = self.fields['device_bay'].queryset.filter(**params)
+ def clean(self):
+ super().clean()
+
+ # Set parent_bay reverse relationship
+ device_bay = self.cleaned_data.get('device_bay')
+ if device_bay:
+ self.instance.parent_bay = device_bay
+
+ # Inherit site and rack from parent device
+ parent = self.cleaned_data.get('parent')
+ if parent:
+ self.instance.site = parent.site
+ self.instance.rack = parent.rack
+
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
pk = forms.ModelMultipleChoiceField(
From 3c8e7e739d2fe4cbb7729a91d49e95f8b09c20b1 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 15 May 2020 09:44:00 -0400
Subject: [PATCH 040/300] Fixes #4649: Fix interface assignment for
bulk-imported IP addresses
---
docs/release-notes/version-2.8.md | 1 +
netbox/ipam/forms.py | 12 ------------
2 files changed, 1 insertion(+), 12 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 6d1907eb0..60adf53cc 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -9,6 +9,7 @@ v2.8.5 (FUTURE)
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
+* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
---
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 5906e19a4..fc1352ec9 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -780,18 +780,6 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs):
- # Set interface
- if self.cleaned_data['device'] and self.cleaned_data['interface_name']:
- self.instance.interface = Interface.objects.get(
- device=self.cleaned_data['device'],
- name=self.cleaned_data['interface_name']
- )
- elif self.cleaned_data['virtual_machine'] and self.cleaned_data['interface_name']:
- self.instance.interface = Interface.objects.get(
- virtual_machine=self.cleaned_data['virtual_machine'],
- name=self.cleaned_data['interface_name']
- )
-
ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM
From cd236aa8862cf05e90e227c589cd0e054afc6e6b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 15 May 2020 10:11:36 -0400
Subject: [PATCH 041/300] Closes #4645: Update minimum required version of
PostgreSQL to 9.6
---
docs/index.md | 2 +-
docs/installation/1-postgresql.md | 4 ++--
docs/release-notes/version-2.8.md | 2 ++
netbox/templates/exceptions/programming_error.html | 2 +-
4 files changed, 6 insertions(+), 4 deletions(-)
diff --git a/docs/index.md b/docs/index.md
index 3880c9d07..ee7f77f69 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -49,7 +49,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| HTTP service | nginx or Apache |
| WSGI service | gunicorn or uWSGI |
| Application | Django/Python |
-| Database | PostgreSQL 9.4+ |
+| Database | PostgreSQL 9.6+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md
index afe3a51d2..933e32edc 100644
--- a/docs/installation/1-postgresql.md
+++ b/docs/installation/1-postgresql.md
@@ -3,7 +3,7 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning
- NetBox requires PostgreSQL 9.4 or higher. Please note that MySQL and other relational databases are **not** supported.
+ NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
@@ -51,7 +51,7 @@ At a minimum, we need to create a database for NetBox and assign it a username a
```no-highlight
# sudo -u postgres psql
-psql (9.4.5)
+psql (10.10)
Type "help" for help.
postgres=# CREATE DATABASE netbox;
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 60adf53cc..9574894a3 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -2,6 +2,8 @@
v2.8.5 (FUTURE)
+**Note:** The minimum required version of PostgreSQL is now 9.6.
+
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
diff --git a/netbox/templates/exceptions/programming_error.html b/netbox/templates/exceptions/programming_error.html
index 6f10c2e27..48ab707b7 100644
--- a/netbox/templates/exceptions/programming_error.html
+++ b/netbox/templates/exceptions/programming_error.html
@@ -10,7 +10,7 @@
python3 manage.py migrate from the command line.
- Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.4 or higher is in use. You
+ Unsupported PostgreSQL version - Ensure that PostgreSQL version 9.6 or higher is in use. You
can check this by connecting to the database using NetBox's credentials and issuing a query for
SELECT VERSION().
From d2e1428c75ce787d33486e69b501f0fac3bda84d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 09:36:55 -0400
Subject: [PATCH 042/300] Closes #4665: Add NEMA L14 and L21 power port/outlet
types
---
docs/release-notes/version-2.8.md | 4 ++++
netbox/dcim/choices.py | 16 ++++++++++++++++
2 files changed, 20 insertions(+)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 9574894a3..ad86ca9df 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -4,6 +4,10 @@ v2.8.5 (FUTURE)
**Note:** The minimum required version of PostgreSQL is now 9.6.
+### Enhancements
+
+* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
+
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 8433bb152..479563093 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -276,6 +276,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_NEMA_L620P = 'nema-l6-20p'
TYPE_NEMA_L630P = 'nema-l6-30p'
TYPE_NEMA_L650P = 'nema-l6-50p'
+ TYPE_NEMA_L1420P = 'nema-l14-20p'
+ TYPE_NEMA_L1430P = 'nema-l14-30p'
+ TYPE_NEMA_L2120P = 'nema-l21-20p'
+ TYPE_NEMA_L2130P = 'nema-l21-30p'
# California style
TYPE_CS6361C = 'cs6361c'
TYPE_CS6365C = 'cs6365c'
@@ -337,6 +341,10 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_NEMA_L620P, 'NEMA L6-20P'),
(TYPE_NEMA_L630P, 'NEMA L6-30P'),
(TYPE_NEMA_L650P, 'NEMA L6-50P'),
+ (TYPE_NEMA_L1420P, 'NEMA L14-20P'),
+ (TYPE_NEMA_L1430P, 'NEMA L14-30P'),
+ (TYPE_NEMA_L2120P, 'NEMA L21-20P'),
+ (TYPE_NEMA_L2130P, 'NEMA L21-30P'),
)),
('California Style', (
(TYPE_CS6361C, 'CS6361C'),
@@ -405,6 +413,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_NEMA_L620R = 'nema-l6-20r'
TYPE_NEMA_L630R = 'nema-l6-30r'
TYPE_NEMA_L650R = 'nema-l6-50r'
+ TYPE_NEMA_L1420R = 'nema-l14-20r'
+ TYPE_NEMA_L1430R = 'nema-l14-30r'
+ TYPE_NEMA_L2120R = 'nema-l21-20r'
+ TYPE_NEMA_L2130R = 'nema-l21-30r'
# California style
TYPE_CS6360C = 'CS6360C'
TYPE_CS6364C = 'CS6364C'
@@ -467,6 +479,10 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_NEMA_L620R, 'NEMA L6-20R'),
(TYPE_NEMA_L630R, 'NEMA L6-30R'),
(TYPE_NEMA_L650R, 'NEMA L6-50R'),
+ (TYPE_NEMA_L1420R, 'NEMA L14-20R'),
+ (TYPE_NEMA_L1430R, 'NEMA L14-30R'),
+ (TYPE_NEMA_L2120R, 'NEMA L21-20R'),
+ (TYPE_NEMA_L2130R, 'NEMA L21-30R'),
)),
('California Style', (
(TYPE_CS6360C, 'CS6360C'),
From 1f5d2520c3f17be08497820e3b7d7904e80eaac9 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 10:37:26 -0400
Subject: [PATCH 043/300] Formatting fix
---
docs/release-notes/version-2.8.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index ad86ca9df..16712bb79 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,6 +1,6 @@
# NetBox v2.8
-v2.8.5 (FUTURE)
+## v2.8.5 (FUTURE)
**Note:** The minimum required version of PostgreSQL is now 9.6.
@@ -19,7 +19,7 @@ v2.8.5 (FUTURE)
---
-v2.8.4 (2020-05-13)
+## v2.8.4 (2020-05-13)
### Enhancements
From 64f60228ecb85e0dd2d96ec796e84bf833d880ad Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 13:35:54 -0400
Subject: [PATCH 044/300] Add web UI view tests for object-level permissions
---
netbox/ipam/views.py | 14 +-
netbox/netbox/tests/test_authentication.py | 221 ++++++++++++++++++++-
2 files changed, 227 insertions(+), 8 deletions(-)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 92eb5b823..0c7d0770f 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -8,6 +8,7 @@ from django.views.generic import View
from django_tables2 import RequestConfig
from dcim.models import Device, Interface
+from netbox.authentication import ObjectPermissionRequiredMixin
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
@@ -440,7 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefixes
#
-class PrefixListView(PermissionRequiredMixin, ObjectListView):
+class PrefixListView(ObjectPermissionRequiredMixin, ObjectListView):
permission_required = 'ipam.view_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
@@ -454,14 +455,13 @@ class PrefixListView(PermissionRequiredMixin, ObjectListView):
return self.queryset.annotate_depth(limit=limit)
-class PrefixView(PermissionRequiredMixin, View):
+class PrefixView(ObjectPermissionRequiredMixin, View):
permission_required = 'ipam.view_prefix'
+ queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
def get(self, request, pk):
- prefix = get_object_or_404(Prefix.objects.prefetch_related(
- 'vrf', 'site__region', 'tenant__group', 'vlan__group', 'role'
- ), pk=pk)
+ prefix = get_object_or_404(self.queryset, pk=pk)
try:
aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
@@ -586,7 +586,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
})
-class PrefixCreateView(PermissionRequiredMixin, ObjectEditView):
+class PrefixCreateView(ObjectPermissionRequiredMixin, ObjectEditView):
permission_required = 'ipam.add_prefix'
queryset = Prefix.objects.all()
model_form = forms.PrefixForm
@@ -598,7 +598,7 @@ class PrefixEditView(PrefixCreateView):
permission_required = 'ipam.change_prefix'
-class PrefixDeleteView(PermissionRequiredMixin, ObjectDeleteView):
+class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 42cddb082..59e4dcde4 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -1,8 +1,16 @@
from django.conf import settings
from django.contrib.auth.models import Group, User
-from django.test import Client, TestCase
+from django.contrib.contenttypes.models import ContentType
+from django.test import Client
from django.test.utils import override_settings
from django.urls import reverse
+from netaddr import IPNetwork
+
+from dcim.models import Site
+from ipam.choices import PrefixStatusChoices
+from ipam.models import Prefix
+from users.models import ObjectPermission
+from utilities.testing.testcases import TestCase
class ExternalAuthenticationTestCase(TestCase):
@@ -157,3 +165,214 @@ class ExternalAuthenticationTestCase(TestCase):
new_user = User.objects.get(username='remoteuser2')
self.assertEqual(int(self.client.session.get('_auth_user_id')), new_user.pk, msg='Authentication failed')
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
+
+
+class ObjectPermissionTestCase(TestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+
+ cls.sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(cls.sites)
+
+ cls.prefixes = (
+ Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]),
+ Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]),
+ Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]),
+ )
+ Prefix.objects.bulk_create(cls.prefixes)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_ui_get_object(self):
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Retrieve permitted object
+ response = self.client.get(self.prefixes[0].get_absolute_url())
+ self.assertHttpStatus(response, 200)
+
+ # Attempt to retrieve non-permitted object
+ response = self.client.get(self.prefixes[3].get_absolute_url())
+ self.assertHttpStatus(response, 404)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_ui_list_objects(self):
+
+ # Attempt to list objects without permission
+ response = self.client.get(reverse('ipam:prefix_list'))
+ self.assertHttpStatus(response, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Retrieve all objects. Only permitted objects should be returned.
+ response = self.client.get(reverse('ipam:prefix_list'))
+ self.assertHttpStatus(response, 200)
+ self.assertIn(str(self.prefixes[0].prefix), str(response.content))
+ self.assertNotIn(str(self.prefixes[3].prefix), str(response.content))
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_ui_create_object(self):
+ initial_count = Prefix.objects.count()
+ form_data = {
+ 'prefix': '10.0.9.0/24',
+ 'site': self.sites[1].pk,
+ 'status': PrefixStatusChoices.STATUS_ACTIVE,
+ }
+
+ # Attempt to create an object without permission
+ request = {
+ 'path': reverse('ipam:prefix_add'),
+ 'data': form_data,
+ 'follow': False, # Do not follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+ self.assertEqual(initial_count, Prefix.objects.count())
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True,
+ can_add=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to create a non-permitted object
+ request = {
+ 'path': reverse('ipam:prefix_add'),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(initial_count, Prefix.objects.count())
+
+ # Create a permitted object
+ form_data['site'] = self.sites[0].pk
+ request = {
+ 'path': reverse('ipam:prefix_add'),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(initial_count + 1, Prefix.objects.count())
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_ui_edit_object(self):
+ form_data = {
+ 'prefix': '10.0.9.0/24',
+ 'site': self.sites[0].pk,
+ 'status': PrefixStatusChoices.STATUS_RESERVED,
+ }
+
+ # Attempt to edit an object without permission
+ request = {
+ 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}),
+ 'data': form_data,
+ 'follow': False, # Do not follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True,
+ can_change=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to edit a non-permitted object
+ request = {
+ 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[3].pk}),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 404)
+
+ # Edit a permitted object
+ request = {
+ 'path': reverse('ipam:prefix_edit', kwargs={'pk': self.prefixes[0].pk}),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ prefix = Prefix.objects.get(pk=self.prefixes[0].pk)
+ self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_ui_delete_object(self):
+ form_data = {
+ 'confirm': True
+ }
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True,
+ can_delete=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Delete permitted object
+ request = {
+ 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists())
+
+ # Attempt to delete non-permitted object
+ request = {
+ 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[3].pk}),
+ 'data': form_data,
+ 'follow': True, # Follow 302 redirects
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 404)
+ self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
From 8eb4d0a36be636e03d728a23391dc57fc130b387 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 16:27:56 -0400
Subject: [PATCH 045/300] Remove ViewExemptBackend; use same for model- and
object-level permissions
---
netbox/netbox/authentication.py | 25 ++--
netbox/netbox/settings.py | 3 +-
netbox/netbox/tests/test_authentication.py | 43 +++---
netbox/utilities/auth_backends.py | 152 +++++++++------------
4 files changed, 98 insertions(+), 125 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index d85b2f124..2e68e6ef1 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -13,23 +13,28 @@ class ObjectPermissionRequiredMixin(AccessMixin):
permission_required = None
def has_permission(self):
- # First, check whether the user is granted the requested permissions from any backend.
- if not self.request.user.has_perm(self.permission_required):
+ user = self.request.user
+
+ # First, check that the user is granted the required permission at either the model or object level.
+ if not user.has_perm(self.permission_required):
return False
- # Next, determine whether the permission is model-level or object-level. Model-level permissions grant the
+ # Superusers implicitly have all permissions
+ if user.is_superuser:
+ return True
+
+ # Determine whether the permission is model-level or object-level. Model-level permissions grant the
# specified action to *all* objects, so no further action is needed.
- if self.request.user.is_superuser or self.permission_required in self.request.user._perm_cache:
+ if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
return True
# If the permission is granted only at the object level, filter the view's queryset to return only objects
# on which the user is permitted to perform the specified action.
- if self.permission_required in self.request.user._obj_perm_cache:
- attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, self.permission_required)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(attrs)
- return True
+ attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required)
+ if attrs:
+ # Update the view's QuerySet to filter only the permitted objects
+ self.queryset = self.queryset.filter(attrs)
+ return True
def dispatch(self, request, *args, **kwargs):
if self.permission_required is None:
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 5c48ee620..d265cc58c 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -333,8 +333,7 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
- REMOTE_AUTH_BACKEND,
- 'utilities.auth_backends.ViewExemptModelBackend',
+ # REMOTE_AUTH_BACKEND,
'utilities.auth_backends.ObjectPermissionBackend',
]
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 59e4dcde4..18bf251d4 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -5,11 +5,12 @@ from django.test import Client
from django.test.utils import override_settings
from django.urls import reverse
from netaddr import IPNetwork
+from rest_framework.test import APIClient
from dcim.models import Site
from ipam.choices import PrefixStatusChoices
from ipam.models import Prefix
-from users.models import ObjectPermission
+from users.models import ObjectPermission, Token
from utilities.testing.testcases import TestCase
@@ -167,7 +168,7 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(new_user.has_perms(['dcim.add_site', 'dcim.change_site']))
-class ObjectPermissionTestCase(TestCase):
+class ObjectPermissionViewTestCase(TestCase):
@classmethod
def setUpTestData(cls):
@@ -193,14 +194,16 @@ class ObjectPermissionTestCase(TestCase):
Prefix.objects.bulk_create(cls.prefixes)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_ui_get_object(self):
+ def test_get_object(self):
+
+ # Attempt to retrieve object without permission
+ response = self.client.get(self.prefixes[0].get_absolute_url())
+ self.assertHttpStatus(response, 403)
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True
)
obj_perm.save()
@@ -215,7 +218,7 @@ class ObjectPermissionTestCase(TestCase):
self.assertHttpStatus(response, 404)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_ui_list_objects(self):
+ def test_list_objects(self):
# Attempt to list objects without permission
response = self.client.get(reverse('ipam:prefix_list'))
@@ -224,9 +227,7 @@ class ObjectPermissionTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True
)
obj_perm.save()
@@ -239,7 +240,7 @@ class ObjectPermissionTestCase(TestCase):
self.assertNotIn(str(self.prefixes[3].prefix), str(response.content))
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_ui_create_object(self):
+ def test_create_object(self):
initial_count = Prefix.objects.count()
form_data = {
'prefix': '10.0.9.0/24',
@@ -260,9 +261,7 @@ class ObjectPermissionTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True,
can_add=True
)
@@ -277,7 +276,7 @@ class ObjectPermissionTestCase(TestCase):
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
- self.assertEqual(initial_count, Prefix.objects.count())
+ self.assertEqual(Prefix.objects.count(), initial_count)
# Create a permitted object
form_data['site'] = self.sites[0].pk
@@ -288,10 +287,10 @@ class ObjectPermissionTestCase(TestCase):
}
response = self.client.post(**request)
self.assertHttpStatus(response, 200)
- self.assertEqual(initial_count + 1, Prefix.objects.count())
+ self.assertEqual(Prefix.objects.count(), initial_count + 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_ui_edit_object(self):
+ def test_edit_object(self):
form_data = {
'prefix': '10.0.9.0/24',
'site': self.sites[0].pk,
@@ -310,9 +309,7 @@ class ObjectPermissionTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True,
can_change=True
)
@@ -340,7 +337,7 @@ class ObjectPermissionTestCase(TestCase):
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_RESERVED)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_ui_delete_object(self):
+ def test_delete_object(self):
form_data = {
'confirm': True
}
@@ -348,9 +345,7 @@ class ObjectPermissionTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True,
can_delete=True
)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 46ec69458..e540a04e0 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -8,115 +8,89 @@ from django.db.models import Q
from users.models import ObjectPermission
-class ViewExemptModelBackend(ModelBackend):
- """
- Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view
- permission enforcement.
- """
- def _get_user_permissions(self, user_obj):
-
- if not settings.EXEMPT_VIEW_PERMISSIONS:
- # No view permissions have been exempted from enforcement, so fall back to the built-in logic.
- return super()._get_user_permissions(user_obj)
-
- if '*' in settings.EXEMPT_VIEW_PERMISSIONS:
- # All view permissions have been exempted from enforcement, so include all view permissions when fetching
- # User permissions.
- return Permission.objects.filter(
- Q(user=user_obj) | Q(codename__startswith='view_')
- )
-
- # Return all Permissions that are either assigned to the user or that are view permissions listed in
- # EXEMPT_VIEW_PERMISSIONS.
- qs_filter = Q(user=user_obj)
- for model in settings.EXEMPT_VIEW_PERMISSIONS:
- app, name = model.split('.')
- qs_filter |= Q(content_type__app_label=app, codename=f'view_{name}')
- return Permission.objects.filter(qs_filter)
-
- def has_perm(self, user_obj, perm, obj=None):
-
- # Authenticated users need to have the view permissions cached for assessment
- if user_obj.is_authenticated:
- return super().has_perm(user_obj, perm, obj)
-
- # If this is a view permission, check whether the model has been exempted from enforcement
- try:
- app, codename = perm.split('.')
- action, model = codename.split('_')
- if action == 'view':
- if (
- # All models are exempt from view permission enforcement
- '*' in settings.EXEMPT_VIEW_PERMISSIONS
- ) or (
- # This specific model is exempt from view permission enforcement
- '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS
- ):
- return True
- except ValueError:
- pass
-
-
class ObjectPermissionBackend(ModelBackend):
- """
- Evaluates permission of a user to access or modify a specific object based on the assignment of ObjectPermissions
- either to the user directly or to a group of which the user is a member. Model-level permissions supersede this
- check: For example, if a user has the dcim.view_site model-level permission assigned, the ViewExemptModelBackend
- will grant permission before this backend is evaluated for permission to view a specific site.
- """
- def _get_all_permissions(self, user_obj):
+
+ def get_object_permissions(self, user_obj):
"""
- Retrieve all ObjectPermissions assigned to this User (either directly or through a Group) and return the model-
- level equivalent codenames.
+ Return all model-level permissions granted to the user by an ObjectPermission.
"""
- perm_names = set()
- for obj_perm in ObjectPermission.objects.filter(
- Q(users=user_obj) | Q(groups__user=user_obj)
- ).prefetch_related('model'):
- for action in ['view', 'add', 'change', 'delete']:
- if getattr(obj_perm, f"can_{action}"):
- perm_names.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}")
- return perm_names
+ if not hasattr(user_obj, '_object_perm_cache'):
+
+ # Cache all assigned ObjectPermissions on the User instance
+ perms = set()
+ for obj_perm in ObjectPermission.objects.filter(
+ Q(users=user_obj) |
+ Q(groups__user=user_obj)
+ ).prefetch_related('model'):
+ for action in ['view', 'add', 'change', 'delete']:
+ if getattr(obj_perm, f"can_{action}"):
+ perms.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}")
+ setattr(user_obj, '_object_perm_cache', perms)
+
+ return user_obj._object_perm_cache
def get_all_permissions(self, user_obj, obj=None):
- """
- Get all model-level permissions assigned by this backend. Permissions are cached on the User instance.
- """
+
+ # Handle inactive/anonymous users
if not user_obj.is_active or user_obj.is_anonymous:
return set()
- if not hasattr(user_obj, '_obj_perm_cache'):
- user_obj._obj_perm_cache = self._get_all_permissions(user_obj)
- return user_obj._obj_perm_cache
+
+ # Cache model-level permissions on the User instance
+ if not hasattr(user_obj, '_perm_cache'):
+ user_obj._perm_cache = {
+ *self.get_user_permissions(user_obj, obj=obj),
+ *self.get_group_permissions(user_obj, obj=obj),
+ *self.get_object_permissions(user_obj)
+ }
+
+ return user_obj._perm_cache
def has_perm(self, user_obj, perm, obj=None):
+ app_label, codename = perm.split('.')
+ action, model_name = codename.split('_')
- # If no object is specified, look for any matching ObjectPermissions. If one or more are found, this indicates
- # that the user has permission to perform the requested action on at least *some* objects, but not necessarily
- # on all of them.
+ # If this is a view permission, check whether the model has been exempted from enforcement
+ if action == 'view':
+ if (
+ # All models are exempt from view permission enforcement
+ '*' in settings.EXEMPT_VIEW_PERMISSIONS
+ ) or (
+ # This specific model is exempt from view permission enforcement
+ '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS
+ ):
+ return True
+
+ # If no object is specified, evaluate model-level permissions. The presence of a permission in this set tells
+ # us that the user has permission for *some* objects, but not necessarily a specific object.
if obj is None:
return perm in self.get_all_permissions(user_obj)
- attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm)
-
- # No ObjectPermissions found for this combination of user, model, and action
- if not attrs:
- return
-
+ # Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.model
-
- # Check that the requested permission applies to the specified object
- app_label, codename = perm.split('.')
- action, model_name = codename.split('_')
if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}")
- # Attempt to retrieve the model from the database using the attributes defined in the
- # ObjectPermission. If we have a match, assert that the user has permission.
- if model.objects.filter(attrs, pk=obj.pk).exists():
+ # If the user has been granted model-level permission for the object, return True
+ model_perms = {
+ *self.get_user_permissions(user_obj),
+ *self.get_group_permissions(user_obj),
+ }
+ if perm in model_perms:
return True
+ # Gather all ObjectPermissions pertinent to the requested permission. If none are found, the User has no
+ # applicable permissions.
+ attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm)
+ if not attrs:
+ return False
-class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_):
+ # Permission to perform the requested action on the object depends on whether the specified object matches
+ # the specified attributes. Note that this check is made against the *database* record representing the object,
+ # not the instance itself.
+ return model.objects.filter(attrs, pk=obj.pk).exists()
+
+
+class RemoteUserBackend(RemoteUserBackend_):
"""
Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
"""
From 8c40148ca730f21ec65b0ffa53d0e5bf924603bc Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 16:47:33 -0400
Subject: [PATCH 046/300] Add object permission tests for get and list API
views
---
netbox/netbox/tests/test_authentication.py | 121 +++++++++++++++++++++
netbox/utilities/api.py | 10 +-
2 files changed, 128 insertions(+), 3 deletions(-)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 18bf251d4..64dd83783 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -371,3 +371,124 @@ class ObjectPermissionViewTestCase(TestCase):
response = self.client.post(**request)
self.assertHttpStatus(response, 404)
self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
+
+
+class ObjectPermissionAPIViewTestCase(TestCase):
+ client_class = APIClient
+
+ @classmethod
+ def setUpTestData(cls):
+
+ cls.sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ Site(name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(cls.sites)
+
+ cls.prefixes = (
+ Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]),
+ Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]),
+ Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]),
+ Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]),
+ Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]),
+ )
+ Prefix.objects.bulk_create(cls.prefixes)
+
+ def setUp(self):
+ """
+ Create a test user and token for API calls.
+ """
+ self.user = User.objects.create(username='testuser')
+ self.token = Token.objects.create(user=self.user)
+ self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_get_object(self):
+
+ # Attempt to retrieve object without permission
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Retrieve permitted object
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.status_code, 200)
+
+ # Attempt to retrieve non-permitted object
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.status_code, 404)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_list_objects(self):
+ url = reverse('ipam-api:prefix-list')
+
+ # Attempt to list objects without permission
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={
+ 'site__name': 'Site 1',
+ },
+ can_view=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Retrieve all objects. Only permitted objects should be returned.
+ response = self.client.get(url, **self.header)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(response.data['count'], 3)
+
+ # TODO
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_create_object(self):
+ # url = reverse('ipam-api:prefix-list')
+ # data = {
+ # 'prefix': '10.0.9.0/24',
+ # 'site': self.sites[1].pk,
+ # }
+ # initial_count = Prefix.objects.count()
+ #
+ # # Attempt to create an object without permission
+ # response = self.client.post(url, data, format='json', **self.header)
+ # self.assertEqual(response.status_code, 403)
+ #
+ # # Assign object permission
+ # obj_perm = ObjectPermission(
+ # model=ContentType.objects.get_for_model(Prefix),
+ # attrs={'site__name': 'Site 1'},
+ # can_view=True
+ # )
+ # obj_perm.save()
+ # obj_perm.users.add(self.user)
+ #
+ # # Attempt to create a non-permitted object
+ # response = self.client.post(url, data, format='json', **self.header)
+ # self.assertEqual(response.status_code, 403)
+ # self.assertEqual(Prefix.objects.count(), initial_count)
+ #
+ # # Create a permitted object
+ # response = self.client.post(url, data, format='json', **self.header)
+ # self.assertEqual(response.status_code, 200)
+ # self.assertEqual(Prefix.objects.count(), initial_count + 1)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 405c26878..9ec587369 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -329,11 +329,15 @@ class ModelViewSet(_ModelViewSet):
if not request.user.is_authenticated or request.user.is_superuser:
return
- permission_required = 'dcim.view_site'
+ # Determine the required permission
+ permission_required = "{}.view_{}".format(
+ self.queryset.model._meta.app_label,
+ self.queryset.model._meta.model_name
+ )
# Enforce object-level permissions
- if permission_required not in self.request.user._perm_cache:
- attrs = ObjectPermission.objects.get_attr_constraints(self.request.user, permission_required)
+ if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}:
+ attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required)
if attrs:
# Update the view's QuerySet to filter only the permitted objects
self.queryset = self.queryset.filter(attrs)
From fa8407371bc10e1739009d537978c4ed1c80a375 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 20 May 2020 16:56:40 -0400
Subject: [PATCH 047/300] Swap position of REMOTE_AUTH_BACKEND
---
netbox/netbox/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index d265cc58c..659eadb1c 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -333,8 +333,8 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
- # REMOTE_AUTH_BACKEND,
'utilities.auth_backends.ObjectPermissionBackend',
+ REMOTE_AUTH_BACKEND,
]
# Internationalization
From a928d337d902ee72bd4a1e5127fde6c0e9c4694b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 10:51:40 -0400
Subject: [PATCH 048/300] Add object permission support for
create/update/delete API views
---
netbox/netbox/tests/test_authentication.py | 127 +++++++++++++++------
netbox/utilities/api.py | 67 +++++++----
2 files changed, 138 insertions(+), 56 deletions(-)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 64dd83783..03d0a1dc3 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -460,35 +460,98 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data['count'], 3)
- # TODO
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_create_object(self):
- # url = reverse('ipam-api:prefix-list')
- # data = {
- # 'prefix': '10.0.9.0/24',
- # 'site': self.sites[1].pk,
- # }
- # initial_count = Prefix.objects.count()
- #
- # # Attempt to create an object without permission
- # response = self.client.post(url, data, format='json', **self.header)
- # self.assertEqual(response.status_code, 403)
- #
- # # Assign object permission
- # obj_perm = ObjectPermission(
- # model=ContentType.objects.get_for_model(Prefix),
- # attrs={'site__name': 'Site 1'},
- # can_view=True
- # )
- # obj_perm.save()
- # obj_perm.users.add(self.user)
- #
- # # Attempt to create a non-permitted object
- # response = self.client.post(url, data, format='json', **self.header)
- # self.assertEqual(response.status_code, 403)
- # self.assertEqual(Prefix.objects.count(), initial_count)
- #
- # # Create a permitted object
- # response = self.client.post(url, data, format='json', **self.header)
- # self.assertEqual(response.status_code, 200)
- # self.assertEqual(Prefix.objects.count(), initial_count + 1)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_create_object(self):
+ url = reverse('ipam-api:prefix-list')
+ data = {
+ 'prefix': '10.0.9.0/24',
+ 'site': self.sites[1].pk,
+ }
+ initial_count = Prefix.objects.count()
+
+ # Attempt to create an object without permission
+ response = self.client.post(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_add=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to create a non-permitted object
+ response = self.client.post(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 403)
+ self.assertEqual(Prefix.objects.count(), initial_count)
+
+ # Create a permitted object
+ data['site'] = self.sites[0].pk
+ response = self.client.post(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(Prefix.objects.count(), initial_count + 1)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_edit_object(self):
+
+ # Attempt to edit an object without permission
+ data = {'site': self.sites[0].pk}
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.patch(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_change=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to edit a non-permitted object
+ data = {'site': self.sites[0].pk}
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+ response = self.client.patch(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 404)
+
+ # Edit a permitted object
+ data['status'] = 'reserved'
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.patch(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 200)
+
+ # Attempt to modify a permitted object to a non-permitted object
+ data['site'] = self.sites[1].pk
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.patch(url, data, format='json', **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_delete_object(self):
+
+ # Attempt to delete an object without permission
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.delete(url, format='json', **self.header)
+ self.assertEqual(response.status_code, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_delete=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to delete a non-permitted object
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
+ response = self.client.delete(url, format='json', **self.header)
+ self.assertEqual(response.status_code, 404)
+
+ # Delete a permitted object
+ url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
+ response = self.client.delete(url, format='json', **self.header)
+ self.assertEqual(response.status_code, 204)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 9ec587369..745f812ff 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -4,7 +4,8 @@ from collections import OrderedDict
import pytz
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
+from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied
+from django.db import transaction
from django.db.models import ManyToManyField, ProtectedError
from django.urls import reverse
from rest_framework.exceptions import APIException
@@ -14,6 +15,7 @@ from rest_framework.response import Response
from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
+from netbox.api import TokenPermissions
from users.models import ObjectPermission
from .utils import dict_to_filter_params, dynamic_import
@@ -329,11 +331,13 @@ class ModelViewSet(_ModelViewSet):
if not request.user.is_authenticated or request.user.is_superuser:
return
- # Determine the required permission
- permission_required = "{}.view_{}".format(
- self.queryset.model._meta.app_label,
- self.queryset.model._meta.model_name
- )
+ # TODO: Move this to a cleaner function
+ # Determine the required permission based on the request method
+ kwargs = {
+ 'app_label': self.queryset.model._meta.app_label,
+ 'model_name': self.queryset.model._meta.model_name
+ }
+ permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
# Enforce object-level permissions
if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}:
@@ -361,34 +365,49 @@ class ModelViewSet(_ModelViewSet):
**kwargs
)
- def list(self, *args, **kwargs):
+ def _validate_objects(self, instance):
"""
- Call to super to allow for caching
+ Check that the provided instance or list of instances are matched by the current queryset. This confirms that
+ any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
"""
- return super().list(*args, **kwargs)
-
- def retrieve(self, *args, **kwargs):
- """
- Call to super to allow for caching
- """
- return super().retrieve(*args, **kwargs)
-
- #
- # Logging
- #
+ if type(instance) is list:
+ # Check that all instances are still included in the view's queryset
+ conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
+ if conforming_count != len(instance):
+ raise ObjectDoesNotExist
+ else:
+ # Check that the instance is matched by the view's queryset
+ self.queryset.get(pk=instance.pk)
def perform_create(self, serializer):
- model = serializer.child.Meta.model if hasattr(serializer, 'many') else serializer.Meta.model
+ model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
logger.info(f"Creating new {model._meta.verbose_name}")
- return super().perform_create(serializer)
+
+ # Enforce object-level permissions on save()
+ try:
+ with transaction.atomic():
+ instance = serializer.save()
+ self._validate_objects(instance)
+ except ObjectDoesNotExist:
+ raise PermissionDenied()
def perform_update(self, serializer):
+ model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
- logger.info(f"Updating {serializer.instance} (PK: {serializer.instance.pk})")
- return super().perform_update(serializer)
+ logger.info(f"Updating {model._meta.verbose_name} {serializer.instance} (PK: {serializer.instance.pk})")
+
+ # Enforce object-level permissions on save()
+ try:
+ with transaction.atomic():
+ instance = serializer.save()
+ self._validate_objects(instance)
+ except ObjectDoesNotExist:
+ raise PermissionDenied()
def perform_destroy(self, instance):
+ model = self.queryset.model
logger = logging.getLogger('netbox.api.views.ModelViewSet')
- logger.info(f"Deleting {instance} (PK: {instance.pk})")
+ logger.info(f"Deleting {model._meta.verbose_name} {instance} (PK: {instance.pk})")
+
return super().perform_destroy(instance)
From 5486cff4410c2a86ab0f20e2fae781a5e3ecee1a Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 11:49:50 -0400
Subject: [PATCH 049/300] Add object permission support, tests for bulk
import/edit/delete views
---
netbox/ipam/views.py | 7 +-
netbox/netbox/tests/test_authentication.py | 149 +++++++++++++++++++++
netbox/utilities/views.py | 41 ++++--
3 files changed, 183 insertions(+), 14 deletions(-)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 0c7d0770f..ace85bc1a 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -605,14 +605,15 @@ class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
default_return_url = 'ipam:prefix_list'
-class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView):
+class PrefixBulkImportView(ObjectPermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_prefix'
+ queryset = Prefix.objects.all()
model_form = forms.PrefixCSVForm
table = tables.PrefixTable
default_return_url = 'ipam:prefix_list'
-class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
+class PrefixBulkEditView(ObjectPermissionRequiredMixin, BulkEditView):
permission_required = 'ipam.change_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
@@ -621,7 +622,7 @@ class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'ipam:prefix_list'
-class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
+class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 03d0a1dc3..d82ef6752 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -342,6 +342,14 @@ class ObjectPermissionViewTestCase(TestCase):
'confirm': True
}
+ # Attempt to delete object without permission
+ request = {
+ 'path': reverse('ipam:prefix_delete', kwargs={'pk': self.prefixes[0].pk}),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+
# Assign object permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(Prefix),
@@ -372,6 +380,147 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 404)
self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_import_objects(self):
+ initial_count = Prefix.objects.count()
+ form_data = {
+ 'csv': "prefix,status,site\n"
+ "10.0.9.0/24,Active,Site 1\n"
+ "10.0.10.0/24,Active,Site 2\n"
+ "10.0.11.0/24,Active,Site 3\n",
+ }
+
+ # Attempt to import objects without permission
+ request = {
+ 'path': reverse('ipam:prefix_import'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+ self.assertEqual(initial_count, Prefix.objects.count())
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_add=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to create non-permitted objects
+ request = {
+ 'path': reverse('ipam:prefix_import'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(Prefix.objects.count(), initial_count)
+
+ # Create a permitted object
+ form_data = {
+ 'csv': "prefix,status,site\n"
+ "10.0.9.0/24,Active,Site 1\n"
+ "10.0.10.0/24,Active,Site 1\n"
+ "10.0.11.0/24,Active,Site 1\n",
+ }
+ request = {
+ 'path': reverse('ipam:prefix_import'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(Prefix.objects.count(), initial_count + 3)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_edit_objects(self):
+ form_data = {
+ 'pk': [p.pk for p in self.prefixes],
+ 'status': 'reserved',
+ '_apply': True,
+ }
+
+ # Attempt to edit objects without permission
+ request = {
+ 'path': reverse('ipam:prefix_bulk_edit'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_change=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to edit non-permitted objects
+ request = {
+ 'path': reverse('ipam:prefix_bulk_edit'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active')
+
+ # Edit permitted objects
+ form_data['pk'] = [p.pk for p in self.prefixes[:3]]
+ request = {
+ 'path': reverse('ipam:prefix_bulk_edit'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 302)
+ self.assertEqual(Prefix.objects.get(pk=self.prefixes[0].pk).status, 'reserved')
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_delete_objects(self):
+ form_data = {
+ 'pk': [p.pk for p in self.prefixes],
+ 'confirm': True,
+ '_confirm': True,
+ }
+
+ # Attempt to delete objects without permission
+ request = {
+ 'path': reverse('ipam:prefix_bulk_delete'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 403)
+
+ # Assign object permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(Prefix),
+ attrs={'site__name': 'Site 1'},
+ can_view=True,
+ can_delete=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Attempt to delete non-permitted object
+ request = {
+ 'path': reverse('ipam:prefix_bulk_delete'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 200)
+ self.assertTrue(Prefix.objects.filter(pk=self.prefixes[3].pk).exists())
+
+ # Delete permitted objects
+ form_data['pk'] = [p.pk for p in self.prefixes[:3]]
+ request = {
+ 'path': reverse('ipam:prefix_bulk_delete'),
+ 'data': form_data,
+ }
+ response = self.client.post(**request)
+ self.assertHttpStatus(response, 302)
+ self.assertFalse(Prefix.objects.filter(pk=self.prefixes[0].pk).exists())
+
class ObjectPermissionAPIViewTestCase(TestCase):
client_class = APIClient
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index d9eace90b..44dd40d90 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -297,9 +297,9 @@ class ObjectEditView(GetReturnURLMixin, View):
return redirect(self.get_return_url(request, obj))
except ObjectDoesNotExist:
- logger.debug("Object save failed due to object-level permissions violation")
- # TODO: Link user to personal permissions view
- form.add_error(None, "Object save failed due to object-level permissions violation")
+ msg = "Object save failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
else:
logger.debug("Form validation failed")
@@ -576,11 +576,13 @@ class BulkImportView(GetReturnURLMixin, View):
"""
Import objects in bulk (CSV format).
- model_form: The form used to create each imported object
- table: The django-tables2 Table used to render the list of imported objects
- template_name: The name of the template
- widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
+ :param queryset: Base queryset for the model
+ :param model_form: The form used to create each imported object
+ :param table: The django-tables2 Table used to render the list of imported objects
+ :param template_name: The name of the template
+ :param widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
"""
+ queryset = None
model_form = None
table = None
template_name = 'utilities/obj_bulk_import.html'
@@ -634,6 +636,10 @@ class BulkImportView(GetReturnURLMixin, View):
form.add_error('csv', "Row {} {}: {}".format(row, field, err[0]))
raise ValidationError("")
+ # Enforce object-level permissions
+ if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+ raise ObjectDoesNotExist
+
# Compile a table containing the imported objects
obj_table = self.table(new_objs)
@@ -650,6 +656,11 @@ class BulkImportView(GetReturnURLMixin, View):
except ValidationError:
pass
+ except ObjectDoesNotExist:
+ msg = "Object import failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
+
else:
logger.debug("Form validation failed")
@@ -707,7 +718,7 @@ class BulkEditView(GetReturnURLMixin, View):
with transaction.atomic():
- updated_count = 0
+ updated_objects = []
for obj in model.objects.filter(pk__in=form.cleaned_data['pk']):
# Update standard fields. If a field is listed in _nullify, delete its value.
@@ -736,6 +747,7 @@ class BulkEditView(GetReturnURLMixin, View):
obj.full_clean()
obj.save()
+ updated_objects.append(obj)
logger.debug(f"Saved {obj} (PK: {obj.pk})")
# Update custom fields
@@ -765,10 +777,12 @@ class BulkEditView(GetReturnURLMixin, View):
if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags'])
- updated_count += 1
+ # Enforce object-level permissions
+ if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
+ raise ObjectDoesNotExist
- if updated_count:
- msg = 'Updated {} {}'.format(updated_count, model._meta.verbose_name_plural)
+ if updated_objects:
+ msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
logger.info(msg)
messages.success(self.request, msg)
@@ -777,6 +791,11 @@ class BulkEditView(GetReturnURLMixin, View):
except ValidationError as e:
messages.error(self.request, "{} failed validation: {}".format(obj, e))
+ except ObjectDoesNotExist:
+ msg = "Object update failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
+
else:
logger.debug("Form validation failed")
From 40c590f44535f663f6b314b9e366b01fff9bcd8e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 11:58:27 -0400
Subject: [PATCH 050/300] Add queryset to all BulkImportViews
---
netbox/circuits/views.py | 3 +++
netbox/dcim/views.py | 23 +++++++++++++++++++++++
netbox/ipam/views.py | 8 ++++++++
netbox/secrets/views.py | 2 ++
netbox/tenancy/views.py | 2 ++
netbox/utilities/views.py | 20 ++++++++++----------
netbox/virtualization/views.py | 4 ++++
7 files changed, 52 insertions(+), 10 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 0546b3832..c3b09f596 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -80,6 +80,7 @@ class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider'
+ queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
@@ -125,6 +126,7 @@ class CircuitTypeEditView(CircuitTypeCreateView):
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
+ queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
@@ -196,6 +198,7 @@ class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit'
+ queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 03e375d35..d6b97e128 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -169,6 +169,7 @@ class RegionEditView(RegionCreateView):
class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_region'
+ queryset = Region.objects.all()
model_form = forms.RegionCSVForm
table = tables.RegionTable
default_return_url = 'dcim:region_list'
@@ -240,6 +241,7 @@ class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_site'
+ queryset = Site.objects.all()
model_form = forms.SiteCSVForm
table = tables.SiteTable
default_return_url = 'dcim:site_list'
@@ -293,6 +295,7 @@ class RackGroupEditView(RackGroupCreateView):
class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackgroup'
+ queryset = RackGroup.objects.all()
model_form = forms.RackGroupCSVForm
table = tables.RackGroupTable
default_return_url = 'dcim:rackgroup_list'
@@ -329,6 +332,7 @@ class RackRoleEditView(RackRoleCreateView):
class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackrole'
+ queryset = RackRole.objects.all()
model_form = forms.RackRoleCSVForm
table = tables.RackRoleTable
default_return_url = 'dcim:rackrole_list'
@@ -446,6 +450,7 @@ class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rack'
+ queryset = Rack.objects.all()
model_form = forms.RackCSVForm
table = tables.RackTable
default_return_url = 'dcim:rack_list'
@@ -520,6 +525,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackreservation'
+ queryset = RackReservation.objects.all()
model_form = forms.RackReservationCSVForm
table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list'
@@ -579,6 +585,7 @@ class ManufacturerEditView(ManufacturerCreateView):
class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_manufacturer'
+ queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerCSVForm
table = tables.ManufacturerTable
default_return_url = 'dcim:manufacturer_list'
@@ -1039,6 +1046,7 @@ class DeviceRoleEditView(DeviceRoleCreateView):
class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicerole'
+ queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleCSVForm
table = tables.DeviceRoleTable
default_return_url = 'dcim:devicerole_list'
@@ -1074,6 +1082,7 @@ class PlatformEditView(PlatformCreateView):
class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_platform'
+ queryset = Platform.objects.all()
model_form = forms.PlatformCSVForm
table = tables.PlatformTable
default_return_url = 'dcim:platform_list'
@@ -1267,6 +1276,7 @@ class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device'
+ queryset = Device.objects.all()
model_form = forms.DeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import.html'
@@ -1275,6 +1285,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_device'
+ queryset = Device.objects.all()
model_form = forms.ChildDeviceCSVForm
table = tables.DeviceImportTable
template_name = 'dcim/device_import_child.html'
@@ -1343,6 +1354,7 @@ class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_consoleport'
+ queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortCSVForm
table = tables.ConsolePortImportTable
default_return_url = 'dcim:consoleport_list'
@@ -1398,6 +1410,7 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_consoleserverport'
+ queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortCSVForm
table = tables.ConsoleServerPortImportTable
default_return_url = 'dcim:consoleserverport_list'
@@ -1465,6 +1478,7 @@ class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_powerport'
+ queryset = PowerPort.objects.all()
model_form = forms.PowerPortCSVForm
table = tables.PowerPortImportTable
default_return_url = 'dcim:powerport_list'
@@ -1520,6 +1534,7 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_poweroutlet'
+ queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletCSVForm
table = tables.PowerOutletImportTable
default_return_url = 'dcim:poweroutlet_list'
@@ -1624,6 +1639,7 @@ class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_interface'
+ queryset = Interface.objects.all()
model_form = forms.InterfaceCSVForm
table = tables.InterfaceImportTable
default_return_url = 'dcim:interface_list'
@@ -1691,6 +1707,7 @@ class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_frontport'
+ queryset = FrontPort.objects.all()
model_form = forms.FrontPortCSVForm
table = tables.FrontPortImportTable
default_return_url = 'dcim:frontport_list'
@@ -1758,6 +1775,7 @@ class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rearport'
+ queryset = RearPort.objects.all()
model_form = forms.RearPortCSVForm
table = tables.RearPortImportTable
default_return_url = 'dcim:rearport_list'
@@ -1896,6 +1914,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicebay'
+ queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayCSVForm
table = tables.DeviceBayImportTable
default_return_url = 'dcim:devicebay_list'
@@ -2170,6 +2189,7 @@ class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class CableBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_cable'
+ queryset = Cable.objects.all()
model_form = forms.CableCSVForm
table = tables.CableTable
default_return_url = 'dcim:cable_list'
@@ -2330,6 +2350,7 @@ class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_inventoryitem'
+ queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemCSVForm
table = tables.InventoryItemTable
default_return_url = 'dcim:inventoryitem_list'
@@ -2673,6 +2694,7 @@ class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_powerpanel'
+ queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelCSVForm
table = tables.PowerPanelTable
default_return_url = 'dcim:powerpanel_list'
@@ -2745,6 +2767,7 @@ class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_powerfeed'
+ queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedCSVForm
table = tables.PowerFeedTable
default_return_url = 'dcim:powerfeed_list'
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index ace85bc1a..ab97afc2a 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -155,6 +155,7 @@ class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vrf'
+ queryset = VRF.objects.all()
model_form = forms.VRFCSVForm
table = tables.VRFTable
default_return_url = 'ipam:vrf_list'
@@ -271,6 +272,7 @@ class RIREditView(RIRCreateView):
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
+ queryset = RIR.objects.all()
model_form = forms.RIRCSVForm
table = tables.RIRTable
default_return_url = 'ipam:rir_list'
@@ -380,6 +382,7 @@ class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_aggregate'
+ queryset = Aggregate.objects.all()
model_form = forms.AggregateCSVForm
table = tables.AggregateTable
default_return_url = 'ipam:aggregate_list'
@@ -425,6 +428,7 @@ class RoleEditView(RoleCreateView):
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
+ queryset = Role.objects.all()
model_form = forms.RoleCSVForm
table = tables.RoleTable
default_return_url = 'ipam:role_list'
@@ -782,6 +786,7 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_ipaddress'
+ queryset = IPAddress.objects.all()
model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable
default_return_url = 'ipam:ipaddress_list'
@@ -829,6 +834,7 @@ class VLANGroupEditView(VLANGroupCreateView):
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
+ queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable
default_return_url = 'ipam:vlangroup_list'
@@ -952,6 +958,7 @@ class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlan'
+ queryset = VLAN.objects.all()
model_form = forms.VLANCSVForm
table = tables.VLANTable
default_return_url = 'ipam:vlan_list'
@@ -1018,6 +1025,7 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_service'
+ queryset = Service.objects.all()
model_form = forms.ServiceCSVForm
table = tables.ServiceTable
default_return_url = 'ipam:service_list'
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index b40e41cb3..8ce9addb4 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -49,6 +49,7 @@ class SecretRoleEditView(SecretRoleCreateView):
class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'secrets.add_secretrole'
+ queryset = SecretRole.objects.all()
model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
@@ -197,6 +198,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class SecretBulkImportView(BulkImportView):
permission_required = 'secrets.add_secret'
+ queryset = Secret.objects.all()
model_form = forms.SecretCSVForm
table = tables.SecretTable
template_name = 'secrets/secret_import.html'
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 2af44094f..745362271 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -43,6 +43,7 @@ class TenantGroupEditView(TenantGroupCreateView):
class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenantgroup'
+ queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupCSVForm
table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list'
@@ -113,6 +114,7 @@ class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenant'
+ queryset = Tenant.objects.all()
model_form = forms.TenantCSVForm
table = tables.TenantTable
default_return_url = 'tenancy:tenant_list'
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 44dd40d90..01eb6d2ba 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -676,11 +676,11 @@ class BulkEditView(GetReturnURLMixin, View):
"""
Edit objects in bulk.
- queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- filter: FilterSet to apply when deleting by QuerySet
- table: The table used to display devices being edited
- form: The form class used to edit objects in bulk
- template_name: The name of the template
+ :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ :param filter: FilterSet to apply when deleting by QuerySet
+ :param table: The table used to display devices being edited
+ :param form: The form class used to edit objects in bulk
+ :param template_name: The name of the template
"""
queryset = None
filterset = None
@@ -829,11 +829,11 @@ class BulkDeleteView(GetReturnURLMixin, View):
"""
Delete objects in bulk.
- queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- filter: FilterSet to apply when deleting by QuerySet
- table: The table used to display devices being deleted
- form: The form class used to delete objects in bulk
- template_name: The name of the template
+ :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ :param filter: FilterSet to apply when deleting by QuerySet
+ :param table: The table used to display devices being deleted
+ :param form: The form class used to delete objects in bulk
+ :param template_name: The name of the template
"""
queryset = None
filterset = None
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 68a2443ae..c6f107be7 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -41,6 +41,7 @@ class ClusterTypeEditView(ClusterTypeCreateView):
class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustertype'
+ queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeCSVForm
table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list'
@@ -76,6 +77,7 @@ class ClusterGroupEditView(ClusterGroupCreateView):
class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustergroup'
+ queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupCSVForm
table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list'
@@ -138,6 +140,7 @@ class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_cluster'
+ queryset = Cluster.objects.all()
model_form = forms.ClusterCSVForm
table = tables.ClusterTable
default_return_url = 'virtualization:cluster_list'
@@ -299,6 +302,7 @@ class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_virtualmachine'
+ queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineCSVForm
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'
From cc6e74dfd53b9d8fc3c5937055c9e90fcaa05275 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 13:12:15 -0400
Subject: [PATCH 051/300] Move ObjectPermissionRequiredMixin to utilities.views
---
netbox/dcim/views.py | 3 +-
netbox/ipam/views.py | 2 +-
netbox/netbox/authentication.py | 55 -----------------------------
netbox/utilities/permissions.py | 15 ++++++++
netbox/utilities/views.py | 62 ++++++++++++++++++++++++++++++++-
5 files changed, 78 insertions(+), 59 deletions(-)
delete mode 100644 netbox/netbox/authentication.py
create mode 100644 netbox/utilities/permissions.py
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d6b97e128..2bcf876c6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -21,13 +21,12 @@ from extras.models import Graph
from extras.views import ObjectConfigContextView
from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
-from netbox.authentication import ObjectPermissionRequiredMixin
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import csv_format
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
- ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index ab97afc2a..bb0844d4d 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -8,10 +8,10 @@ from django.views.generic import View
from django_tables2 import RequestConfig
from dcim.models import Device, Interface
-from netbox.authentication import ObjectPermissionRequiredMixin
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
deleted file mode 100644
index 2e68e6ef1..000000000
--- a/netbox/netbox/authentication.py
+++ /dev/null
@@ -1,55 +0,0 @@
-from django.contrib.auth.mixins import AccessMixin
-from django.core.exceptions import ImproperlyConfigured
-
-from users.models import ObjectPermission
-
-
-class ObjectPermissionRequiredMixin(AccessMixin):
- """
- Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
- permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
- to return only those objects on which the user is permitted to perform the specified action.
- """
- permission_required = None
-
- def has_permission(self):
- user = self.request.user
-
- # First, check that the user is granted the required permission at either the model or object level.
- if not user.has_perm(self.permission_required):
- return False
-
- # Superusers implicitly have all permissions
- if user.is_superuser:
- return True
-
- # Determine whether the permission is model-level or object-level. Model-level permissions grant the
- # specified action to *all* objects, so no further action is needed.
- if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
- return True
-
- # If the permission is granted only at the object level, filter the view's queryset to return only objects
- # on which the user is permitted to perform the specified action.
- attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(attrs)
- return True
-
- def dispatch(self, request, *args, **kwargs):
- if self.permission_required is None:
- raise ImproperlyConfigured(
- '{0} is missing the permission_required attribute. Define {0}.permission_required, or override '
- '{0}.get_permission_required().'.format(self.__class__.__name__)
- )
-
- if not hasattr(self, 'queryset'):
- raise ImproperlyConfigured(
- '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
- 'a base queryset'.format(self.__class__.__name__)
- )
-
- if not self.has_permission():
- return self.handle_no_permission()
-
- return super().dispatch(request, *args, **kwargs)
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
new file mode 100644
index 000000000..516d6fe5b
--- /dev/null
+++ b/netbox/utilities/permissions.py
@@ -0,0 +1,15 @@
+def get_permission_for_model(model, action):
+ """
+ Resolve the named permission for a given model (or instance) and action (e.g. view or add).
+
+ :param model: A model or instance
+ :param action: View, add, change, or delete (string)
+ """
+ if action not in ('view', 'add', 'change', 'delete'):
+ raise ValueError(f"Unsupported action: {action}")
+
+ return '{}.{}_{}'.format(
+ model._meta.app_label,
+ action,
+ model._meta.model_name
+ )
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 01eb6d2ba..6097fa5b2 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -4,7 +4,8 @@ from copy import deepcopy
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
+from django.contrib.auth.mixins import AccessMixin
+from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
@@ -32,6 +33,61 @@ from .forms import ConfirmationForm, ImportForm
from .paginator import EnhancedPaginator, get_paginate_count
+#
+# Mixins
+#
+
+class ObjectPermissionRequiredMixin(AccessMixin):
+ """
+ Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
+ permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
+ to return only those objects on which the user is permitted to perform the specified action.
+ """
+ permission_required = None
+
+ def has_permission(self):
+ user = self.request.user
+
+ # First, check that the user is granted the required permission at either the model or object level.
+ if not user.has_perm(self.permission_required):
+ return False
+
+ # Superusers implicitly have all permissions
+ if user.is_superuser:
+ return True
+
+ # Determine whether the permission is model-level or object-level. Model-level permissions grant the
+ # specified action to *all* objects, so no further action is needed.
+ if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
+ return True
+
+ # If the permission is granted only at the object level, filter the view's queryset to return only objects
+ # on which the user is permitted to perform the specified action.
+ attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required)
+ if attrs:
+ # Update the view's QuerySet to filter only the permitted objects
+ self.queryset = self.queryset.filter(attrs)
+ return True
+
+ def dispatch(self, request, *args, **kwargs):
+ if self.permission_required is None:
+ raise ImproperlyConfigured(
+ '{0} is missing the permission_required attribute. Define {0}.permission_required, or override '
+ '{0}.get_permission_required().'.format(self.__class__.__name__)
+ )
+
+ if not hasattr(self, 'queryset'):
+ raise ImproperlyConfigured(
+ '{} has no queryset defined. ObjectPermissionRequiredMixin may only be used on views which define '
+ 'a base queryset'.format(self.__class__.__name__)
+ )
+
+ if not self.has_permission():
+ return self.handle_no_permission()
+
+ return super().dispatch(request, *args, **kwargs)
+
+
class GetReturnURLMixin(object):
"""
Provides logic for determining where a user should be redirected after processing a form.
@@ -58,6 +114,10 @@ class GetReturnURLMixin(object):
return reverse('home')
+#
+# Generic views
+#
+
class ObjectListView(View):
"""
List a series of objects.
From 993ee8c900a45b433e20dee8d3751f42992bf7f8 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 13:22:09 -0400
Subject: [PATCH 052/300] Transition ObjectListView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/views.py | 9 ++--
netbox/dcim/views.py | 79 ++++++++++++----------------------
netbox/extras/views.py | 9 ++--
netbox/ipam/views.py | 27 ++++--------
netbox/secrets/views.py | 6 +--
netbox/tenancy/views.py | 6 +--
netbox/utilities/views.py | 33 ++++++++------
netbox/virtualization/views.py | 12 ++----
8 files changed, 69 insertions(+), 112 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index c3b09f596..e3f347398 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -23,8 +23,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
-class ProviderListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_provider'
+class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
@@ -107,8 +106,7 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuit Types
#
-class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_circuittype'
+class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
@@ -143,8 +141,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuits
#
-class CircuitListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'circuits.view_circuit'
+class CircuitListView(ObjectListView):
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site'
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 2bcf876c6..9faad490e 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -141,8 +141,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
# Regions
#
-class RegionListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_region'
+class RegionListView(ObjectListView):
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -186,8 +185,7 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Sites
#
-class SiteListView(ObjectPermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_site'
+class SiteListView(ObjectListView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
filterset_form = forms.SiteFilterForm
@@ -267,8 +265,7 @@ class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack groups
#
-class RackGroupListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackgroup'
+class RackGroupListView(ObjectListView):
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
Rack,
@@ -312,8 +309,7 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack roles
#
-class RackRoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackrole'
+class RackRoleListView(ObjectListView):
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
@@ -348,8 +344,7 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Racks
#
-class RackListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rack'
+class RackListView(ObjectListView):
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate(
@@ -476,8 +471,7 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack reservations
#
-class RackReservationListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rackreservation'
+class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.prefetch_related('rack__site')
filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
@@ -561,8 +555,7 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Manufacturers
#
-class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_manufacturer'
+class ManufacturerListView(ObjectListView):
queryset = Manufacturer.objects.annotate(
devicetype_count=Count('device_types', distinct=True),
inventoryitem_count=Count('inventory_items', distinct=True),
@@ -601,8 +594,7 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device types
#
-class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicetype'
+class DeviceTypeListView(ObjectListView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filterset = filters.DeviceTypeFilterSet
filterset_form = forms.DeviceTypeFilterForm
@@ -1026,8 +1018,7 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device roles
#
-class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicerole'
+class DeviceRoleListView(ObjectListView):
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
@@ -1062,8 +1053,7 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Platforms
#
-class PlatformListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_platform'
+class PlatformListView(ObjectListView):
queryset = Platform.objects.all()
table = tables.PlatformTable
@@ -1098,8 +1088,7 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Devices
#
-class DeviceListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_device'
+class DeviceListView(ObjectListView):
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
)
@@ -1323,8 +1312,7 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Console ports
#
-class ConsolePortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_consoleport'
+class ConsolePortListView(ObjectListView):
queryset = ConsolePort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
@@ -1379,8 +1367,7 @@ class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Console server ports
#
-class ConsoleServerPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_consoleserverport'
+class ConsoleServerPortListView(ObjectListView):
queryset = ConsoleServerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
@@ -1447,8 +1434,7 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power ports
#
-class PowerPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerport'
+class PowerPortListView(ObjectListView):
queryset = PowerPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
@@ -1503,8 +1489,7 @@ class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power outlets
#
-class PowerOutletListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_poweroutlet'
+class PowerOutletListView(ObjectListView):
queryset = PowerOutlet.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
@@ -1571,8 +1556,7 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces
#
-class InterfaceListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_interface'
+class InterfaceListView(ObjectListView):
queryset = Interface.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
@@ -1676,8 +1660,7 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Front ports
#
-class FrontPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_frontport'
+class FrontPortListView(ObjectListView):
queryset = FrontPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
@@ -1744,8 +1727,7 @@ class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rear ports
#
-class RearPortListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_rearport'
+class RearPortListView(ObjectListView):
queryset = RearPort.objects.prefetch_related('device', 'device__tenant', 'device__site', 'cable')
filterset = filters.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
@@ -1812,8 +1794,7 @@ class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device bays
#
-class DeviceBayListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_devicebay'
+class DeviceBayListView(ObjectListView):
queryset = DeviceBay.objects.prefetch_related(
'device', 'device__site', 'installed_device', 'installed_device__site'
)
@@ -2045,8 +2026,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
# Cables
#
-class CableListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_cable'
+class CableListView(ObjectListView):
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
@@ -2215,7 +2195,7 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Connections
#
-class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
+class ConsoleConnectionsListView(ObjectListView):
permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
@@ -2247,7 +2227,7 @@ class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
return '\n'.join(csv_data)
-class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
+class PowerConnectionsListView(ObjectListView):
permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device'
@@ -2279,8 +2259,7 @@ class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
return '\n'.join(csv_data)
-class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_interface'
+class InterfaceConnectionsListView(ObjectListView):
queryset = Interface.objects.prefetch_related(
'device', 'cable', '_connected_interface__device'
).filter(
@@ -2319,8 +2298,7 @@ class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
# Inventory items
#
-class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_inventoryitem'
+class InventoryItemListView(ObjectListView):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filterset = filters.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
@@ -2376,8 +2354,7 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Virtual chassis
#
-class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_virtualchassis'
+class VirtualChassisListView(ObjectListView):
queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members'))
table = tables.VirtualChassisTable
filterset = filters.VirtualChassisFilterSet
@@ -2644,8 +2621,7 @@ class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power panels
#
-class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerpanel'
+class PowerPanelListView(ObjectListView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
@@ -2724,8 +2700,7 @@ class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Power feeds
#
-class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'dcim.view_powerfeed'
+class PowerFeedListView(ObjectListView):
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack'
)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index e466414b6..c1bee4dd7 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -25,8 +25,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
# Tags
#
-class TagListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'extras.view_tag'
+class TagListView(ObjectListView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
@@ -106,8 +105,7 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Config contexts
#
-class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'extras.view_configcontext'
+class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
@@ -200,8 +198,7 @@ class ObjectConfigContextView(View):
# Change logging
#
-class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'extras.view_objectchange'
+class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filterset = filters.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index bb0844d4d..09c3f7892 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -113,8 +113,7 @@ def add_available_vlans(vlan_group, vlans):
# VRFs
#
-class VRFListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_vrf'
+class VRFListView(ObjectListView):
queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet
filterset_form = forms.VRFFilterForm
@@ -182,8 +181,7 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# RIRs
#
-class RIRListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_rir'
+class RIRListView(ObjectListView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet
filterset_form = forms.RIRFilterForm
@@ -290,8 +288,7 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Aggregates
#
-class AggregateListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_aggregate'
+class AggregateListView(ObjectListView):
queryset = Aggregate.objects.prefetch_related('rir').annotate(
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
)
@@ -409,8 +406,7 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefix/VLAN roles
#
-class RoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_role'
+class RoleListView(ObjectListView):
queryset = Role.objects.all()
table = tables.RoleTable
@@ -445,8 +441,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Prefixes
#
-class PrefixListView(ObjectPermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_prefix'
+class PrefixListView(ObjectListView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
@@ -638,8 +633,7 @@ class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView):
# IP addresses
#
-class IPAddressListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_ipaddress'
+class IPAddressListView(ObjectListView):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device', 'interface__virtual_machine'
)
@@ -813,8 +807,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# VLAN groups
#
-class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_vlangroup'
+class VLANGroupListView(ObjectListView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
@@ -889,8 +882,7 @@ class VLANGroupVLANsView(PermissionRequiredMixin, View):
# VLANs
#
-class VLANListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_vlan'
+class VLANListView(ObjectListView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
filterset = filters.VLANFilterSet
filterset_form = forms.VLANFilterForm
@@ -985,8 +977,7 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Services
#
-class ServiceListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'ipam.view_service'
+class ServiceListView(ObjectListView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet
filterset_form = forms.ServiceFilterForm
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 8ce9addb4..eda845375 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -30,8 +30,7 @@ def get_session_key(request):
# Secret roles
#
-class SecretRoleListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'secrets.view_secretrole'
+class SecretRoleListView(ObjectListView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
@@ -66,8 +65,7 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Secrets
#
-class SecretListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'secrets.view_secret'
+class SecretListView(ObjectListView):
queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet
filterset_form = forms.SecretFilterForm
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 745362271..b4e37d153 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -18,8 +18,7 @@ from .models import Tenant, TenantGroup
# Tenant groups
#
-class TenantGroupListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'tenancy.view_tenantgroup'
+class TenantGroupListView(ObjectListView):
queryset = TenantGroup.objects.add_related_count(
TenantGroup.objects.all(),
Tenant,
@@ -60,8 +59,7 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Tenants
#
-class TenantListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'tenancy.view_tenant'
+class TenantListView(ObjectListView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet
filterset_form = forms.TenantFilterForm
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 6097fa5b2..8b4efeb5a 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -27,6 +27,7 @@ from extras.querysets import CustomFieldQueryset
from users.models import ObjectPermission
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
+from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
@@ -45,11 +46,15 @@ class ObjectPermissionRequiredMixin(AccessMixin):
"""
permission_required = None
+ def get_required_permission(self):
+ return self.permission_required
+
def has_permission(self):
user = self.request.user
+ permission_required = self.get_required_permission()
# First, check that the user is granted the required permission at either the model or object level.
- if not user.has_perm(self.permission_required):
+ if not user.has_perm(permission_required):
return False
# Superusers implicitly have all permissions
@@ -58,23 +63,18 @@ class ObjectPermissionRequiredMixin(AccessMixin):
# Determine whether the permission is model-level or object-level. Model-level permissions grant the
# specified action to *all* objects, so no further action is needed.
- if self.permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
+ if permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
return True
# If the permission is granted only at the object level, filter the view's queryset to return only objects
# on which the user is permitted to perform the specified action.
- attrs = ObjectPermission.objects.get_attr_constraints(user, self.permission_required)
+ attrs = ObjectPermission.objects.get_attr_constraints(user, permission_required)
if attrs:
# Update the view's QuerySet to filter only the permitted objects
self.queryset = self.queryset.filter(attrs)
return True
def dispatch(self, request, *args, **kwargs):
- if self.permission_required is None:
- raise ImproperlyConfigured(
- '{0} is missing the permission_required attribute. Define {0}.permission_required, or override '
- '{0}.get_permission_required().'.format(self.__class__.__name__)
- )
if not hasattr(self, 'queryset'):
raise ImproperlyConfigured(
@@ -118,15 +118,15 @@ class GetReturnURLMixin(object):
# Generic views
#
-class ObjectListView(View):
+class ObjectListView(ObjectPermissionRequiredMixin, View):
"""
List a series of objects.
- queryset: The queryset of objects to display
- filter: A django-filter FilterSet that is applied to the queryset
- filter_form: The form used to render filter options
- table: The django-tables2 Table used to render the objects list
- template_name: The name of the template
+ :param queryset: The queryset of objects to display
+ :param filter: A django-filter FilterSet that is applied to the queryset
+ :param filter_form: The form used to render filter options
+ :param table: The django-tables2 Table used to render the objects list
+ :param template_name: The name of the template
"""
queryset = None
filterset = None
@@ -135,6 +135,11 @@ class ObjectListView(View):
template_name = 'utilities/obj_list.html'
action_buttons = ('add', 'import', 'export')
+ def get_required_permission(self):
+ if getattr(self, 'permission_required') is not None:
+ return self.permission_required
+ return get_permission_for_model(self.queryset.model, 'view')
+
def queryset_to_yaml(self):
"""
Export the queryset of objects as concatenated YAML documents.
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index c6f107be7..85dbf4774 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -22,8 +22,7 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
# Cluster types
#
-class ClusterTypeListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'virtualization.view_clustertype'
+class ClusterTypeListView(ObjectListView):
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable
@@ -58,8 +57,7 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Cluster groups
#
-class ClusterGroupListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'virtualization.view_clustergroup'
+class ClusterGroupListView(ObjectListView):
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable
@@ -94,8 +92,7 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Clusters
#
-class ClusterListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'virtualization.view_cluster'
+class ClusterListView(ObjectListView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site', 'tenant')
table = tables.ClusterTable
filterset = filters.ClusterFilterSet
@@ -251,8 +248,7 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
# Virtual machines
#
-class VirtualMachineListView(PermissionRequiredMixin, ObjectListView):
- permission_required = 'virtualization.view_virtualmachine'
+class VirtualMachineListView(ObjectListView):
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6')
filterset = filters.VirtualMachineFilterSet
filterset_form = forms.VirtualMachineFilterForm
From 406b076b95c94d1f307b9ddb823095da34d7b346 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 13:59:19 -0400
Subject: [PATCH 053/300] Transition ObjectEditView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/urls.py | 8 +-
netbox/circuits/views.py | 28 +------
netbox/dcim/urls.py | 30 +++----
netbox/dcim/views.py | 145 +++++++--------------------------
netbox/extras/urls.py | 2 +-
netbox/extras/views.py | 13 +--
netbox/ipam/urls.py | 16 ++--
netbox/ipam/views.py | 62 ++------------
netbox/secrets/urls.py | 2 +-
netbox/secrets/views.py | 7 +-
netbox/tenancy/urls.py | 4 +-
netbox/tenancy/views.py | 14 +---
netbox/utilities/views.py | 8 +-
netbox/virtualization/urls.py | 12 +--
netbox/virtualization/views.py | 31 ++-----
15 files changed, 99 insertions(+), 283 deletions(-)
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 72d9720df..1a7fa283b 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -10,7 +10,7 @@ urlpatterns = [
# Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
- path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
+ path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -21,7 +21,7 @@ urlpatterns = [
# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
- path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
+ path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
@@ -29,7 +29,7 @@ urlpatterns = [
# Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
- path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
+ path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@@ -41,7 +41,7 @@ urlpatterns = [
# Circuit terminations
- path('circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
+ path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index e3f347398..59cdac930 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -59,18 +59,13 @@ class ProviderView(PermissionRequiredMixin, View):
})
-class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_provider'
+class ProviderEditView(ObjectEditView):
queryset = Provider.objects.all()
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
-class ProviderEditView(ProviderCreateView):
- permission_required = 'circuits.change_provider'
-
-
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.all()
@@ -111,17 +106,12 @@ class CircuitTypeListView(ObjectListView):
table = tables.CircuitTypeTable
-class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuittype'
+class CircuitTypeEditView(ObjectEditView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
default_return_url = 'circuits:circuittype_list'
-class CircuitTypeEditView(CircuitTypeCreateView):
- permission_required = 'circuits.change_circuittype'
-
-
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
queryset = CircuitType.objects.all()
@@ -175,18 +165,13 @@ class CircuitView(PermissionRequiredMixin, View):
})
-class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuit'
+class CircuitEditView(ObjectEditView):
queryset = Circuit.objects.all()
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
-class CircuitEditView(CircuitCreateView):
- permission_required = 'circuits.change_circuit'
-
-
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.all()
@@ -271,8 +256,7 @@ def circuit_terminations_swap(request, pk):
# Circuit terminations
#
-class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'circuits.add_circuittermination'
+class CircuitTerminationEditView(ObjectEditView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
@@ -286,10 +270,6 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url()
-class CircuitTerminationEditView(CircuitTerminationCreateView):
- permission_required = 'circuits.change_circuittermination'
-
-
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
queryset = CircuitTermination.objects.all()
diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py
index 0b1f6250e..a0d6bdc92 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -1,7 +1,7 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
-from ipam.views import ServiceCreateView
+from ipam.views import ServiceEditView
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
@@ -14,7 +14,7 @@ urlpatterns = [
# Regions
path('regions/', views.RegionListView.as_view(), name='region_list'),
- path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
+ path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'),
@@ -22,7 +22,7 @@ urlpatterns = [
# Sites
path('sites/', views.SiteListView.as_view(), name='site_list'),
- path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
+ path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@@ -34,7 +34,7 @@ urlpatterns = [
# Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
- path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
+ path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
@@ -42,7 +42,7 @@ urlpatterns = [
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
- path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
+ path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
@@ -50,7 +50,7 @@ urlpatterns = [
# Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
- path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
+ path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'),
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@@ -62,7 +62,7 @@ urlpatterns = [
# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
- path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
+ path('racks/add/', views.RackEditView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@@ -74,7 +74,7 @@ urlpatterns = [
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
- path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
+ path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
@@ -82,7 +82,7 @@ urlpatterns = [
# Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
- path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
+ path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@@ -149,7 +149,7 @@ urlpatterns = [
# Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
- path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
+ path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
@@ -157,7 +157,7 @@ urlpatterns = [
# Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
- path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
+ path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'),
@@ -165,7 +165,7 @@ urlpatterns = [
# Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'),
- path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
+ path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@@ -179,7 +179,7 @@ urlpatterns = [
path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'),
- path('devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
+ path('devices//services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
@@ -332,7 +332,7 @@ urlpatterns = [
# Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
- path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
+ path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@@ -343,7 +343,7 @@ urlpatterns = [
# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
- path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
+ path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 9faad490e..e33f3bd04 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -154,17 +154,12 @@ class RegionListView(ObjectListView):
table = tables.RegionTable
-class RegionCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_region'
+class RegionEditView(ObjectEditView):
queryset = Region.objects.all()
model_form = forms.RegionForm
default_return_url = 'dcim:region_list'
-class RegionEditView(RegionCreateView):
- permission_required = 'dcim.change_region'
-
-
class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_region'
queryset = Region.objects.all()
@@ -218,18 +213,13 @@ class SiteView(ObjectPermissionRequiredMixin, View):
})
-class SiteCreateView(ObjectPermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_site'
+class SiteEditView(ObjectEditView):
queryset = Site.objects.all()
model_form = forms.SiteForm
template_name = 'dcim/site_edit.html'
default_return_url = 'dcim:site_list'
-class SiteEditView(SiteCreateView):
- permission_required = 'dcim.change_site'
-
-
class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_site'
queryset = Site.objects.all()
@@ -278,17 +268,12 @@ class RackGroupListView(ObjectListView):
table = tables.RackGroupTable
-class RackGroupCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackgroup'
+class RackGroupEditView(ObjectEditView):
queryset = RackGroup.objects.all()
model_form = forms.RackGroupForm
default_return_url = 'dcim:rackgroup_list'
-class RackGroupEditView(RackGroupCreateView):
- permission_required = 'dcim.change_rackgroup'
-
-
class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackgroup'
queryset = RackGroup.objects.all()
@@ -314,17 +299,12 @@ class RackRoleListView(ObjectListView):
table = tables.RackRoleTable
-class RackRoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackrole'
+class RackRoleEditView(ObjectEditView):
queryset = RackRole.objects.all()
model_form = forms.RackRoleForm
default_return_url = 'dcim:rackrole_list'
-class RackRoleEditView(RackRoleCreateView):
- permission_required = 'dcim.change_rackrole'
-
-
class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_rackrole'
queryset = RackRole.objects.all()
@@ -424,18 +404,13 @@ class RackView(PermissionRequiredMixin, View):
})
-class RackCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rack'
+class RackEditView(ObjectEditView):
queryset = Rack.objects.all()
model_form = forms.RackForm
template_name = 'dcim/rack_edit.html'
default_return_url = 'dcim:rack_list'
-class RackEditView(RackCreateView):
- permission_required = 'dcim.change_rack'
-
-
class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rack'
queryset = Rack.objects.all()
@@ -491,8 +466,7 @@ class RackReservationView(PermissionRequiredMixin, View):
})
-class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_rackreservation'
+class RackReservationEditView(ObjectEditView):
queryset = RackReservation.objects.all()
model_form = forms.RackReservationForm
template_name = 'dcim/rackreservation_edit.html'
@@ -506,10 +480,6 @@ class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj
-class RackReservationEditView(RackReservationCreateView):
- permission_required = 'dcim.change_rackreservation'
-
-
class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_rackreservation'
queryset = RackReservation.objects.all()
@@ -564,17 +534,12 @@ class ManufacturerListView(ObjectListView):
table = tables.ManufacturerTable
-class ManufacturerCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_manufacturer'
+class ManufacturerEditView(ObjectEditView):
queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerForm
default_return_url = 'dcim:manufacturer_list'
-class ManufacturerEditView(ManufacturerCreateView):
- permission_required = 'dcim.change_manufacturer'
-
-
class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_manufacturer'
queryset = Manufacturer.objects.all()
@@ -664,18 +629,13 @@ class DeviceTypeView(PermissionRequiredMixin, View):
})
-class DeviceTypeCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_devicetype'
+class DeviceTypeEditView(ObjectEditView):
queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeForm
template_name = 'dcim/devicetype_edit.html'
default_return_url = 'dcim:devicetype_list'
-class DeviceTypeEditView(DeviceTypeCreateView):
- permission_required = 'dcim.change_devicetype'
-
-
class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_devicetype'
queryset = DeviceType.objects.all()
@@ -738,8 +698,7 @@ class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
template_name = 'dcim/device_component_add.html'
-class ConsolePortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleporttemplate'
+class ConsolePortTemplateEditView(ObjectEditView):
queryset = ConsolePortTemplate.objects.all()
model_form = forms.ConsolePortTemplateForm
@@ -774,8 +733,7 @@ class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCrea
template_name = 'dcim/device_component_add.html'
-class ConsoleServerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleserverporttemplate'
+class ConsoleServerPortTemplateEditView(ObjectEditView):
queryset = ConsoleServerPortTemplate.objects.all()
model_form = forms.ConsoleServerPortTemplateForm
@@ -810,8 +768,7 @@ class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class PowerPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_powerporttemplate'
+class PowerPortTemplateEditView(ObjectEditView):
queryset = PowerPortTemplate.objects.all()
model_form = forms.PowerPortTemplateForm
@@ -846,8 +803,7 @@ class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView
template_name = 'dcim/device_component_add.html'
-class PowerOutletTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_poweroutlettemplate'
+class PowerOutletTemplateEditView(ObjectEditView):
queryset = PowerOutletTemplate.objects.all()
model_form = forms.PowerOutletTemplateForm
@@ -882,8 +838,7 @@ class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class InterfaceTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_interfacetemplate'
+class InterfaceTemplateEditView(ObjectEditView):
queryset = InterfaceTemplate.objects.all()
model_form = forms.InterfaceTemplateForm
@@ -918,8 +873,7 @@ class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class FrontPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_frontporttemplate'
+class FrontPortTemplateEditView(ObjectEditView):
queryset = FrontPortTemplate.objects.all()
model_form = forms.FrontPortTemplateForm
@@ -954,8 +908,7 @@ class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class RearPortTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_rearporttemplate'
+class RearPortTemplateEditView(ObjectEditView):
queryset = RearPortTemplate.objects.all()
model_form = forms.RearPortTemplateForm
@@ -990,8 +943,7 @@ class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class DeviceBayTemplateEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_devicebaytemplate'
+class DeviceBayTemplateEditView(ObjectEditView):
queryset = DeviceBayTemplate.objects.all()
model_form = forms.DeviceBayTemplateForm
@@ -1023,17 +975,12 @@ class DeviceRoleListView(ObjectListView):
table = tables.DeviceRoleTable
-class DeviceRoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_devicerole'
+class DeviceRoleEditView(ObjectEditView):
queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleForm
default_return_url = 'dcim:devicerole_list'
-class DeviceRoleEditView(DeviceRoleCreateView):
- permission_required = 'dcim.change_devicerole'
-
-
class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_devicerole'
queryset = DeviceRole.objects.all()
@@ -1058,17 +1005,12 @@ class PlatformListView(ObjectListView):
table = tables.PlatformTable
-class PlatformCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_platform'
+class PlatformEditView(ObjectEditView):
queryset = Platform.objects.all()
model_form = forms.PlatformForm
default_return_url = 'dcim:platform_list'
-class PlatformEditView(PlatformCreateView):
- permission_required = 'dcim.change_platform'
-
-
class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_platform'
queryset = Platform.objects.all()
@@ -1244,18 +1186,13 @@ class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
base_template = 'dcim/device.html'
-class DeviceCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_device'
+class DeviceEditView(ObjectEditView):
queryset = Device.objects.all()
model_form = forms.DeviceForm
template_name = 'dcim/device_edit.html'
default_return_url = 'dcim:device_list'
-class DeviceEditView(DeviceCreateView):
- permission_required = 'dcim.change_device'
-
-
class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_device'
queryset = Device.objects.all()
@@ -1328,8 +1265,7 @@ class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class ConsolePortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleport'
+class ConsolePortEditView(ObjectEditView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm
@@ -1383,8 +1319,7 @@ class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class ConsoleServerPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_consoleserverport'
+class ConsoleServerPortEditView(ObjectEditView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm
@@ -1450,8 +1385,7 @@ class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class PowerPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_powerport'
+class PowerPortEditView(ObjectEditView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm
@@ -1505,8 +1439,7 @@ class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class PowerOutletEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_poweroutlet'
+class PowerOutletEditView(ObjectEditView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm
@@ -1608,8 +1541,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_interface'
+class InterfaceEditView(ObjectEditView):
queryset = Interface.objects.all()
model_form = forms.InterfaceForm
template_name = 'dcim/interface_edit.html'
@@ -1676,8 +1608,7 @@ class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class FrontPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_frontport'
+class FrontPortEditView(ObjectEditView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortForm
@@ -1743,8 +1674,7 @@ class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class RearPortEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_rearport'
+class RearPortEditView(ObjectEditView):
queryset = RearPort.objects.all()
model_form = forms.RearPortForm
@@ -1812,8 +1742,7 @@ class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayEditView(ObjectEditView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm
@@ -2152,8 +2081,7 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
})
-class CableEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_cable'
+class CableEditView(ObjectEditView):
queryset = Cable.objects.all()
model_form = forms.CableForm
template_name = 'dcim/cable_edit.html'
@@ -2306,8 +2234,7 @@ class InventoryItemListView(ObjectListView):
action_buttons = ('import', 'export')
-class InventoryItemEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_inventoryitem'
+class InventoryItemEditView(ObjectEditView):
queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemForm
@@ -2650,17 +2577,12 @@ class PowerPanelView(PermissionRequiredMixin, View):
})
-class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_powerpanel'
+class PowerPanelEditView(ObjectEditView):
queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelForm
default_return_url = 'dcim:powerpanel_list'
-class PowerPanelEditView(PowerPanelCreateView):
- permission_required = 'dcim.change_powerpanel'
-
-
class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerpanel'
queryset = PowerPanel.objects.all()
@@ -2721,18 +2643,13 @@ class PowerFeedView(PermissionRequiredMixin, View):
})
-class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.add_powerfeed'
+class PowerFeedEditView(ObjectEditView):
queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedForm
template_name = 'dcim/powerfeed_edit.html'
default_return_url = 'dcim:powerfeed_list'
-class PowerFeedEditView(PowerFeedCreateView):
- permission_required = 'dcim.change_powerfeed'
-
-
class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerfeed'
queryset = PowerFeed.objects.all()
diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py
index a486ce7fc..3eee303a3 100644
--- a/netbox/extras/urls.py
+++ b/netbox/extras/urls.py
@@ -18,7 +18,7 @@ urlpatterns = [
# Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
- path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
+ path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts//', views.ConfigContextView.as_view(), name='configcontext'),
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index c1bee4dd7..b5d9306f8 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -64,8 +64,7 @@ class TagView(PermissionRequiredMixin, View):
})
-class TagEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'extras.change_tag'
+class TagEditView(ObjectEditView):
queryset = Tag.objects.all()
model_form = forms.TagForm
default_return_url = 'extras:tag_list'
@@ -132,18 +131,13 @@ class ConfigContextView(PermissionRequiredMixin, View):
})
-class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'extras.add_configcontext'
+class ConfigContextEditView(ObjectEditView):
queryset = ConfigContext.objects.all()
model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html'
-class ConfigContextEditView(ConfigContextCreateView):
- permission_required = 'extras.change_configcontext'
-
-
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
queryset = ConfigContext.objects.all()
@@ -301,8 +295,7 @@ class ObjectChangeLogView(View):
# Image attachments
#
-class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'extras.change_imageattachment'
+class ImageAttachmentEditView(ObjectEditView):
queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm
diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py
index f1211473e..de8fc86eb 100644
--- a/netbox/ipam/urls.py
+++ b/netbox/ipam/urls.py
@@ -9,7 +9,7 @@ urlpatterns = [
# VRFs
path('vrfs/', views.VRFListView.as_view(), name='vrf_list'),
- path('vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'),
+ path('vrfs/add/', views.VRFEditView.as_view(), name='vrf_add'),
path('vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'),
path('vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'),
path('vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'),
@@ -20,7 +20,7 @@ urlpatterns = [
# RIRs
path('rirs/', views.RIRListView.as_view(), name='rir_list'),
- path('rirs/add/', views.RIRCreateView.as_view(), name='rir_add'),
+ path('rirs/add/', views.RIREditView.as_view(), name='rir_add'),
path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'),
@@ -28,7 +28,7 @@ urlpatterns = [
# Aggregates
path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'),
- path('aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'),
+ path('aggregates/add/', views.AggregateEditView.as_view(), name='aggregate_add'),
path('aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'),
path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
@@ -39,7 +39,7 @@ urlpatterns = [
# Roles
path('roles/', views.RoleListView.as_view(), name='role_list'),
- path('roles/add/', views.RoleCreateView.as_view(), name='role_add'),
+ path('roles/add/', views.RoleEditView.as_view(), name='role_add'),
path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'),
@@ -47,7 +47,7 @@ urlpatterns = [
# Prefixes
path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'),
- path('prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'),
+ path('prefixes/add/', views.PrefixEditView.as_view(), name='prefix_add'),
path('prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'),
path('prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'),
path('prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'),
@@ -60,7 +60,7 @@ urlpatterns = [
# IP addresses
path('ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'),
- path('ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'),
+ path('ip-addresses/add/', views.IPAddressEditView.as_view(), name='ipaddress_add'),
path('ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'),
path('ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
@@ -73,7 +73,7 @@ urlpatterns = [
# VLAN groups
path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'),
- path('vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'),
+ path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'),
path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
@@ -82,7 +82,7 @@ urlpatterns = [
# VLANs
path('vlans/', views.VLANListView.as_view(), name='vlan_list'),
- path('vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'),
+ path('vlans/add/', views.VLANEditView.as_view(), name='vlan_add'),
path('vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'),
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 09c3f7892..220205c19 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -134,18 +134,13 @@ class VRFView(PermissionRequiredMixin, View):
})
-class VRFCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_vrf'
+class VRFEditView(ObjectEditView):
queryset = VRF.objects.all()
model_form = forms.VRFForm
template_name = 'ipam/vrf_edit.html'
default_return_url = 'ipam:vrf_list'
-class VRFEditView(VRFCreateView):
- permission_required = 'ipam.change_vrf'
-
-
class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vrf'
queryset = VRF.objects.all()
@@ -257,17 +252,12 @@ class RIRListView(ObjectListView):
return rirs
-class RIRCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_rir'
+class RIREditView(ObjectEditView):
queryset = RIR.objects.all()
model_form = forms.RIRForm
default_return_url = 'ipam:rir_list'
-class RIREditView(RIRCreateView):
- permission_required = 'ipam.change_rir'
-
-
class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_rir'
queryset = RIR.objects.all()
@@ -359,18 +349,13 @@ class AggregateView(PermissionRequiredMixin, View):
})
-class AggregateCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_aggregate'
+class AggregateEditView(ObjectEditView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateForm
template_name = 'ipam/aggregate_edit.html'
default_return_url = 'ipam:aggregate_list'
-class AggregateEditView(AggregateCreateView):
- permission_required = 'ipam.change_aggregate'
-
-
class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_aggregate'
queryset = Aggregate.objects.all()
@@ -411,17 +396,12 @@ class RoleListView(ObjectListView):
table = tables.RoleTable
-class RoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_role'
+class RoleEditView(ObjectEditView):
queryset = Role.objects.all()
model_form = forms.RoleForm
default_return_url = 'ipam:role_list'
-class RoleEditView(RoleCreateView):
- permission_required = 'ipam.change_role'
-
-
class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_role'
queryset = Role.objects.all()
@@ -585,18 +565,13 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
})
-class PrefixCreateView(ObjectPermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_prefix'
+class PrefixEditView(ObjectEditView):
queryset = Prefix.objects.all()
model_form = forms.PrefixForm
template_name = 'ipam/prefix_edit.html'
default_return_url = 'ipam:prefix_list'
-class PrefixEditView(PrefixCreateView):
- permission_required = 'ipam.change_prefix'
-
-
class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_prefix'
queryset = Prefix.objects.all()
@@ -696,8 +671,7 @@ class IPAddressView(PermissionRequiredMixin, View):
})
-class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_ipaddress'
+class IPAddressEditView(ObjectEditView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
@@ -715,10 +689,6 @@ class IPAddressCreateView(PermissionRequiredMixin, ObjectEditView):
return obj
-class IPAddressEditView(IPAddressCreateView):
- permission_required = 'ipam.change_ipaddress'
-
-
class IPAddressAssignView(PermissionRequiredMixin, View):
"""
Search for IPAddresses to be assigned to an Interface.
@@ -814,17 +784,13 @@ class VLANGroupListView(ObjectListView):
table = tables.VLANGroupTable
-class VLANGroupCreateView(PermissionRequiredMixin, ObjectEditView):
+class VLANGroupEditView(ObjectEditView):
permission_required = 'ipam.add_vlangroup'
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm
default_return_url = 'ipam:vlangroup_list'
-class VLANGroupEditView(VLANGroupCreateView):
- permission_required = 'ipam.change_vlangroup'
-
-
class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'ipam.add_vlangroup'
queryset = VLANGroup.objects.all()
@@ -930,18 +896,13 @@ class VLANMembersView(PermissionRequiredMixin, View):
})
-class VLANCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_vlan'
+class VLANEditView(ObjectEditView):
queryset = VLAN.objects.all()
model_form = forms.VLANForm
template_name = 'ipam/vlan_edit.html'
default_return_url = 'ipam:vlan_list'
-class VLANEditView(VLANCreateView):
- permission_required = 'ipam.change_vlan'
-
-
class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_vlan'
queryset = VLAN.objects.all()
@@ -997,8 +958,7 @@ class ServiceView(PermissionRequiredMixin, View):
})
-class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'ipam.add_service'
+class ServiceEditView(ObjectEditView):
queryset = Service.objects.all()
model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html'
@@ -1022,10 +982,6 @@ class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'ipam:service_list'
-class ServiceEditView(ServiceCreateView):
- permission_required = 'ipam.change_service'
-
-
class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_service'
queryset = Service.objects.all()
diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py
index a19ec6ae0..ac75a7ed4 100644
--- a/netbox/secrets/urls.py
+++ b/netbox/secrets/urls.py
@@ -9,7 +9,7 @@ urlpatterns = [
# Secret roles
path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'),
- path('secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'),
+ path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'),
path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index eda845375..be0c87cee 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -35,17 +35,12 @@ class SecretRoleListView(ObjectListView):
table = tables.SecretRoleTable
-class SecretRoleCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'secrets.add_secretrole'
+class SecretRoleEditView(ObjectEditView):
queryset = SecretRole.objects.all()
model_form = forms.SecretRoleForm
default_return_url = 'secrets:secretrole_list'
-class SecretRoleEditView(SecretRoleCreateView):
- permission_required = 'secrets.change_secretrole'
-
-
class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'secrets.add_secretrole'
queryset = SecretRole.objects.all()
diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py
index 0218a5674..4c65ce4e8 100644
--- a/netbox/tenancy/urls.py
+++ b/netbox/tenancy/urls.py
@@ -9,7 +9,7 @@ urlpatterns = [
# Tenant groups
path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'),
- path('tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'),
+ path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'),
path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
@@ -17,7 +17,7 @@ urlpatterns = [
# Tenants
path('tenants/', views.TenantListView.as_view(), name='tenant_list'),
- path('tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'),
+ path('tenants/add/', views.TenantEditView.as_view(), name='tenant_add'),
path('tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'),
path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'),
path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'),
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index b4e37d153..4dbc99815 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -29,17 +29,12 @@ class TenantGroupListView(ObjectListView):
table = tables.TenantGroupTable
-class TenantGroupCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'tenancy.add_tenantgroup'
+class TenantGroupEditView(ObjectEditView):
queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupForm
default_return_url = 'tenancy:tenantgroup_list'
-class TenantGroupEditView(TenantGroupCreateView):
- permission_required = 'tenancy.change_tenantgroup'
-
-
class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'tenancy.add_tenantgroup'
queryset = TenantGroup.objects.all()
@@ -92,18 +87,13 @@ class TenantView(PermissionRequiredMixin, View):
})
-class TenantCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'tenancy.add_tenant'
+class TenantEditView(ObjectEditView):
queryset = Tenant.objects.all()
model_form = forms.TenantForm
template_name = 'tenancy/tenant_edit.html'
default_return_url = 'tenancy:tenant_list'
-class TenantEditView(TenantCreateView):
- permission_required = 'tenancy.change_tenant'
-
-
class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'tenancy.delete_tenant'
queryset = Tenant.objects.all()
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 8b4efeb5a..127e0daeb 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -278,7 +278,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
return {}
-class ObjectEditView(GetReturnURLMixin, View):
+class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Create or edit a single object.
@@ -290,6 +290,12 @@ class ObjectEditView(GetReturnURLMixin, View):
model_form = None
template_name = 'utilities/obj_edit.html'
+ def get_required_permission(self):
+ # Determine required permission based on whether we are editing an existing object
+ if self.obj.pk is None:
+ return get_permission_for_model(self.queryset.model, 'add')
+ return get_permission_for_model(self.queryset.model, 'change')
+
def get_object(self, kwargs):
# Look up an existing object by slug or PK, if provided.
if 'slug' in kwargs:
diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py
index 557f8a9ca..38ad1a8b1 100644
--- a/netbox/virtualization/urls.py
+++ b/netbox/virtualization/urls.py
@@ -1,7 +1,7 @@
from django.urls import path
from extras.views import ObjectChangeLogView
-from ipam.views import ServiceCreateView
+from ipam.views import ServiceEditView
from . import views
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -10,7 +10,7 @@ urlpatterns = [
# Cluster types
path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'),
- path('cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'),
+ path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'),
path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
@@ -18,7 +18,7 @@ urlpatterns = [
# Cluster groups
path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'),
- path('cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'),
+ path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'),
path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
@@ -26,7 +26,7 @@ urlpatterns = [
# Clusters
path('clusters/', views.ClusterListView.as_view(), name='cluster_list'),
- path('clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'),
+ path('clusters/add/', views.ClusterEditView.as_view(), name='cluster_add'),
path('clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'),
path('clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'),
path('clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'),
@@ -39,7 +39,7 @@ urlpatterns = [
# Virtual machines
path('virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'),
- path('virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'),
+ path('virtual-machines/add/', views.VirtualMachineEditView.as_view(), name='virtualmachine_add'),
path('virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'),
path('virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'),
path('virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'),
@@ -48,7 +48,7 @@ urlpatterns = [
path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'),
path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'),
path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}),
- path('virtual-machines//services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'),
+ path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'),
# VM interfaces
path('interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 85dbf4774..11090def8 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -27,17 +27,12 @@ class ClusterTypeListView(ObjectListView):
table = tables.ClusterTypeTable
-class ClusterTypeCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'virtualization.add_clustertype'
+class ClusterTypeEditView(ObjectEditView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeForm
default_return_url = 'virtualization:clustertype_list'
-class ClusterTypeEditView(ClusterTypeCreateView):
- permission_required = 'virtualization.change_clustertype'
-
-
class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustertype'
queryset = ClusterType.objects.all()
@@ -62,17 +57,12 @@ class ClusterGroupListView(ObjectListView):
table = tables.ClusterGroupTable
-class ClusterGroupCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'virtualization.add_clustergroup'
+class ClusterGroupEditView(ObjectEditView):
queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupForm
default_return_url = 'virtualization:clustergroup_list'
-class ClusterGroupEditView(ClusterGroupCreateView):
- permission_required = 'virtualization.change_clustergroup'
-
-
class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'virtualization.add_clustergroup'
queryset = ClusterGroup.objects.all()
@@ -118,17 +108,12 @@ class ClusterView(PermissionRequiredMixin, View):
})
-class ClusterCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'virtualization.add_cluster'
+class ClusterEditView(ObjectEditView):
template_name = 'virtualization/cluster_edit.html'
queryset = Cluster.objects.all()
model_form = forms.ClusterForm
-class ClusterEditView(ClusterCreateView):
- permission_required = 'virtualization.change_cluster'
-
-
class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_cluster'
queryset = Cluster.objects.all()
@@ -278,18 +263,13 @@ class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigConte
base_template = 'virtualization/virtualmachine.html'
-class VirtualMachineCreateView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'virtualization.add_virtualmachine'
+class VirtualMachineEditView(ObjectEditView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineForm
template_name = 'virtualization/virtualmachine_edit.html'
default_return_url = 'virtualization:virtualmachine_list'
-class VirtualMachineEditView(VirtualMachineCreateView):
- permission_required = 'virtualization.change_virtualmachine'
-
-
class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'virtualization.delete_virtualmachine'
queryset = VirtualMachine.objects.all()
@@ -333,8 +313,7 @@ class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'virtualization/virtualmachine_component_add.html'
-class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
- permission_required = 'dcim.change_interface'
+class InterfaceEditView(ObjectEditView):
queryset = Interface.objects.all()
model_form = forms.InterfaceForm
template_name = 'virtualization/interface_edit.html'
From 5381c4e0aeae0ede246d28f795ebea843d1b209b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 14:26:56 -0400
Subject: [PATCH 054/300] Tweak evaluation of required permission for
ObjectEditView
---
netbox/utilities/views.py | 28 +++++++++++++++++-----------
1 file changed, 17 insertions(+), 11 deletions(-)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 127e0daeb..9815018b7 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -291,10 +291,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
template_name = 'utilities/obj_edit.html'
def get_required_permission(self):
- # Determine required permission based on whether we are editing an existing object
- if self.obj.pk is None:
- return get_permission_for_model(self.queryset.model, 'add')
- return get_permission_for_model(self.queryset.model, 'change')
+ # self._permission_action is set by dispatch() to either "add" or "change" depending on whether
+ # we are modifying an existing object or creating a new one.
+ return get_permission_for_model(self.queryset.model, self._permission_action)
def get_object(self, kwargs):
# Look up an existing object by slug or PK, if provided.
@@ -311,25 +310,32 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
return obj
def dispatch(self, request, *args, **kwargs):
- self.obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
+ # Determine required permission based on whether we are editing an existing object
+ self._permission_action = 'change' if kwargs else 'add'
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
+ obj = self.alter_obj(self.get_object(kwargs), 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=self.obj, initial=initial_data)
+ form = self.model_form(instance=obj, initial=initial_data)
return render(request, self.template_name, {
- 'obj': self.obj,
+ 'obj': obj,
'obj_type': self.queryset.model._meta.verbose_name,
'form': form,
- 'return_url': self.get_return_url(request, self.obj),
+ 'return_url': self.get_return_url(request, obj),
})
def post(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.views.ObjectEditView')
- form = self.model_form(request.POST, request.FILES, instance=self.obj)
+ form = self.model_form(
+ data=request.POST,
+ files=request.FILES,
+ instance=self.alter_obj(self.get_object(kwargs), request, args, kwargs)
+ )
if form.is_valid():
logger.debug("Form validation was successful")
@@ -376,10 +382,10 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
logger.debug("Form validation failed")
return render(request, self.template_name, {
- 'obj': self.obj,
+ 'obj': obj,
'obj_type': self.queryset.model._meta.verbose_name,
'form': form,
- 'return_url': self.get_return_url(request, self.obj),
+ 'return_url': self.get_return_url(request, obj),
})
From 2b32430a1070b3bf0bddddc7c30e0dc20b3573be Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 14:34:40 -0400
Subject: [PATCH 055/300] Transition ObjectDeleteView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/views.py | 11 ++---
netbox/dcim/views.py | 78 ++++++++++++----------------------
netbox/extras/views.py | 9 ++--
netbox/ipam/views.py | 18 +++-----
netbox/secrets/views.py | 3 +-
netbox/tenancy/views.py | 3 +-
netbox/utilities/views.py | 15 ++++---
netbox/virtualization/views.py | 9 ++--
8 files changed, 53 insertions(+), 93 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 59cdac930..7016a5b9d 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -3,7 +3,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
-from django.db.models import Count, OuterRef, Subquery
+from django.db.models import Count, OuterRef
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig
@@ -66,8 +66,7 @@ class ProviderEditView(ObjectEditView):
default_return_url = 'circuits:provider_list'
-class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_provider'
+class ProviderDeleteView(ObjectDeleteView):
queryset = Provider.objects.all()
default_return_url = 'circuits:provider_list'
@@ -172,8 +171,7 @@ class CircuitEditView(ObjectEditView):
default_return_url = 'circuits:circuit_list'
-class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_circuit'
+class CircuitDeleteView(ObjectDeleteView):
queryset = Circuit.objects.all()
default_return_url = 'circuits:circuit_list'
@@ -270,6 +268,5 @@ class CircuitTerminationEditView(ObjectEditView):
return obj.circuit.get_absolute_url()
-class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'circuits.delete_circuittermination'
+class CircuitTerminationDeleteView(ObjectDeleteView):
queryset = CircuitTermination.objects.all()
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index e33f3bd04..d61d0f82f 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -220,8 +220,7 @@ class SiteEditView(ObjectEditView):
default_return_url = 'dcim:site_list'
-class SiteDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_site'
+class SiteDeleteView(ObjectDeleteView):
queryset = Site.objects.all()
default_return_url = 'dcim:site_list'
@@ -411,8 +410,7 @@ class RackEditView(ObjectEditView):
default_return_url = 'dcim:rack_list'
-class RackDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rack'
+class RackDeleteView(ObjectDeleteView):
queryset = Rack.objects.all()
default_return_url = 'dcim:rack_list'
@@ -480,8 +478,7 @@ class RackReservationEditView(ObjectEditView):
return obj
-class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rackreservation'
+class RackReservationDeleteView(ObjectDeleteView):
queryset = RackReservation.objects.all()
default_return_url = 'dcim:rackreservation_list'
@@ -636,8 +633,7 @@ class DeviceTypeEditView(ObjectEditView):
default_return_url = 'dcim:devicetype_list'
-class DeviceTypeDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicetype'
+class DeviceTypeDeleteView(ObjectDeleteView):
queryset = DeviceType.objects.all()
default_return_url = 'dcim:devicetype_list'
@@ -703,8 +699,7 @@ class ConsolePortTemplateEditView(ObjectEditView):
model_form = forms.ConsolePortTemplateForm
-class ConsolePortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleporttemplate'
+class ConsolePortTemplateDeleteView(ObjectDeleteView):
queryset = ConsolePortTemplate.objects.all()
@@ -738,8 +733,7 @@ class ConsoleServerPortTemplateEditView(ObjectEditView):
model_form = forms.ConsoleServerPortTemplateForm
-class ConsoleServerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleserverporttemplate'
+class ConsoleServerPortTemplateDeleteView(ObjectDeleteView):
queryset = ConsoleServerPortTemplate.objects.all()
@@ -773,8 +767,7 @@ class PowerPortTemplateEditView(ObjectEditView):
model_form = forms.PowerPortTemplateForm
-class PowerPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerporttemplate'
+class PowerPortTemplateDeleteView(ObjectDeleteView):
queryset = PowerPortTemplate.objects.all()
@@ -808,8 +801,7 @@ class PowerOutletTemplateEditView(ObjectEditView):
model_form = forms.PowerOutletTemplateForm
-class PowerOutletTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_poweroutlettemplate'
+class PowerOutletTemplateDeleteView(ObjectDeleteView):
queryset = PowerOutletTemplate.objects.all()
@@ -843,8 +835,7 @@ class InterfaceTemplateEditView(ObjectEditView):
model_form = forms.InterfaceTemplateForm
-class InterfaceTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_interfacetemplate'
+class InterfaceTemplateDeleteView(ObjectDeleteView):
queryset = InterfaceTemplate.objects.all()
@@ -878,8 +869,7 @@ class FrontPortTemplateEditView(ObjectEditView):
model_form = forms.FrontPortTemplateForm
-class FrontPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_frontporttemplate'
+class FrontPortTemplateDeleteView(ObjectDeleteView):
queryset = FrontPortTemplate.objects.all()
@@ -913,8 +903,7 @@ class RearPortTemplateEditView(ObjectEditView):
model_form = forms.RearPortTemplateForm
-class RearPortTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rearporttemplate'
+class RearPortTemplateDeleteView(ObjectDeleteView):
queryset = RearPortTemplate.objects.all()
@@ -948,8 +937,7 @@ class DeviceBayTemplateEditView(ObjectEditView):
model_form = forms.DeviceBayTemplateForm
-class DeviceBayTemplateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicebaytemplate'
+class DeviceBayTemplateDeleteView(ObjectDeleteView):
queryset = DeviceBayTemplate.objects.all()
@@ -1193,8 +1181,7 @@ class DeviceEditView(ObjectEditView):
default_return_url = 'dcim:device_list'
-class DeviceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_device'
+class DeviceDeleteView(ObjectDeleteView):
queryset = Device.objects.all()
default_return_url = 'dcim:device_list'
@@ -1270,8 +1257,7 @@ class ConsolePortEditView(ObjectEditView):
model_form = forms.ConsolePortForm
-class ConsolePortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleport'
+class ConsolePortDeleteView(ObjectDeleteView):
queryset = ConsolePort.objects.all()
@@ -1324,8 +1310,7 @@ class ConsoleServerPortEditView(ObjectEditView):
model_form = forms.ConsoleServerPortForm
-class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_consoleserverport'
+class ConsoleServerPortDeleteView(ObjectDeleteView):
queryset = ConsoleServerPort.objects.all()
@@ -1390,8 +1375,7 @@ class PowerPortEditView(ObjectEditView):
model_form = forms.PowerPortForm
-class PowerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerport'
+class PowerPortDeleteView(ObjectDeleteView):
queryset = PowerPort.objects.all()
@@ -1444,8 +1428,7 @@ class PowerOutletEditView(ObjectEditView):
model_form = forms.PowerOutletForm
-class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_poweroutlet'
+class PowerOutletDeleteView(ObjectDeleteView):
queryset = PowerOutlet.objects.all()
@@ -1547,8 +1530,7 @@ class InterfaceEditView(ObjectEditView):
template_name = 'dcim/interface_edit.html'
-class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_interface'
+class InterfaceDeleteView(ObjectDeleteView):
queryset = Interface.objects.all()
@@ -1613,8 +1595,7 @@ class FrontPortEditView(ObjectEditView):
model_form = forms.FrontPortForm
-class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_frontport'
+class FrontPortDeleteView(ObjectDeleteView):
queryset = FrontPort.objects.all()
@@ -1679,8 +1660,7 @@ class RearPortEditView(ObjectEditView):
model_form = forms.RearPortForm
-class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_rearport'
+class RearPortDeleteView(ObjectDeleteView):
queryset = RearPort.objects.all()
@@ -1747,8 +1727,7 @@ class DeviceBayEditView(ObjectEditView):
model_form = forms.DeviceBayForm
-class DeviceBayDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_devicebay'
+class DeviceBayDeleteView(ObjectDeleteView):
queryset = DeviceBay.objects.all()
@@ -2088,8 +2067,7 @@ class CableEditView(ObjectEditView):
default_return_url = 'dcim:cable_list'
-class CableDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_cable'
+class CableDeleteView(ObjectDeleteView):
queryset = Cable.objects.all()
default_return_url = 'dcim:cable_list'
@@ -2247,8 +2225,7 @@ class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
template_name = 'dcim/device_component_add.html'
-class InventoryItemDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_inventoryitem'
+class InventoryItemDeleteView(ObjectDeleteView):
queryset = InventoryItem.objects.all()
@@ -2420,8 +2397,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
})
-class VirtualChassisDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_virtualchassis'
+class VirtualChassisDeleteView(ObjectDeleteView):
queryset = VirtualChassis.objects.all()
default_return_url = 'dcim:device_list'
@@ -2583,8 +2559,7 @@ class PowerPanelEditView(ObjectEditView):
default_return_url = 'dcim:powerpanel_list'
-class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerpanel'
+class PowerPanelDeleteView(ObjectDeleteView):
queryset = PowerPanel.objects.all()
default_return_url = 'dcim:powerpanel_list'
@@ -2650,8 +2625,7 @@ class PowerFeedEditView(ObjectEditView):
default_return_url = 'dcim:powerfeed_list'
-class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_powerfeed'
+class PowerFeedDeleteView(ObjectDeleteView):
queryset = PowerFeed.objects.all()
default_return_url = 'dcim:powerfeed_list'
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index b5d9306f8..63764b683 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -71,8 +71,7 @@ class TagEditView(ObjectEditView):
template_name = 'extras/tag_edit.html'
-class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'extras.delete_tag'
+class TagDeleteView(ObjectDeleteView):
queryset = Tag.objects.all()
default_return_url = 'extras:tag_list'
@@ -147,8 +146,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
default_return_url = 'extras:configcontext_list'
-class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'extras.delete_configcontext'
+class ConfigContextDeleteView(ObjectDeleteView):
queryset = ConfigContext.objects.all()
default_return_url = 'extras:configcontext_list'
@@ -310,8 +308,7 @@ class ImageAttachmentEditView(ObjectEditView):
return imageattachment.parent.get_absolute_url()
-class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'extras.delete_imageattachment'
+class ImageAttachmentDeleteView(ObjectDeleteView):
queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment):
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 220205c19..176321982 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -141,8 +141,7 @@ class VRFEditView(ObjectEditView):
default_return_url = 'ipam:vrf_list'
-class VRFDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_vrf'
+class VRFDeleteView(ObjectDeleteView):
queryset = VRF.objects.all()
default_return_url = 'ipam:vrf_list'
@@ -356,8 +355,7 @@ class AggregateEditView(ObjectEditView):
default_return_url = 'ipam:aggregate_list'
-class AggregateDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_aggregate'
+class AggregateDeleteView(ObjectDeleteView):
queryset = Aggregate.objects.all()
default_return_url = 'ipam:aggregate_list'
@@ -572,8 +570,7 @@ class PrefixEditView(ObjectEditView):
default_return_url = 'ipam:prefix_list'
-class PrefixDeleteView(ObjectPermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_prefix'
+class PrefixDeleteView(ObjectDeleteView):
queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
default_return_url = 'ipam:prefix_list'
@@ -733,8 +730,7 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
})
-class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_ipaddress'
+class IPAddressDeleteView(ObjectDeleteView):
queryset = IPAddress.objects.all()
default_return_url = 'ipam:ipaddress_list'
@@ -903,8 +899,7 @@ class VLANEditView(ObjectEditView):
default_return_url = 'ipam:vlan_list'
-class VLANDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_vlan'
+class VLANDeleteView(ObjectDeleteView):
queryset = VLAN.objects.all()
default_return_url = 'ipam:vlan_list'
@@ -982,8 +977,7 @@ class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'ipam:service_list'
-class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'ipam.delete_service'
+class ServiceDeleteView(ObjectDeleteView):
queryset = Service.objects.all()
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index be0c87cee..7c69d0ac4 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -183,8 +183,7 @@ def secret_edit(request, pk):
})
-class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'secrets.delete_secret'
+class SecretDeleteView(ObjectDeleteView):
queryset = Secret.objects.all()
default_return_url = 'secrets:secret_list'
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 4dbc99815..97480bb6a 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -94,8 +94,7 @@ class TenantEditView(ObjectEditView):
default_return_url = 'tenancy:tenant_list'
-class TenantDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'tenancy.delete_tenant'
+class TenantDeleteView(ObjectDeleteView):
queryset = Tenant.objects.all()
default_return_url = 'tenancy:tenant_list'
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 9815018b7..f4267748f 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -282,9 +282,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Create or edit a single object.
- queryset: The base queryset for the object being modified
- model_form: The form used to create or edit the object
- template_name: The name of the template
+ :param queryset: The base queryset for the object being modified
+ :param model_form: The form used to create or edit the object
+ :param template_name: The name of the template
"""
queryset = None
model_form = None
@@ -389,16 +389,19 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class ObjectDeleteView(GetReturnURLMixin, View):
+class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Delete a single object.
- queryset: The base queryset for the object being deleted
- template_name: The name of the template
+ :param queryset: The base queryset for the object being deleted
+ :param template_name: The name of the template
"""
queryset = None
template_name = 'utilities/obj_delete.html'
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'delete')
+
def get_object(self, kwargs):
# Look up object by slug if one has been provided. Otherwise, use PK.
if 'slug' in kwargs:
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 11090def8..8bc3876ca 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -114,8 +114,7 @@ class ClusterEditView(ObjectEditView):
model_form = forms.ClusterForm
-class ClusterDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'virtualization.delete_cluster'
+class ClusterDeleteView(ObjectDeleteView):
queryset = Cluster.objects.all()
default_return_url = 'virtualization:cluster_list'
@@ -270,8 +269,7 @@ class VirtualMachineEditView(ObjectEditView):
default_return_url = 'virtualization:virtualmachine_list'
-class VirtualMachineDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'virtualization.delete_virtualmachine'
+class VirtualMachineDeleteView(ObjectDeleteView):
queryset = VirtualMachine.objects.all()
default_return_url = 'virtualization:virtualmachine_list'
@@ -319,8 +317,7 @@ class InterfaceEditView(ObjectEditView):
template_name = 'virtualization/interface_edit.html'
-class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
- permission_required = 'dcim.delete_interface'
+class InterfaceDeleteView(ObjectDeleteView):
queryset = Interface.objects.all()
From 5e5038d7808870347dc07c22c3ab693368fcf783 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 14:43:27 -0400
Subject: [PATCH 056/300] Transition BulkImportView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/views.py | 9 ++---
netbox/dcim/views.py | 69 ++++++++++++----------------------
netbox/ipam/views.py | 27 +++++--------
netbox/secrets/views.py | 4 +-
netbox/tenancy/views.py | 6 +--
netbox/utilities/views.py | 5 ++-
netbox/virtualization/views.py | 12 ++----
7 files changed, 46 insertions(+), 86 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 7016a5b9d..7ee6a7dc1 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -71,8 +71,7 @@ class ProviderDeleteView(ObjectDeleteView):
default_return_url = 'circuits:provider_list'
-class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_provider'
+class ProviderBulkImportView(BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
@@ -111,8 +110,7 @@ class CircuitTypeEditView(ObjectEditView):
default_return_url = 'circuits:circuittype_list'
-class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_circuittype'
+class CircuitTypeBulkImportView(BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
@@ -176,8 +174,7 @@ class CircuitDeleteView(ObjectDeleteView):
default_return_url = 'circuits:circuit_list'
-class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'circuits.add_circuit'
+class CircuitBulkImportView(BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d61d0f82f..d1882359d 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -160,8 +160,7 @@ class RegionEditView(ObjectEditView):
default_return_url = 'dcim:region_list'
-class RegionBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_region'
+class RegionBulkImportView(BulkImportView):
queryset = Region.objects.all()
model_form = forms.RegionCSVForm
table = tables.RegionTable
@@ -225,8 +224,7 @@ class SiteDeleteView(ObjectDeleteView):
default_return_url = 'dcim:site_list'
-class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_site'
+class SiteBulkImportView(BulkImportView):
queryset = Site.objects.all()
model_form = forms.SiteCSVForm
table = tables.SiteTable
@@ -273,8 +271,7 @@ class RackGroupEditView(ObjectEditView):
default_return_url = 'dcim:rackgroup_list'
-class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackgroup'
+class RackGroupBulkImportView(BulkImportView):
queryset = RackGroup.objects.all()
model_form = forms.RackGroupCSVForm
table = tables.RackGroupTable
@@ -304,8 +301,7 @@ class RackRoleEditView(ObjectEditView):
default_return_url = 'dcim:rackrole_list'
-class RackRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackrole'
+class RackRoleBulkImportView(BulkImportView):
queryset = RackRole.objects.all()
model_form = forms.RackRoleCSVForm
table = tables.RackRoleTable
@@ -415,8 +411,7 @@ class RackDeleteView(ObjectDeleteView):
default_return_url = 'dcim:rack_list'
-class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rack'
+class RackBulkImportView(BulkImportView):
queryset = Rack.objects.all()
model_form = forms.RackCSVForm
table = tables.RackTable
@@ -483,8 +478,7 @@ class RackReservationDeleteView(ObjectDeleteView):
default_return_url = 'dcim:rackreservation_list'
-class RackReservationImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rackreservation'
+class RackReservationImportView(BulkImportView):
queryset = RackReservation.objects.all()
model_form = forms.RackReservationCSVForm
table = tables.RackReservationTable
@@ -537,8 +531,7 @@ class ManufacturerEditView(ObjectEditView):
default_return_url = 'dcim:manufacturer_list'
-class ManufacturerBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_manufacturer'
+class ManufacturerBulkImportView(BulkImportView):
queryset = Manufacturer.objects.all()
model_form = forms.ManufacturerCSVForm
table = tables.ManufacturerTable
@@ -969,8 +962,7 @@ class DeviceRoleEditView(ObjectEditView):
default_return_url = 'dcim:devicerole_list'
-class DeviceRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_devicerole'
+class DeviceRoleBulkImportView(BulkImportView):
queryset = DeviceRole.objects.all()
model_form = forms.DeviceRoleCSVForm
table = tables.DeviceRoleTable
@@ -999,8 +991,7 @@ class PlatformEditView(ObjectEditView):
default_return_url = 'dcim:platform_list'
-class PlatformBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_platform'
+class PlatformBulkImportView(BulkImportView):
queryset = Platform.objects.all()
model_form = forms.PlatformCSVForm
table = tables.PlatformTable
@@ -1186,8 +1177,7 @@ class DeviceDeleteView(ObjectDeleteView):
default_return_url = 'dcim:device_list'
-class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_device'
+class DeviceBulkImportView(BulkImportView):
queryset = Device.objects.all()
model_form = forms.DeviceCSVForm
table = tables.DeviceImportTable
@@ -1195,8 +1185,7 @@ class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
default_return_url = 'dcim:device_list'
-class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_device'
+class ChildDeviceBulkImportView(BulkImportView):
queryset = Device.objects.all()
model_form = forms.ChildDeviceCSVForm
table = tables.DeviceImportTable
@@ -1261,8 +1250,7 @@ class ConsolePortDeleteView(ObjectDeleteView):
queryset = ConsolePort.objects.all()
-class ConsolePortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_consoleport'
+class ConsolePortBulkImportView(BulkImportView):
queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortCSVForm
table = tables.ConsolePortImportTable
@@ -1314,8 +1302,7 @@ class ConsoleServerPortDeleteView(ObjectDeleteView):
queryset = ConsoleServerPort.objects.all()
-class ConsoleServerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_consoleserverport'
+class ConsoleServerPortBulkImportView(BulkImportView):
queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortCSVForm
table = tables.ConsoleServerPortImportTable
@@ -1379,8 +1366,7 @@ class PowerPortDeleteView(ObjectDeleteView):
queryset = PowerPort.objects.all()
-class PowerPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_powerport'
+class PowerPortBulkImportView(BulkImportView):
queryset = PowerPort.objects.all()
model_form = forms.PowerPortCSVForm
table = tables.PowerPortImportTable
@@ -1432,8 +1418,7 @@ class PowerOutletDeleteView(ObjectDeleteView):
queryset = PowerOutlet.objects.all()
-class PowerOutletBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_poweroutlet'
+class PowerOutletBulkImportView(BulkImportView):
queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletCSVForm
table = tables.PowerOutletImportTable
@@ -1534,8 +1519,7 @@ class InterfaceDeleteView(ObjectDeleteView):
queryset = Interface.objects.all()
-class InterfaceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_interface'
+class InterfaceBulkImportView(BulkImportView):
queryset = Interface.objects.all()
model_form = forms.InterfaceCSVForm
table = tables.InterfaceImportTable
@@ -1599,8 +1583,7 @@ class FrontPortDeleteView(ObjectDeleteView):
queryset = FrontPort.objects.all()
-class FrontPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_frontport'
+class FrontPortBulkImportView(BulkImportView):
queryset = FrontPort.objects.all()
model_form = forms.FrontPortCSVForm
table = tables.FrontPortImportTable
@@ -1664,8 +1647,7 @@ class RearPortDeleteView(ObjectDeleteView):
queryset = RearPort.objects.all()
-class RearPortBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_rearport'
+class RearPortBulkImportView(BulkImportView):
queryset = RearPort.objects.all()
model_form = forms.RearPortCSVForm
table = tables.RearPortImportTable
@@ -1800,8 +1782,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
})
-class DeviceBayBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_devicebay'
+class DeviceBayBulkImportView(BulkImportView):
queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayCSVForm
table = tables.DeviceBayImportTable
@@ -2072,8 +2053,7 @@ class CableDeleteView(ObjectDeleteView):
default_return_url = 'dcim:cable_list'
-class CableBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_cable'
+class CableBulkImportView(BulkImportView):
queryset = Cable.objects.all()
model_form = forms.CableCSVForm
table = tables.CableTable
@@ -2229,8 +2209,7 @@ class InventoryItemDeleteView(ObjectDeleteView):
queryset = InventoryItem.objects.all()
-class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_inventoryitem'
+class InventoryItemBulkImportView(BulkImportView):
queryset = InventoryItem.objects.all()
model_form = forms.InventoryItemCSVForm
table = tables.InventoryItemTable
@@ -2564,8 +2543,7 @@ class PowerPanelDeleteView(ObjectDeleteView):
default_return_url = 'dcim:powerpanel_list'
-class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_powerpanel'
+class PowerPanelBulkImportView(BulkImportView):
queryset = PowerPanel.objects.all()
model_form = forms.PowerPanelCSVForm
table = tables.PowerPanelTable
@@ -2630,8 +2608,7 @@ class PowerFeedDeleteView(ObjectDeleteView):
default_return_url = 'dcim:powerfeed_list'
-class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'dcim.add_powerfeed'
+class PowerFeedBulkImportView(BulkImportView):
queryset = PowerFeed.objects.all()
model_form = forms.PowerFeedCSVForm
table = tables.PowerFeedTable
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 176321982..dbd45b923 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -146,8 +146,7 @@ class VRFDeleteView(ObjectDeleteView):
default_return_url = 'ipam:vrf_list'
-class VRFBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_vrf'
+class VRFBulkImportView(BulkImportView):
queryset = VRF.objects.all()
model_form = forms.VRFCSVForm
table = tables.VRFTable
@@ -257,8 +256,7 @@ class RIREditView(ObjectEditView):
default_return_url = 'ipam:rir_list'
-class RIRBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_rir'
+class RIRBulkImportView(BulkImportView):
queryset = RIR.objects.all()
model_form = forms.RIRCSVForm
table = tables.RIRTable
@@ -360,8 +358,7 @@ class AggregateDeleteView(ObjectDeleteView):
default_return_url = 'ipam:aggregate_list'
-class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_aggregate'
+class AggregateBulkImportView(BulkImportView):
queryset = Aggregate.objects.all()
model_form = forms.AggregateCSVForm
table = tables.AggregateTable
@@ -400,8 +397,7 @@ class RoleEditView(ObjectEditView):
default_return_url = 'ipam:role_list'
-class RoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_role'
+class RoleBulkImportView(BulkImportView):
queryset = Role.objects.all()
model_form = forms.RoleCSVForm
table = tables.RoleTable
@@ -576,8 +572,7 @@ class PrefixDeleteView(ObjectDeleteView):
default_return_url = 'ipam:prefix_list'
-class PrefixBulkImportView(ObjectPermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_prefix'
+class PrefixBulkImportView(BulkImportView):
queryset = Prefix.objects.all()
model_form = forms.PrefixCSVForm
table = tables.PrefixTable
@@ -744,8 +739,7 @@ class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
default_return_url = 'ipam:ipaddress_list'
-class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_ipaddress'
+class IPAddressBulkImportView(BulkImportView):
queryset = IPAddress.objects.all()
model_form = forms.IPAddressCSVForm
table = tables.IPAddressTable
@@ -787,8 +781,7 @@ class VLANGroupEditView(ObjectEditView):
default_return_url = 'ipam:vlangroup_list'
-class VLANGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_vlangroup'
+class VLANGroupBulkImportView(BulkImportView):
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupCSVForm
table = tables.VLANGroupTable
@@ -904,8 +897,7 @@ class VLANDeleteView(ObjectDeleteView):
default_return_url = 'ipam:vlan_list'
-class VLANBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_vlan'
+class VLANBulkImportView(BulkImportView):
queryset = VLAN.objects.all()
model_form = forms.VLANCSVForm
table = tables.VLANTable
@@ -969,8 +961,7 @@ class ServiceEditView(ObjectEditView):
return service.parent.get_absolute_url()
-class ServiceBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'ipam.add_service'
+class ServiceBulkImportView(BulkImportView):
queryset = Service.objects.all()
model_form = forms.ServiceCSVForm
table = tables.ServiceTable
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 7c69d0ac4..00794f684 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -41,8 +41,7 @@ class SecretRoleEditView(ObjectEditView):
default_return_url = 'secrets:secretrole_list'
-class SecretRoleBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'secrets.add_secretrole'
+class SecretRoleBulkImportView(BulkImportView):
queryset = SecretRole.objects.all()
model_form = forms.SecretRoleCSVForm
table = tables.SecretRoleTable
@@ -189,7 +188,6 @@ class SecretDeleteView(ObjectDeleteView):
class SecretBulkImportView(BulkImportView):
- permission_required = 'secrets.add_secret'
queryset = Secret.objects.all()
model_form = forms.SecretCSVForm
table = tables.SecretTable
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 97480bb6a..f666e606a 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -35,8 +35,7 @@ class TenantGroupEditView(ObjectEditView):
default_return_url = 'tenancy:tenantgroup_list'
-class TenantGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'tenancy.add_tenantgroup'
+class TenantGroupBulkImportView(BulkImportView):
queryset = TenantGroup.objects.all()
model_form = forms.TenantGroupCSVForm
table = tables.TenantGroupTable
@@ -99,8 +98,7 @@ class TenantDeleteView(ObjectDeleteView):
default_return_url = 'tenancy:tenant_list'
-class TenantBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'tenancy.add_tenant'
+class TenantBulkImportView(BulkImportView):
queryset = Tenant.objects.all()
model_form = forms.TenantCSVForm
table = tables.TenantTable
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index f4267748f..3d11cf25b 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -652,7 +652,7 @@ class ObjectImportView(GetReturnURLMixin, View):
})
-class BulkImportView(GetReturnURLMixin, View):
+class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Import objects in bulk (CSV format).
@@ -684,6 +684,9 @@ class BulkImportView(GetReturnURLMixin, View):
"""
return obj_form.save()
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'add')
+
def get(self, request):
return render(request, self.template_name, {
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 8bc3876ca..de4569b83 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -33,8 +33,7 @@ class ClusterTypeEditView(ObjectEditView):
default_return_url = 'virtualization:clustertype_list'
-class ClusterTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'virtualization.add_clustertype'
+class ClusterTypeBulkImportView(BulkImportView):
queryset = ClusterType.objects.all()
model_form = forms.ClusterTypeCSVForm
table = tables.ClusterTypeTable
@@ -63,8 +62,7 @@ class ClusterGroupEditView(ObjectEditView):
default_return_url = 'virtualization:clustergroup_list'
-class ClusterGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'virtualization.add_clustergroup'
+class ClusterGroupBulkImportView(BulkImportView):
queryset = ClusterGroup.objects.all()
model_form = forms.ClusterGroupCSVForm
table = tables.ClusterGroupTable
@@ -119,8 +117,7 @@ class ClusterDeleteView(ObjectDeleteView):
default_return_url = 'virtualization:cluster_list'
-class ClusterBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'virtualization.add_cluster'
+class ClusterBulkImportView(BulkImportView):
queryset = Cluster.objects.all()
model_form = forms.ClusterCSVForm
table = tables.ClusterTable
@@ -274,8 +271,7 @@ class VirtualMachineDeleteView(ObjectDeleteView):
default_return_url = 'virtualization:virtualmachine_list'
-class VirtualMachineBulkImportView(PermissionRequiredMixin, BulkImportView):
- permission_required = 'virtualization.add_virtualmachine'
+class VirtualMachineBulkImportView(BulkImportView):
queryset = VirtualMachine.objects.all()
model_form = forms.VirtualMachineCSVForm
table = tables.VirtualMachineTable
From 82c247f3cf7660a24791f3e9e6505b0b18ab99b2 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 15:07:20 -0400
Subject: [PATCH 057/300] Transition BulkEditView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/views.py | 6 +-
netbox/dcim/views.py | 77 ++++++++--------------
netbox/extras/views.py | 6 +-
netbox/ipam/views.py | 18 ++---
netbox/netbox/tests/test_authentication.py | 2 +-
netbox/secrets/views.py | 3 +-
netbox/tenancy/views.py | 3 +-
netbox/utilities/views.py | 9 ++-
netbox/virtualization/views.py | 9 +--
9 files changed, 48 insertions(+), 85 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 7ee6a7dc1..3dc7032e4 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -78,8 +78,7 @@ class ProviderBulkImportView(BulkImportView):
default_return_url = 'circuits:provider_list'
-class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'circuits.change_provider'
+class ProviderBulkEditView(BulkEditView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@@ -181,8 +180,7 @@ class CircuitBulkImportView(BulkImportView):
default_return_url = 'circuits:circuit_list'
-class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'circuits.change_circuit'
+class CircuitBulkEditView(BulkEditView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d1882359d..8e2355a9c 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -231,8 +231,7 @@ class SiteBulkImportView(BulkImportView):
default_return_url = 'dcim:site_list'
-class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_site'
+class SiteBulkEditView(BulkEditView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
table = tables.SiteTable
@@ -418,8 +417,7 @@ class RackBulkImportView(BulkImportView):
default_return_url = 'dcim:rack_list'
-class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rack'
+class RackBulkEditView(BulkEditView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
@@ -495,8 +493,7 @@ class RackReservationImportView(BulkImportView):
return instance
-class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rackreservation'
+class RackReservationBulkEditView(BulkEditView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filterset = filters.RackReservationFilterSet
table = tables.RackReservationTable
@@ -658,8 +655,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
default_return_url = 'dcim:devicetype_import'
-class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_devicetype'
+class DeviceTypeBulkEditView(BulkEditView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
@@ -696,8 +692,7 @@ class ConsolePortTemplateDeleteView(ObjectDeleteView):
queryset = ConsolePortTemplate.objects.all()
-class ConsolePortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleporttemplate'
+class ConsolePortTemplateBulkEditView(BulkEditView):
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
form = forms.ConsolePortTemplateBulkEditForm
@@ -730,8 +725,7 @@ class ConsoleServerPortTemplateDeleteView(ObjectDeleteView):
queryset = ConsoleServerPortTemplate.objects.all()
-class ConsoleServerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleserverporttemplate'
+class ConsoleServerPortTemplateBulkEditView(BulkEditView):
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
form = forms.ConsoleServerPortTemplateBulkEditForm
@@ -764,8 +758,7 @@ class PowerPortTemplateDeleteView(ObjectDeleteView):
queryset = PowerPortTemplate.objects.all()
-class PowerPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerporttemplate'
+class PowerPortTemplateBulkEditView(BulkEditView):
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
form = forms.PowerPortTemplateBulkEditForm
@@ -798,8 +791,7 @@ class PowerOutletTemplateDeleteView(ObjectDeleteView):
queryset = PowerOutletTemplate.objects.all()
-class PowerOutletTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_poweroutlettemplate'
+class PowerOutletTemplateBulkEditView(BulkEditView):
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
form = forms.PowerOutletTemplateBulkEditForm
@@ -832,8 +824,7 @@ class InterfaceTemplateDeleteView(ObjectDeleteView):
queryset = InterfaceTemplate.objects.all()
-class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_interfacetemplate'
+class InterfaceTemplateBulkEditView(BulkEditView):
queryset = InterfaceTemplate.objects.all()
table = tables.InterfaceTemplateTable
form = forms.InterfaceTemplateBulkEditForm
@@ -866,8 +857,7 @@ class FrontPortTemplateDeleteView(ObjectDeleteView):
queryset = FrontPortTemplate.objects.all()
-class FrontPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_frontporttemplate'
+class FrontPortTemplateBulkEditView(BulkEditView):
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
form = forms.FrontPortTemplateBulkEditForm
@@ -900,8 +890,7 @@ class RearPortTemplateDeleteView(ObjectDeleteView):
queryset = RearPortTemplate.objects.all()
-class RearPortTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rearporttemplate'
+class RearPortTemplateBulkEditView(BulkEditView):
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
form = forms.RearPortTemplateBulkEditForm
@@ -934,7 +923,7 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView):
queryset = DeviceBayTemplate.objects.all()
-# class DeviceBayTemplateBulkEditView(PermissionRequiredMixin, BulkEditView):
+# class DeviceBayTemplateBulkEditView(BulkEditView):
# permission_required = 'dcim.change_devicebaytemplate'
# queryset = DeviceBayTemplate.objects.all()
# table = tables.DeviceBayTemplateTable
@@ -1204,8 +1193,7 @@ class ChildDeviceBulkImportView(BulkImportView):
return obj
-class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_device'
+class DeviceBulkEditView(BulkEditView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
@@ -1257,8 +1245,7 @@ class ConsolePortBulkImportView(BulkImportView):
default_return_url = 'dcim:consoleport_list'
-class ConsolePortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleport'
+class ConsolePortBulkEditView(BulkEditView):
queryset = ConsolePort.objects.all()
filterset = filters.ConsolePortFilterSet
table = tables.ConsolePortTable
@@ -1309,8 +1296,7 @@ class ConsoleServerPortBulkImportView(BulkImportView):
default_return_url = 'dcim:consoleserverport_list'
-class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_consoleserverport'
+class ConsoleServerPortBulkEditView(BulkEditView):
queryset = ConsoleServerPort.objects.all()
filterset = filters.ConsoleServerPortFilterSet
table = tables.ConsoleServerPortTable
@@ -1373,8 +1359,7 @@ class PowerPortBulkImportView(BulkImportView):
default_return_url = 'dcim:powerport_list'
-class PowerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerport'
+class PowerPortBulkEditView(BulkEditView):
queryset = PowerPort.objects.all()
filterset = filters.PowerPortFilterSet
table = tables.PowerPortTable
@@ -1425,8 +1410,7 @@ class PowerOutletBulkImportView(BulkImportView):
default_return_url = 'dcim:poweroutlet_list'
-class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_poweroutlet'
+class PowerOutletBulkEditView(BulkEditView):
queryset = PowerOutlet.objects.all()
filterset = filters.PowerOutletFilterSet
table = tables.PowerOutletTable
@@ -1526,8 +1510,7 @@ class InterfaceBulkImportView(BulkImportView):
default_return_url = 'dcim:interface_list'
-class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_interface'
+class InterfaceBulkEditView(BulkEditView):
queryset = Interface.objects.all()
filterset = filters.InterfaceFilterSet
table = tables.InterfaceTable
@@ -1590,8 +1573,7 @@ class FrontPortBulkImportView(BulkImportView):
default_return_url = 'dcim:frontport_list'
-class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_frontport'
+class FrontPortBulkEditView(BulkEditView):
queryset = FrontPort.objects.all()
filterset = filters.FrontPortFilterSet
table = tables.FrontPortTable
@@ -1654,8 +1636,7 @@ class RearPortBulkImportView(BulkImportView):
default_return_url = 'dcim:rearport_list'
-class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_rearport'
+class RearPortBulkEditView(BulkEditView):
queryset = RearPort.objects.all()
filterset = filters.RearPortFilterSet
table = tables.RearPortTable
@@ -1789,8 +1770,7 @@ class DeviceBayBulkImportView(BulkImportView):
default_return_url = 'dcim:devicebay_list'
-class DeviceBayBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayBulkEditView(BulkEditView):
queryset = DeviceBay.objects.all()
filterset = filters.DeviceBayFilterSet
table = tables.DeviceBayTable
@@ -2060,8 +2040,7 @@ class CableBulkImportView(BulkImportView):
default_return_url = 'dcim:cable_list'
-class CableBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_cable'
+class CableBulkEditView(BulkEditView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
filterset = filters.CableFilterSet
table = tables.CableTable
@@ -2216,8 +2195,7 @@ class InventoryItemBulkImportView(BulkImportView):
default_return_url = 'dcim:inventoryitem_list'
-class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_inventoryitem'
+class InventoryItemBulkEditView(BulkEditView):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filterset = filters.InventoryItemFilterSet
table = tables.InventoryItemTable
@@ -2482,8 +2460,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
})
-class VirtualChassisBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisBulkEditView(BulkEditView):
queryset = VirtualChassis.objects.all()
filterset = filters.VirtualChassisFilterSet
table = tables.VirtualChassisTable
@@ -2550,8 +2527,7 @@ class PowerPanelBulkImportView(BulkImportView):
default_return_url = 'dcim:powerpanel_list'
-class PowerPanelBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerpanel'
+class PowerPanelBulkEditView(BulkEditView):
queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
filterset = filters.PowerPanelFilterSet
table = tables.PowerPanelTable
@@ -2615,8 +2591,7 @@ class PowerFeedBulkImportView(BulkImportView):
default_return_url = 'dcim:powerfeed_list'
-class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_powerfeed'
+class PowerFeedBulkEditView(BulkEditView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filterset = filters.PowerFeedFilterSet
table = tables.PowerFeedTable
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 63764b683..3aadbda98 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -76,8 +76,7 @@ class TagDeleteView(ObjectDeleteView):
default_return_url = 'extras:tag_list'
-class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'extras.change_tag'
+class TagBulkEditView(BulkEditView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
@@ -137,8 +136,7 @@ class ConfigContextEditView(ObjectEditView):
template_name = 'extras/configcontext_edit.html'
-class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'extras.change_configcontext'
+class ConfigContextBulkEditView(BulkEditView):
queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet
table = ConfigContextTable
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index dbd45b923..ba4b310ef 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -153,8 +153,7 @@ class VRFBulkImportView(BulkImportView):
default_return_url = 'ipam:vrf_list'
-class VRFBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_vrf'
+class VRFBulkEditView(BulkEditView):
queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet
table = tables.VRFTable
@@ -365,8 +364,7 @@ class AggregateBulkImportView(BulkImportView):
default_return_url = 'ipam:aggregate_list'
-class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_aggregate'
+class AggregateBulkEditView(BulkEditView):
queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet
table = tables.AggregateTable
@@ -579,8 +577,7 @@ class PrefixBulkImportView(BulkImportView):
default_return_url = 'ipam:prefix_list'
-class PrefixBulkEditView(ObjectPermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_prefix'
+class PrefixBulkEditView(BulkEditView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
table = tables.PrefixTable
@@ -746,8 +743,7 @@ class IPAddressBulkImportView(BulkImportView):
default_return_url = 'ipam:ipaddress_list'
-class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_ipaddress'
+class IPAddressBulkEditView(BulkEditView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
@@ -904,8 +900,7 @@ class VLANBulkImportView(BulkImportView):
default_return_url = 'ipam:vlan_list'
-class VLANBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_vlan'
+class VLANBulkEditView(BulkEditView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet
table = tables.VLANTable
@@ -972,8 +967,7 @@ class ServiceDeleteView(ObjectDeleteView):
queryset = Service.objects.all()
-class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'ipam.change_service'
+class ServiceBulkEditView(BulkEditView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet
table = tables.ServiceTable
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index d82ef6752..39e82df61 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -463,7 +463,7 @@ class ObjectPermissionViewTestCase(TestCase):
'data': form_data,
}
response = self.client.post(**request)
- self.assertHttpStatus(response, 200)
+ self.assertHttpStatus(response, 302)
self.assertEqual(Prefix.objects.get(pk=self.prefixes[3].pk).status, 'active')
# Edit permitted objects
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 00794f684..877133619 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -235,8 +235,7 @@ class SecretBulkImportView(BulkImportView):
})
-class SecretBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'secrets.change_secret'
+class SecretBulkEditView(BulkEditView):
queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet
table = tables.SecretTable
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index f666e606a..fdfcbd7f5 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -105,8 +105,7 @@ class TenantBulkImportView(BulkImportView):
default_return_url = 'tenancy:tenant_list'
-class TenantBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'tenancy.change_tenant'
+class TenantBulkEditView(BulkEditView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet
table = tables.TenantTable
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 3d11cf25b..1c8ceb525 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -755,7 +755,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class BulkEditView(GetReturnURLMixin, View):
+class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Edit objects in bulk.
@@ -771,6 +771,9 @@ class BulkEditView(GetReturnURLMixin, View):
form = None
template_name = 'utilities/obj_bulk_edit.html'
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'change')
+
def get(self, request):
return redirect(self.get_return_url(request))
@@ -781,7 +784,7 @@ class BulkEditView(GetReturnURLMixin, View):
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
if request.POST.get('_all') and self.filterset is not None:
pk_list = [
- obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs
+ obj.pk for obj in self.filterset(request.GET, self.queryset.only('pk')).qs
]
else:
pk_list = request.POST.getlist('pk')
@@ -802,7 +805,7 @@ class BulkEditView(GetReturnURLMixin, View):
with transaction.atomic():
updated_objects = []
- for obj in model.objects.filter(pk__in=form.cleaned_data['pk']):
+ for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
# Update standard fields. If a field is listed in _nullify, delete its value.
for name in standard_fields:
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index de4569b83..e565832d8 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -124,8 +124,7 @@ class ClusterBulkImportView(BulkImportView):
default_return_url = 'virtualization:cluster_list'
-class ClusterBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'virtualization.change_cluster'
+class ClusterBulkEditView(BulkEditView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet
table = tables.ClusterTable
@@ -278,8 +277,7 @@ class VirtualMachineBulkImportView(BulkImportView):
default_return_url = 'virtualization:virtualmachine_list'
-class VirtualMachineBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'virtualization.change_virtualmachine'
+class VirtualMachineBulkEditView(BulkEditView):
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
@@ -317,8 +315,7 @@ class InterfaceDeleteView(ObjectDeleteView):
queryset = Interface.objects.all()
-class InterfaceBulkEditView(PermissionRequiredMixin, BulkEditView):
- permission_required = 'dcim.change_interface'
+class InterfaceBulkEditView(BulkEditView):
queryset = Interface.objects.all()
table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm
From 8fd860a413361b0c1a739e72237d57046f0f2dcb Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 15:14:29 -0400
Subject: [PATCH 058/300] Transition BulkDeleteView to use
ObjectPermissionRequiredMixin
---
netbox/circuits/views.py | 9 ++--
netbox/dcim/views.py | 96 ++++++++++++----------------------
netbox/extras/views.py | 6 +--
netbox/ipam/views.py | 27 ++++------
netbox/secrets/views.py | 6 +--
netbox/tenancy/views.py | 6 +--
netbox/utilities/views.py | 5 +-
netbox/virtualization/views.py | 15 ++----
8 files changed, 59 insertions(+), 111 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 3dc7032e4..e3260431f 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -86,8 +86,7 @@ class ProviderBulkEditView(BulkEditView):
default_return_url = 'circuits:provider_list'
-class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_provider'
+class ProviderBulkDeleteView(BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
@@ -116,8 +115,7 @@ class CircuitTypeBulkImportView(BulkImportView):
default_return_url = 'circuits:circuittype_list'
-class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_circuittype'
+class CircuitTypeBulkDeleteView(BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
@@ -188,8 +186,7 @@ class CircuitBulkEditView(BulkEditView):
default_return_url = 'circuits:circuit_list'
-class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'circuits.delete_circuit'
+class CircuitBulkDeleteView(BulkDeleteView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 8e2355a9c..5559d577c 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -167,8 +167,7 @@ class RegionBulkImportView(BulkImportView):
default_return_url = 'dcim:region_list'
-class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_region'
+class RegionBulkDeleteView(BulkDeleteView):
queryset = Region.objects.all()
filterset = filters.RegionFilterSet
table = tables.RegionTable
@@ -239,8 +238,7 @@ class SiteBulkEditView(BulkEditView):
default_return_url = 'dcim:site_list'
-class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_site'
+class SiteBulkDeleteView(BulkDeleteView):
queryset = Site.objects.prefetch_related('region', 'tenant')
filterset = filters.SiteFilterSet
table = tables.SiteTable
@@ -277,8 +275,7 @@ class RackGroupBulkImportView(BulkImportView):
default_return_url = 'dcim:rackgroup_list'
-class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackgroup'
+class RackGroupBulkDeleteView(BulkDeleteView):
queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
filterset = filters.RackGroupFilterSet
table = tables.RackGroupTable
@@ -307,8 +304,7 @@ class RackRoleBulkImportView(BulkImportView):
default_return_url = 'dcim:rackrole_list'
-class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackrole'
+class RackRoleBulkDeleteView(BulkDeleteView):
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
default_return_url = 'dcim:rackrole_list'
@@ -425,8 +421,7 @@ class RackBulkEditView(BulkEditView):
default_return_url = 'dcim:rack_list'
-class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rack'
+class RackBulkDeleteView(BulkDeleteView):
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.RackFilterSet
table = tables.RackTable
@@ -501,8 +496,7 @@ class RackReservationBulkEditView(BulkEditView):
default_return_url = 'dcim:rackreservation_list'
-class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rackreservation'
+class RackReservationBulkDeleteView(BulkDeleteView):
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filterset = filters.RackReservationFilterSet
table = tables.RackReservationTable
@@ -535,8 +529,7 @@ class ManufacturerBulkImportView(BulkImportView):
default_return_url = 'dcim:manufacturer_list'
-class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_manufacturer'
+class ManufacturerBulkDeleteView(BulkDeleteView):
queryset = Manufacturer.objects.annotate(devicetype_count=Count('device_types'))
table = tables.ManufacturerTable
default_return_url = 'dcim:manufacturer_list'
@@ -663,8 +656,7 @@ class DeviceTypeBulkEditView(BulkEditView):
default_return_url = 'dcim:devicetype_list'
-class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicetype'
+class DeviceTypeBulkDeleteView(BulkDeleteView):
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filterset = filters.DeviceTypeFilterSet
table = tables.DeviceTypeTable
@@ -698,8 +690,7 @@ class ConsolePortTemplateBulkEditView(BulkEditView):
form = forms.ConsolePortTemplateBulkEditForm
-class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleporttemplate'
+class ConsolePortTemplateBulkDeleteView(BulkDeleteView):
queryset = ConsolePortTemplate.objects.all()
table = tables.ConsolePortTemplateTable
@@ -731,8 +722,7 @@ class ConsoleServerPortTemplateBulkEditView(BulkEditView):
form = forms.ConsoleServerPortTemplateBulkEditForm
-class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleserverporttemplate'
+class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView):
queryset = ConsoleServerPortTemplate.objects.all()
table = tables.ConsoleServerPortTemplateTable
@@ -764,8 +754,7 @@ class PowerPortTemplateBulkEditView(BulkEditView):
form = forms.PowerPortTemplateBulkEditForm
-class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerporttemplate'
+class PowerPortTemplateBulkDeleteView(BulkDeleteView):
queryset = PowerPortTemplate.objects.all()
table = tables.PowerPortTemplateTable
@@ -797,8 +786,7 @@ class PowerOutletTemplateBulkEditView(BulkEditView):
form = forms.PowerOutletTemplateBulkEditForm
-class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_poweroutlettemplate'
+class PowerOutletTemplateBulkDeleteView(BulkDeleteView):
queryset = PowerOutletTemplate.objects.all()
table = tables.PowerOutletTemplateTable
@@ -830,8 +818,7 @@ class InterfaceTemplateBulkEditView(BulkEditView):
form = forms.InterfaceTemplateBulkEditForm
-class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_interfacetemplate'
+class InterfaceTemplateBulkDeleteView(BulkDeleteView):
queryset = InterfaceTemplate.objects.all()
table = tables.InterfaceTemplateTable
@@ -863,8 +850,7 @@ class FrontPortTemplateBulkEditView(BulkEditView):
form = forms.FrontPortTemplateBulkEditForm
-class FrontPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_frontporttemplate'
+class FrontPortTemplateBulkDeleteView(BulkDeleteView):
queryset = FrontPortTemplate.objects.all()
table = tables.FrontPortTemplateTable
@@ -896,8 +882,7 @@ class RearPortTemplateBulkEditView(BulkEditView):
form = forms.RearPortTemplateBulkEditForm
-class RearPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rearporttemplate'
+class RearPortTemplateBulkDeleteView(BulkDeleteView):
queryset = RearPortTemplate.objects.all()
table = tables.RearPortTemplateTable
@@ -930,8 +915,7 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView):
# form = forms.DeviceBayTemplateBulkEditForm
-class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicebaytemplate'
+class DeviceBayTemplateBulkDeleteView(BulkDeleteView):
queryset = DeviceBayTemplate.objects.all()
table = tables.DeviceBayTemplateTable
@@ -958,8 +942,7 @@ class DeviceRoleBulkImportView(BulkImportView):
default_return_url = 'dcim:devicerole_list'
-class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicerole'
+class DeviceRoleBulkDeleteView(BulkDeleteView):
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
default_return_url = 'dcim:devicerole_list'
@@ -987,8 +970,7 @@ class PlatformBulkImportView(BulkImportView):
default_return_url = 'dcim:platform_list'
-class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_platform'
+class PlatformBulkDeleteView(BulkDeleteView):
queryset = Platform.objects.all()
table = tables.PlatformTable
default_return_url = 'dcim:platform_list'
@@ -1201,8 +1183,7 @@ class DeviceBulkEditView(BulkEditView):
default_return_url = 'dcim:device_list'
-class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_device'
+class DeviceBulkDeleteView(BulkDeleteView):
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
@@ -1252,8 +1233,7 @@ class ConsolePortBulkEditView(BulkEditView):
form = forms.ConsolePortBulkEditForm
-class ConsolePortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleport'
+class ConsolePortBulkDeleteView(BulkDeleteView):
queryset = ConsolePort.objects.all()
filterset = filters.ConsolePortFilterSet
table = tables.ConsolePortTable
@@ -1315,8 +1295,7 @@ class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnec
form = forms.ConsoleServerPortBulkDisconnectForm
-class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_consoleserverport'
+class ConsoleServerPortBulkDeleteView(BulkDeleteView):
queryset = ConsoleServerPort.objects.all()
filterset = filters.ConsoleServerPortFilterSet
table = tables.ConsoleServerPortTable
@@ -1366,8 +1345,7 @@ class PowerPortBulkEditView(BulkEditView):
form = forms.PowerPortBulkEditForm
-class PowerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerport'
+class PowerPortBulkDeleteView(BulkDeleteView):
queryset = PowerPort.objects.all()
filterset = filters.PowerPortFilterSet
table = tables.PowerPortTable
@@ -1429,8 +1407,7 @@ class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView)
form = forms.PowerOutletBulkDisconnectForm
-class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_poweroutlet'
+class PowerOutletBulkDeleteView(BulkDeleteView):
queryset = PowerOutlet.objects.all()
filterset = filters.PowerOutletFilterSet
table = tables.PowerOutletTable
@@ -1529,8 +1506,7 @@ class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
form = forms.InterfaceBulkDisconnectForm
-class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_interface'
+class InterfaceBulkDeleteView(BulkDeleteView):
queryset = Interface.objects.all()
filterset = filters.InterfaceFilterSet
table = tables.InterfaceTable
@@ -1592,8 +1568,7 @@ class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
form = forms.FrontPortBulkDisconnectForm
-class FrontPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_frontport'
+class FrontPortBulkDeleteView(BulkDeleteView):
queryset = FrontPort.objects.all()
filterset = filters.FrontPortFilterSet
table = tables.FrontPortTable
@@ -1655,8 +1630,7 @@ class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
form = forms.RearPortBulkDisconnectForm
-class RearPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_rearport'
+class RearPortBulkDeleteView(BulkDeleteView):
queryset = RearPort.objects.all()
filterset = filters.RearPortFilterSet
table = tables.RearPortTable
@@ -1783,8 +1757,7 @@ class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
form = forms.DeviceBayBulkRenameForm
-class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_devicebay'
+class DeviceBayBulkDeleteView(BulkDeleteView):
queryset = DeviceBay.objects.all()
filterset = filters.DeviceBayFilterSet
table = tables.DeviceBayTable
@@ -2048,8 +2021,7 @@ class CableBulkEditView(BulkEditView):
default_return_url = 'dcim:cable_list'
-class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_cable'
+class CableBulkDeleteView(BulkDeleteView):
queryset = Cable.objects.prefetch_related('termination_a', 'termination_b')
filterset = filters.CableFilterSet
table = tables.CableTable
@@ -2203,8 +2175,7 @@ class InventoryItemBulkEditView(BulkEditView):
default_return_url = 'dcim:inventoryitem_list'
-class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_inventoryitem'
+class InventoryItemBulkDeleteView(BulkDeleteView):
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
@@ -2468,8 +2439,7 @@ class VirtualChassisBulkEditView(BulkEditView):
default_return_url = 'dcim:virtualchassis_list'
-class VirtualChassisBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_virtualchassis'
+class VirtualChassisBulkDeleteView(BulkDeleteView):
queryset = VirtualChassis.objects.all()
filterset = filters.VirtualChassisFilterSet
table = tables.VirtualChassisTable
@@ -2535,8 +2505,7 @@ class PowerPanelBulkEditView(BulkEditView):
default_return_url = 'dcim:powerpanel_list'
-class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerpanel'
+class PowerPanelBulkDeleteView(BulkDeleteView):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
@@ -2599,8 +2568,7 @@ class PowerFeedBulkEditView(BulkEditView):
default_return_url = 'dcim:powerfeed_list'
-class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_powerfeed'
+class PowerFeedBulkDeleteView(BulkDeleteView):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filterset = filters.PowerFeedFilterSet
table = tables.PowerFeedTable
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 3aadbda98..0a3796a28 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -87,8 +87,7 @@ class TagBulkEditView(BulkEditView):
default_return_url = 'extras:tag_list'
-class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'extras.delete_tag'
+class TagBulkDeleteView(BulkDeleteView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(
@@ -149,8 +148,7 @@ class ConfigContextDeleteView(ObjectDeleteView):
default_return_url = 'extras:configcontext_list'
-class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'extras.delete_configcontext'
+class ConfigContextBulkDeleteView(BulkDeleteView):
queryset = ConfigContext.objects.all()
table = ConfigContextTable
default_return_url = 'extras:configcontext_list'
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index ba4b310ef..19d38be5d 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -161,8 +161,7 @@ class VRFBulkEditView(BulkEditView):
default_return_url = 'ipam:vrf_list'
-class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_vrf'
+class VRFBulkDeleteView(BulkDeleteView):
queryset = VRF.objects.prefetch_related('tenant')
filterset = filters.VRFFilterSet
table = tables.VRFTable
@@ -262,8 +261,7 @@ class RIRBulkImportView(BulkImportView):
default_return_url = 'ipam:rir_list'
-class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_rir'
+class RIRBulkDeleteView(BulkDeleteView):
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
filterset = filters.RIRFilterSet
table = tables.RIRTable
@@ -372,8 +370,7 @@ class AggregateBulkEditView(BulkEditView):
default_return_url = 'ipam:aggregate_list'
-class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_aggregate'
+class AggregateBulkDeleteView(BulkDeleteView):
queryset = Aggregate.objects.prefetch_related('rir')
filterset = filters.AggregateFilterSet
table = tables.AggregateTable
@@ -402,8 +399,7 @@ class RoleBulkImportView(BulkImportView):
default_return_url = 'ipam:role_list'
-class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_role'
+class RoleBulkDeleteView(BulkDeleteView):
queryset = Role.objects.all()
table = tables.RoleTable
default_return_url = 'ipam:role_list'
@@ -585,8 +581,7 @@ class PrefixBulkEditView(BulkEditView):
default_return_url = 'ipam:prefix_list'
-class PrefixBulkDeleteView(ObjectPermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_prefix'
+class PrefixBulkDeleteView(BulkDeleteView):
queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
filterset = filters.PrefixFilterSet
table = tables.PrefixTable
@@ -751,8 +746,7 @@ class IPAddressBulkEditView(BulkEditView):
default_return_url = 'ipam:ipaddress_list'
-class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_ipaddress'
+class IPAddressBulkDeleteView(BulkDeleteView):
queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant').prefetch_related('interface__device')
filterset = filters.IPAddressFilterSet
table = tables.IPAddressTable
@@ -784,8 +778,7 @@ class VLANGroupBulkImportView(BulkImportView):
default_return_url = 'ipam:vlangroup_list'
-class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_vlangroup'
+class VLANGroupBulkDeleteView(BulkDeleteView):
queryset = VLANGroup.objects.prefetch_related('site').annotate(vlan_count=Count('vlans'))
filterset = filters.VLANGroupFilterSet
table = tables.VLANGroupTable
@@ -908,8 +901,7 @@ class VLANBulkEditView(BulkEditView):
default_return_url = 'ipam:vlan_list'
-class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_vlan'
+class VLANBulkDeleteView(BulkDeleteView):
queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role')
filterset = filters.VLANFilterSet
table = tables.VLANTable
@@ -975,8 +967,7 @@ class ServiceBulkEditView(BulkEditView):
default_return_url = 'ipam:service_list'
-class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'ipam.delete_service'
+class ServiceBulkDeleteView(BulkDeleteView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filters.ServiceFilterSet
table = tables.ServiceTable
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 877133619..dbcf72262 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -48,8 +48,7 @@ class SecretRoleBulkImportView(BulkImportView):
default_return_url = 'secrets:secretrole_list'
-class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'secrets.delete_secretrole'
+class SecretRoleBulkDeleteView(BulkDeleteView):
queryset = SecretRole.objects.annotate(secret_count=Count('secrets'))
table = tables.SecretRoleTable
default_return_url = 'secrets:secretrole_list'
@@ -243,8 +242,7 @@ class SecretBulkEditView(BulkEditView):
default_return_url = 'secrets:secret_list'
-class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'secrets.delete_secret'
+class SecretBulkDeleteView(BulkDeleteView):
queryset = Secret.objects.prefetch_related('role', 'device')
filterset = filters.SecretFilterSet
table = tables.SecretTable
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index fdfcbd7f5..3de321301 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -42,8 +42,7 @@ class TenantGroupBulkImportView(BulkImportView):
default_return_url = 'tenancy:tenantgroup_list'
-class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'tenancy.delete_tenantgroup'
+class TenantGroupBulkDeleteView(BulkDeleteView):
queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants'))
table = tables.TenantGroupTable
default_return_url = 'tenancy:tenantgroup_list'
@@ -113,8 +112,7 @@ class TenantBulkEditView(BulkEditView):
default_return_url = 'tenancy:tenant_list'
-class TenantBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'tenancy.delete_tenant'
+class TenantBulkDeleteView(BulkDeleteView):
queryset = Tenant.objects.prefetch_related('group')
filterset = filters.TenantFilterSet
table = tables.TenantTable
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 1c8ceb525..6a1086c94 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -911,7 +911,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class BulkDeleteView(GetReturnURLMixin, View):
+class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Delete objects in bulk.
@@ -927,6 +927,9 @@ class BulkDeleteView(GetReturnURLMixin, View):
form = None
template_name = 'utilities/obj_bulk_delete.html'
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'delete')
+
def get(self, request):
return redirect(self.get_return_url(request))
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index e565832d8..898648f90 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -40,8 +40,7 @@ class ClusterTypeBulkImportView(BulkImportView):
default_return_url = 'virtualization:clustertype_list'
-class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'virtualization.delete_clustertype'
+class ClusterTypeBulkDeleteView(BulkDeleteView):
queryset = ClusterType.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterTypeTable
default_return_url = 'virtualization:clustertype_list'
@@ -69,8 +68,7 @@ class ClusterGroupBulkImportView(BulkImportView):
default_return_url = 'virtualization:clustergroup_list'
-class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'virtualization.delete_clustergroup'
+class ClusterGroupBulkDeleteView(BulkDeleteView):
queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters'))
table = tables.ClusterGroupTable
default_return_url = 'virtualization:clustergroup_list'
@@ -132,8 +130,7 @@ class ClusterBulkEditView(BulkEditView):
default_return_url = 'virtualization:cluster_list'
-class ClusterBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'virtualization.delete_cluster'
+class ClusterBulkDeleteView(BulkDeleteView):
queryset = Cluster.objects.prefetch_related('type', 'group', 'site')
filterset = filters.ClusterFilterSet
table = tables.ClusterTable
@@ -285,8 +282,7 @@ class VirtualMachineBulkEditView(BulkEditView):
default_return_url = 'virtualization:virtualmachine_list'
-class VirtualMachineBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'virtualization.delete_virtualmachine'
+class VirtualMachineBulkDeleteView(BulkDeleteView):
queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role')
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
@@ -321,8 +317,7 @@ class InterfaceBulkEditView(BulkEditView):
form = forms.InterfaceBulkEditForm
-class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
- permission_required = 'dcim.delete_interface'
+class InterfaceBulkDeleteView(BulkDeleteView):
queryset = Interface.objects.all()
table = tables.InterfaceTable
From e61fc1f7090a70e483df7fdcc556263b8aea6e25 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 15:39:07 -0400
Subject: [PATCH 059/300] Introduce ObjectView to enforce object-level
permissions for individual object views
---
docs/development/utility-views.md | 4 ++
netbox/circuits/views.py | 16 +++----
netbox/dcim/views.py | 80 ++++++++++++++++---------------
netbox/extras/views.py | 20 ++++----
netbox/ipam/views.py | 57 +++++++++++-----------
netbox/secrets/views.py | 9 ++--
netbox/tenancy/views.py | 10 ++--
netbox/utilities/views.py | 12 +++++
netbox/virtualization/views.py | 16 +++----
9 files changed, 118 insertions(+), 106 deletions(-)
diff --git a/docs/development/utility-views.md b/docs/development/utility-views.md
index a6e50f71e..3b9c1053d 100644
--- a/docs/development/utility-views.md
+++ b/docs/development/utility-views.md
@@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
## Individual Views
+### ObjectView
+
+Retrieve and display a single object.
+
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index e3260431f..1f5f05230 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,18 +1,16 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count, OuterRef
from django.shortcuts import get_object_or_404, redirect, render
-from django.views.generic import View
from django_tables2 import RequestConfig
from extras.models import Graph
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
@@ -30,12 +28,12 @@ class ProviderListView(ObjectListView):
table = tables.ProviderTable
-class ProviderView(PermissionRequiredMixin, View):
- permission_required = 'circuits.view_provider'
+class ProviderView(ObjectView):
+ queryset = Provider.objects.all()
def get(self, request, slug):
- provider = get_object_or_404(Provider, slug=slug)
+ provider = get_object_or_404(self.queryset, slug=slug)
circuits = Circuit.objects.filter(
provider=provider
).prefetch_related(
@@ -135,12 +133,12 @@ class CircuitListView(ObjectListView):
table = tables.CircuitTable
-class CircuitView(PermissionRequiredMixin, View):
- permission_required = 'circuits.view_circuit'
+class CircuitView(ObjectView):
+ queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
def get(self, request, pk):
- circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
+ circuit = get_object_or_404(self.queryset, pk=pk)
termination_a = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 5559d577c..fb60b6b31 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -26,7 +26,7 @@ from utilities.paginator import EnhancedPaginator
from utilities.utils import csv_format
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
- ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin,
+ ObjectView, ObjectImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -185,8 +185,7 @@ class SiteListView(ObjectListView):
table = tables.SiteTable
-class SiteView(ObjectPermissionRequiredMixin, View):
- permission_required = 'dcim.view_site'
+class SiteView(ObjectView):
queryset = Site.objects.prefetch_related('region', 'tenant__group')
def get(self, request, slug):
@@ -362,12 +361,12 @@ class RackElevationListView(PermissionRequiredMixin, View):
})
-class RackView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_rack'
+class RackView(ObjectView):
+ queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
def get(self, request, pk):
- rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
+ rack = get_object_or_404(self.queryset, pk=pk)
nonracked_devices = Device.objects.filter(
rack=rack,
@@ -440,12 +439,12 @@ class RackReservationListView(ObjectListView):
action_buttons = ('export',)
-class RackReservationView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_rackreservation'
+class RackReservationView(ObjectView):
+ queryset = RackReservation.objects.prefetch_related('rack')
def get(self, request, pk):
- rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk)
+ rackreservation = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/rackreservation.html', {
'rackreservation': rackreservation,
@@ -546,12 +545,12 @@ class DeviceTypeListView(ObjectListView):
table = tables.DeviceTypeTable
-class DeviceTypeView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_devicetype'
+class DeviceTypeView(ObjectView):
+ queryset = DeviceType.objects.prefetch_related('manufacturer')
def get(self, request, pk):
- devicetype = get_object_or_404(DeviceType, pk=pk)
+ devicetype = get_object_or_404(self.queryset, pk=pk)
# Component tables
consoleport_table = tables.ConsolePortTemplateTable(
@@ -990,14 +989,14 @@ class DeviceListView(ObjectListView):
template_name = 'dcim/device_list.html'
-class DeviceView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_device'
+class DeviceView(ObjectView):
+ queryset = Device.objects.prefetch_related(
+ 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
+ )
def get(self, request, pk):
- device = get_object_or_404(Device.objects.prefetch_related(
- 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
- ), pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
# VirtualChassis members
if device.virtual_chassis is not None:
@@ -1068,12 +1067,12 @@ class DeviceView(PermissionRequiredMixin, View):
})
-class DeviceInventoryView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_device'
+class DeviceInventoryView(ObjectView):
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
inventory_items = InventoryItem.objects.filter(
device=device, parent=None
).prefetch_related(
@@ -1087,12 +1086,13 @@ class DeviceInventoryView(PermissionRequiredMixin, View):
})
-class DeviceStatusView(PermissionRequiredMixin, View):
+class DeviceStatusView(ObjectView):
permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/device_status.html', {
'device': device,
@@ -1102,10 +1102,11 @@ class DeviceStatusView(PermissionRequiredMixin, View):
class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
'_connected_interface__device'
)
@@ -1119,10 +1120,11 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
class DeviceConfigView(PermissionRequiredMixin, View):
permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ queryset = Device.objects.all()
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk)
+ device = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/device_config.html', {
'device': device,
@@ -1426,12 +1428,12 @@ class InterfaceListView(ObjectListView):
action_buttons = ('import', 'export')
-class InterfaceView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_interface'
+class InterfaceView(ObjectView):
+ queryset = Interface.objects.all()
def get(self, request, pk):
- interface = get_object_or_404(Interface, pk=pk)
+ interface = get_object_or_404(self.queryset, pk=pk)
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
@@ -1878,12 +1880,12 @@ class CableListView(ObjectListView):
action_buttons = ('import', 'export')
-class CableView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_cable'
+class CableView(ObjectView):
+ queryset = Cable.objects.all()
def get(self, request, pk):
- cable = get_object_or_404(Cable, pk=pk)
+ cable = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/cable.html', {
'cable': cable,
@@ -2194,11 +2196,11 @@ class VirtualChassisListView(ObjectListView):
action_buttons = ('export',)
-class VirtualChassisView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_virtualchassis'
+class VirtualChassisView(ObjectView):
+ queryset = VirtualChassis.objects.prefetch_related('members')
def get(self, request, pk):
- virtualchassis = get_object_or_404(VirtualChassis.objects.prefetch_related('members'), pk=pk)
+ virtualchassis = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/virtualchassis.html', {
'virtualchassis': virtualchassis,
@@ -2461,12 +2463,12 @@ class PowerPanelListView(ObjectListView):
table = tables.PowerPanelTable
-class PowerPanelView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_powerpanel'
+class PowerPanelView(ObjectView):
+ queryset = PowerPanel.objects.prefetch_related('site', 'rack_group')
def get(self, request, pk):
- powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk)
+ powerpanel = get_object_or_404(self.queryset, pk=pk)
powerfeed_table = tables.PowerFeedTable(
data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'),
orderable=False
@@ -2529,12 +2531,12 @@ class PowerFeedListView(ObjectListView):
table = tables.PowerFeedTable
-class PowerFeedView(PermissionRequiredMixin, View):
- permission_required = 'dcim.view_powerfeed'
+class PowerFeedView(ObjectView):
+ queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
def get(self, request, pk):
- powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk)
+ powerfeed = get_object_or_404(self.queryset, pk=pk)
return render(request, 'dcim/powerfeed.html', {
'powerfeed': powerfeed,
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 0a3796a28..78db8f24a 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -13,7 +13,7 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
-from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
+from utilities.views import BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports
@@ -37,12 +37,12 @@ class TagListView(ObjectListView):
action_buttons = ()
-class TagView(PermissionRequiredMixin, View):
- permission_required = 'extras.view_tag'
+class TagView(ObjectView):
+ queryset = Tag.objects.all()
def get(self, request, slug):
- tag = get_object_or_404(Tag, slug=slug)
+ tag = get_object_or_404(self.queryset, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).prefetch_related(
@@ -109,11 +109,11 @@ class ConfigContextListView(ObjectListView):
action_buttons = ('add',)
-class ConfigContextView(PermissionRequiredMixin, View):
- permission_required = 'extras.view_configcontext'
+class ConfigContextView(ObjectView):
+ queryset = ConfigContext.objects.all()
def get(self, request, pk):
- configcontext = get_object_or_404(ConfigContext, pk=pk)
+ configcontext = get_object_or_404(self.queryset, pk=pk)
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
@@ -195,12 +195,12 @@ class ObjectChangeListView(ObjectListView):
action_buttons = ('export',)
-class ObjectChangeView(PermissionRequiredMixin, View):
- permission_required = 'extras.view_objectchange'
+class ObjectChangeView(ObjectView):
+ queryset = ObjectChange.objects.all()
def get(self, request, pk):
- objectchange = get_object_or_404(ObjectChange, pk=pk)
+ objectchange = get_object_or_404(self.queryset, pk=pk)
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
related_changes_table = ObjectChangeTable(
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 19d38be5d..706f819cc 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -10,8 +10,8 @@ from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.views import (
- BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
- ObjectPermissionRequiredMixin,
+ BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
+ ObjectListView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -120,12 +120,12 @@ class VRFListView(ObjectListView):
table = tables.VRFTable
-class VRFView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_vrf'
+class VRFView(ObjectView):
+ queryset = VRF.objects.all()
def get(self, request, pk):
- vrf = get_object_or_404(VRF.objects.all(), pk=pk)
+ vrf = get_object_or_404(self.queryset, pk=pk)
prefix_count = Prefix.objects.filter(vrf=vrf).count()
return render(request, 'ipam/vrf.html', {
@@ -298,12 +298,12 @@ class AggregateListView(ObjectListView):
}
-class AggregateView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_aggregate'
+class AggregateView(ObjectView):
+ queryset = Aggregate.objects.all()
def get(self, request, pk):
- aggregate = get_object_or_404(Aggregate, pk=pk)
+ aggregate = get_object_or_404(self.queryset, pk=pk)
# Find all child prefixes contained by this aggregate
child_prefixes = Prefix.objects.filter(
@@ -422,8 +422,7 @@ class PrefixListView(ObjectListView):
return self.queryset.annotate_depth(limit=limit)
-class PrefixView(ObjectPermissionRequiredMixin, View):
- permission_required = 'ipam.view_prefix'
+class PrefixView(ObjectView):
queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role')
def get(self, request, pk):
@@ -465,12 +464,12 @@ class PrefixView(ObjectPermissionRequiredMixin, View):
})
-class PrefixPrefixesView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_prefix'
+class PrefixPrefixesView(ObjectView):
+ queryset = Prefix.objects.all()
def get(self, request, pk):
- prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
+ prefix = get_object_or_404(self.queryset, pk=pk)
# Child prefixes table
child_prefixes = prefix.get_child_prefixes().prefetch_related(
@@ -509,12 +508,12 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
})
-class PrefixIPAddressesView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_prefix'
+class PrefixIPAddressesView(ObjectView):
+ queryset = Prefix.objects.all()
def get(self, request, pk):
- prefix = get_object_or_404(Prefix.objects.all(), pk=pk)
+ prefix = get_object_or_404(self.queryset, pk=pk)
# Find all IPAddresses belonging to this Prefix
ipaddresses = prefix.get_child_ips().prefetch_related(
@@ -601,12 +600,12 @@ class IPAddressListView(ObjectListView):
table = tables.IPAddressDetailTable
-class IPAddressView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_ipaddress'
+class IPAddressView(ObjectView):
+ queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
def get(self, request, pk):
- ipaddress = get_object_or_404(IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), pk=pk)
+ ipaddress = get_object_or_404(self.queryset, pk=pk)
# Parent prefixes table
parent_prefixes = Prefix.objects.filter(
@@ -833,14 +832,12 @@ class VLANListView(ObjectListView):
table = tables.VLANDetailTable
-class VLANView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_vlan'
+class VLANView(ObjectView):
+ queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role')
def get(self, request, pk):
- vlan = get_object_or_404(VLAN.objects.prefetch_related(
- 'site__region', 'tenant__group', 'role'
- ), pk=pk)
+ vlan = get_object_or_404(self.queryset, pk=pk)
prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role')
prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',)
@@ -851,12 +848,12 @@ class VLANView(PermissionRequiredMixin, View):
})
-class VLANMembersView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_vlan'
+class VLANMembersView(ObjectView):
+ queryset = VLAN.objects.all()
def get(self, request, pk):
- vlan = get_object_or_404(VLAN.objects.all(), pk=pk)
+ vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
@@ -920,12 +917,12 @@ class ServiceListView(ObjectListView):
action_buttons = ('export',)
-class ServiceView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_service'
+class ServiceView(ObjectView):
+ queryset = Service.objects.all()
def get(self, request, pk):
- service = get_object_or_404(Service, pk=pk)
+ service = get_object_or_404(self.queryset, pk=pk)
return render(request, 'ipam/service.html', {
'service': service,
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index dbcf72262..a2e627a7c 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -9,7 +9,8 @@ from django.urls import reverse
from django.views.generic import View
from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectView, ObjectDeleteView, ObjectEditView,
+ ObjectListView,
)
from . import filters, forms, tables
from .decorators import userkey_required
@@ -66,12 +67,12 @@ class SecretListView(ObjectListView):
action_buttons = ('import', 'export')
-class SecretView(PermissionRequiredMixin, View):
- permission_required = 'secrets.view_secret'
+class SecretView(ObjectView):
+ queryset = Secret.objects.all()
def get(self, request, pk):
- secret = get_object_or_404(Secret, pk=pk)
+ secret = get_object_or_404(self.queryset, pk=pk)
return render(request, 'secrets/secret.html', {
'secret': secret,
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 3de321301..823df9933 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -1,13 +1,11 @@
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.shortcuts import get_object_or_404, render
-from django.views.generic import View
from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation
from ipam.models import IPAddress, Prefix, VLAN, VRF
from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from virtualization.models import VirtualMachine, Cluster
from . import filters, forms, tables
@@ -59,12 +57,12 @@ class TenantListView(ObjectListView):
table = tables.TenantTable
-class TenantView(PermissionRequiredMixin, View):
- permission_required = 'tenancy.view_tenant'
+class TenantView(ObjectView):
+ queryset = Tenant.objects.prefetch_related('group')
def get(self, request, slug):
- tenant = get_object_or_404(Tenant, slug=slug)
+ tenant = get_object_or_404(self.queryset, slug=slug)
stats = {
'site_count': Site.objects.filter(tenant=tenant).count(),
'rack_count': Rack.objects.filter(tenant=tenant).count(),
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 6a1086c94..bd612b4df 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -118,6 +118,18 @@ class GetReturnURLMixin(object):
# Generic views
#
+class ObjectView(ObjectPermissionRequiredMixin, View):
+ """
+ Retrieve a single object for display.
+
+ :param queryset: The base queryset for retrieving the object.
+ """
+ queryset = None
+
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'view')
+
+
class ObjectListView(ObjectPermissionRequiredMixin, View):
"""
List a series of objects.
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 898648f90..53fcf9697 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -11,8 +11,8 @@ from dcim.tables import DeviceTable
from extras.views import ObjectConfigContextView
from ipam.models import Service
from utilities.views import (
- BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectDeleteView,
- ObjectEditView, ObjectListView,
+ BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, ObjectView,
+ ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -85,12 +85,12 @@ class ClusterListView(ObjectListView):
filterset_form = forms.ClusterFilterForm
-class ClusterView(PermissionRequiredMixin, View):
- permission_required = 'virtualization.view_cluster'
+class ClusterView(ObjectView):
+ queryset = Cluster.objects.all()
def get(self, request, pk):
- cluster = get_object_or_404(Cluster, pk=pk)
+ cluster = get_object_or_404(self.queryset, pk=pk)
devices = Device.objects.filter(cluster=cluster).prefetch_related(
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
@@ -233,12 +233,12 @@ class VirtualMachineListView(ObjectListView):
template_name = 'virtualization/virtualmachine_list.html'
-class VirtualMachineView(PermissionRequiredMixin, View):
- permission_required = 'virtualization.view_virtualmachine'
+class VirtualMachineView(ObjectView):
+ queryset = VirtualMachine.objects.prefetch_related('tenant__group')
def get(self, request, pk):
- virtualmachine = get_object_or_404(VirtualMachine.objects.prefetch_related('tenant__group'), pk=pk)
+ virtualmachine = get_object_or_404(self.queryset, pk=pk)
interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
services = Service.objects.filter(virtual_machine=virtualmachine)
From 91362b0f821bad2a0634eb6e582dbcbbe12d0745 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 15:53:50 -0400
Subject: [PATCH 060/300] Transition BulkCreateView to use
ObjectPermissionRequiredMixin
---
netbox/ipam/views.py | 2 +-
netbox/utilities/views.py | 26 ++++++++++++++++++++------
2 files changed, 21 insertions(+), 7 deletions(-)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 706f819cc..476943b13 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -721,7 +721,7 @@ class IPAddressDeleteView(ObjectDeleteView):
default_return_url = 'ipam:ipaddress_list'
-class IPAddressBulkCreateView(PermissionRequiredMixin, BulkCreateView):
+class IPAddressBulkCreateView(BulkCreateView):
permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index bd612b4df..ba1c18acc 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -468,20 +468,25 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class BulkCreateView(GetReturnURLMixin, View):
+class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Create new objects in bulk.
- form: Form class which provides the `pattern` field
- model_form: The ModelForm used to create individual objects
- pattern_target: Name of the field to be evaluated as a pattern (if any)
- template_name: The name of the template
+ :param queryset: Base queryset for the objects being created
+ :param form: Form class which provides the `pattern` field
+ :param model_form: The ModelForm used to create individual objects
+ :param pattern_target: Name of the field to be evaluated as a pattern (if any)
+ :param template_name: The name of the template
"""
+ queryset = None
form = None
model_form = None
pattern_target = ''
template_name = None
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'add')
+
def get(self, request):
# Set initial values for visible form fields from query args
initial = {}
@@ -501,7 +506,7 @@ class BulkCreateView(GetReturnURLMixin, View):
def post(self, request):
logger = logging.getLogger('netbox.views.BulkCreateView')
- model = self.model_form._meta.model
+ model = self.queryset.model
form = self.form(request.POST)
model_form = self.model_form(request.POST)
@@ -534,6 +539,10 @@ class BulkCreateView(GetReturnURLMixin, View):
# Raise an IntegrityError to break the for loop and abort the transaction.
raise IntegrityError()
+ # Enforce object-level permissions
+ if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+ raise ObjectDoesNotExist
+
# 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)
@@ -546,6 +555,11 @@ class BulkCreateView(GetReturnURLMixin, View):
except IntegrityError:
pass
+ except ObjectDoesNotExist:
+ msg = "Object creation failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
+
else:
logger.debug("Form validation failed")
From af8e1a647273bd991907884d2e1a738fea49bfaa Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 16:00:18 -0400
Subject: [PATCH 061/300] Strip 'param' indicators from docstrings
---
netbox/utilities/views.py | 62 +++++++++++++++++++--------------------
1 file changed, 31 insertions(+), 31 deletions(-)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index ba1c18acc..cc0c7596d 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -122,7 +122,7 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
"""
Retrieve a single object for display.
- :param queryset: The base queryset for retrieving the object.
+ queryset: The base queryset for retrieving the object.
"""
queryset = None
@@ -134,11 +134,11 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
"""
List a series of objects.
- :param queryset: The queryset of objects to display
- :param filter: A django-filter FilterSet that is applied to the queryset
- :param filter_form: The form used to render filter options
- :param table: The django-tables2 Table used to render the objects list
- :param template_name: The name of the template
+ queryset: The queryset of objects to display
+ filter: A django-filter FilterSet that is applied to the queryset
+ filter_form: The form used to render filter options
+ table: The django-tables2 Table used to render the objects list
+ template_name: The name of the template
"""
queryset = None
filterset = None
@@ -294,9 +294,9 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Create or edit a single object.
- :param queryset: The base queryset for the object being modified
- :param model_form: The form used to create or edit the object
- :param template_name: The name of the template
+ queryset: The base queryset for the object being modified
+ model_form: The form used to create or edit the object
+ template_name: The name of the template
"""
queryset = None
model_form = None
@@ -405,8 +405,8 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Delete a single object.
- :param queryset: The base queryset for the object being deleted
- :param template_name: The name of the template
+ queryset: The base queryset for the object being deleted
+ template_name: The name of the template
"""
queryset = None
template_name = 'utilities/obj_delete.html'
@@ -472,11 +472,11 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Create new objects in bulk.
- :param queryset: Base queryset for the objects being created
- :param form: Form class which provides the `pattern` field
- :param model_form: The ModelForm used to create individual objects
- :param pattern_target: Name of the field to be evaluated as a pattern (if any)
- :param template_name: The name of the template
+ queryset: Base queryset for the objects being created
+ form: Form class which provides the `pattern` field
+ model_form: The ModelForm used to create individual objects
+ pattern_target: Name of the field to be evaluated as a pattern (if any)
+ template_name: The name of the template
"""
queryset = None
form = None
@@ -682,11 +682,11 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Import objects in bulk (CSV format).
- :param queryset: Base queryset for the model
- :param model_form: The form used to create each imported object
- :param table: The django-tables2 Table used to render the list of imported objects
- :param template_name: The name of the template
- :param widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
+ queryset: Base queryset for the model
+ model_form: The form used to create each imported object
+ table: The django-tables2 Table used to render the list of imported objects
+ template_name: The name of the template
+ widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key)
"""
queryset = None
model_form = None
@@ -785,11 +785,11 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Edit objects in bulk.
- :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- :param filter: FilterSet to apply when deleting by QuerySet
- :param table: The table used to display devices being edited
- :param form: The form class used to edit objects in bulk
- :param template_name: The name of the template
+ queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ filter: FilterSet to apply when deleting by QuerySet
+ table: The table used to display devices being edited
+ form: The form class used to edit objects in bulk
+ template_name: The name of the template
"""
queryset = None
filterset = None
@@ -941,11 +941,11 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Delete objects in bulk.
- :param queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
- :param filter: FilterSet to apply when deleting by QuerySet
- :param table: The table used to display devices being deleted
- :param form: The form class used to delete objects in bulk
- :param template_name: The name of the template
+ queryset: Custom queryset to use when retrieving objects (e.g. to select related objects)
+ filter: FilterSet to apply when deleting by QuerySet
+ table: The table used to display devices being deleted
+ form: The form class used to delete objects in bulk
+ template_name: The name of the template
"""
queryset = None
filterset = None
From 49b780358ed3a1deb59b4319be87fa2741df1344 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 16:11:46 -0400
Subject: [PATCH 062/300] Transition BulkRenameView, BulkDisconnectView to use
ObjectPermissionRequiredMixin
---
netbox/dcim/views.py | 68 ++++++++++++++++++++------------------------
1 file changed, 31 insertions(+), 37 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index fb60b6b31..34a482da8 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -23,6 +23,7 @@ from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
+from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format
from utilities.views import (
BulkComponentCreateView, BulkDeleteView, BulkEditView, BulkImportView, ComponentCreateView, GetReturnURLMixin,
@@ -41,7 +42,7 @@ from .models import (
)
-class BulkRenameView(GetReturnURLMixin, View):
+class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for renaming device components in bulk.
"""
@@ -49,9 +50,10 @@ class BulkRenameView(GetReturnURLMixin, View):
form = None
template_name = 'dcim/bulk_rename.html'
- def post(self, request):
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'change')
- model = self.queryset.model
+ def post(self, request):
if '_preview' in request.POST or '_apply' in request.POST:
form = self.form(request.POST, initial={'pk': request.POST.getlist('pk')})
@@ -76,7 +78,7 @@ class BulkRenameView(GetReturnURLMixin, View):
obj.save()
messages.success(request, "Renamed {} {}".format(
len(selected_objects),
- model._meta.verbose_name_plural
+ self.queryset.model._meta.verbose_name_plural
))
return redirect(self.get_return_url(request))
@@ -86,7 +88,7 @@ class BulkRenameView(GetReturnURLMixin, View):
return render(request, self.template_name, {
'form': form,
- 'obj_type_plural': model._meta.verbose_name_plural,
+ 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': self.get_return_url(request),
})
@@ -96,10 +98,13 @@ class BulkDisconnectView(GetReturnURLMixin, View):
"""
An extendable view for disconnection console/power/interface components in bulk.
"""
- model = None
+ queryset = None
form = None
template_name = 'dcim/bulk_disconnect.html'
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'change')
+
def post(self, request):
selected_objects = []
@@ -113,25 +118,25 @@ class BulkDisconnectView(GetReturnURLMixin, View):
with transaction.atomic():
count = 0
- for obj in self.model.objects.filter(pk__in=form.cleaned_data['pk']):
+ for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
if obj.cable is None:
continue
obj.cable.delete()
count += 1
messages.success(request, "Disconnected {} {}".format(
- count, self.model._meta.verbose_name_plural
+ count, self.queryset.model._meta.verbose_name_plural
))
return redirect(return_url)
else:
form = self.form(initial={'pk': request.POST.getlist('pk')})
- selected_objects = self.model.objects.filter(pk__in=form.initial['pk'])
+ selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, {
'form': form,
- 'obj_type_plural': self.model._meta.verbose_name_plural,
+ 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects,
'return_url': return_url,
})
@@ -1285,15 +1290,13 @@ class ConsoleServerPortBulkEditView(BulkEditView):
form = forms.ConsoleServerPortBulkEditForm
-class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_consoleserverport'
+class ConsoleServerPortBulkRenameView(BulkRenameView):
queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortBulkRenameForm
-class ConsoleServerPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_consoleserverport'
- model = ConsoleServerPort
+class ConsoleServerPortBulkDisconnectView(BulkDisconnectView):
+ queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortBulkDisconnectForm
@@ -1397,15 +1400,13 @@ class PowerOutletBulkEditView(BulkEditView):
form = forms.PowerOutletBulkEditForm
-class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_poweroutlet'
+class PowerOutletBulkRenameView(BulkRenameView):
queryset = PowerOutlet.objects.all()
form = forms.PowerOutletBulkRenameForm
-class PowerOutletBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_poweroutlet'
- model = PowerOutlet
+class PowerOutletBulkDisconnectView(BulkDisconnectView):
+ queryset = PowerOutlet.objects.all()
form = forms.PowerOutletBulkDisconnectForm
@@ -1496,15 +1497,13 @@ class InterfaceBulkEditView(BulkEditView):
form = forms.InterfaceBulkEditForm
-class InterfaceBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_interface'
+class InterfaceBulkRenameView(BulkRenameView):
queryset = Interface.objects.all()
form = forms.InterfaceBulkRenameForm
-class InterfaceBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_interface'
- model = Interface
+class InterfaceBulkDisconnectView(BulkDisconnectView):
+ queryset = Interface.objects.all()
form = forms.InterfaceBulkDisconnectForm
@@ -1558,15 +1557,13 @@ class FrontPortBulkEditView(BulkEditView):
form = forms.FrontPortBulkEditForm
-class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_frontport'
+class FrontPortBulkRenameView(BulkRenameView):
queryset = FrontPort.objects.all()
form = forms.FrontPortBulkRenameForm
-class FrontPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_frontport'
- model = FrontPort
+class FrontPortBulkDisconnectView(BulkDisconnectView):
+ queryset = FrontPort.objects.all()
form = forms.FrontPortBulkDisconnectForm
@@ -1620,15 +1617,13 @@ class RearPortBulkEditView(BulkEditView):
form = forms.RearPortBulkEditForm
-class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_rearport'
+class RearPortBulkRenameView(BulkRenameView):
queryset = RearPort.objects.all()
form = forms.RearPortBulkRenameForm
-class RearPortBulkDisconnectView(PermissionRequiredMixin, BulkDisconnectView):
- permission_required = 'dcim.change_rearport'
- model = RearPort
+class RearPortBulkDisconnectView(BulkDisconnectView):
+ queryset = RearPort.objects.all()
form = forms.RearPortBulkDisconnectForm
@@ -1753,8 +1748,7 @@ class DeviceBayBulkEditView(BulkEditView):
form = forms.DeviceBayBulkEditForm
-class DeviceBayBulkRenameView(PermissionRequiredMixin, BulkRenameView):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayBulkRenameView(BulkRenameView):
queryset = DeviceBay.objects.all()
form = forms.DeviceBayBulkRenameForm
From f36c797e98eb2345ee6927f3256e6b4ef701d3ed Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 16:28:11 -0400
Subject: [PATCH 063/300] Transition ComponentCreateView to use
ObjectPermissionRequiredMixin
---
netbox/dcim/views.py | 90 ++++++++++++++--------------------
netbox/utilities/views.py | 49 ++++++++++++------
netbox/virtualization/views.py | 5 +-
3 files changed, 72 insertions(+), 72 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 34a482da8..41269d0e0 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -671,9 +671,8 @@ class DeviceTypeBulkDeleteView(BulkDeleteView):
# Console port templates
#
-class ConsolePortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleporttemplate'
- model = ConsolePortTemplate
+class ConsolePortTemplateCreateView(ComponentCreateView):
+ queryset = ConsolePortTemplate.objects.all()
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -703,9 +702,8 @@ class ConsolePortTemplateBulkDeleteView(BulkDeleteView):
# Console server port templates
#
-class ConsoleServerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleserverporttemplate'
- model = ConsoleServerPortTemplate
+class ConsoleServerPortTemplateCreateView(ComponentCreateView):
+ queryset = ConsoleServerPortTemplate.objects.all()
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -735,9 +733,8 @@ class ConsoleServerPortTemplateBulkDeleteView(BulkDeleteView):
# Power port templates
#
-class PowerPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_powerporttemplate'
- model = PowerPortTemplate
+class PowerPortTemplateCreateView(ComponentCreateView):
+ queryset = PowerPortTemplate.objects.all()
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -767,9 +764,8 @@ class PowerPortTemplateBulkDeleteView(BulkDeleteView):
# Power outlet templates
#
-class PowerOutletTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_poweroutlettemplate'
- model = PowerOutletTemplate
+class PowerOutletTemplateCreateView(ComponentCreateView):
+ queryset = PowerOutletTemplate.objects.all()
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -799,9 +795,8 @@ class PowerOutletTemplateBulkDeleteView(BulkDeleteView):
# Interface templates
#
-class InterfaceTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_interfacetemplate'
- model = InterfaceTemplate
+class InterfaceTemplateCreateView(ComponentCreateView):
+ queryset = InterfaceTemplate.objects.all()
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -831,9 +826,8 @@ class InterfaceTemplateBulkDeleteView(BulkDeleteView):
# Front port templates
#
-class FrontPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_frontporttemplate'
- model = FrontPortTemplate
+class FrontPortTemplateCreateView(ComponentCreateView):
+ queryset = FrontPortTemplate.objects.all()
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -863,9 +857,8 @@ class FrontPortTemplateBulkDeleteView(BulkDeleteView):
# Rear port templates
#
-class RearPortTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_rearporttemplate'
- model = RearPortTemplate
+class RearPortTemplateCreateView(ComponentCreateView):
+ queryset = RearPortTemplate.objects.all()
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -895,9 +888,8 @@ class RearPortTemplateBulkDeleteView(BulkDeleteView):
# Device bay templates
#
-class DeviceBayTemplateCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_devicebaytemplate'
- model = DeviceBayTemplate
+class DeviceBayTemplateCreateView(ComponentCreateView):
+ queryset = DeviceBayTemplate.objects.all()
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
template_name = 'dcim/device_component_add.html'
@@ -913,7 +905,6 @@ class DeviceBayTemplateDeleteView(ObjectDeleteView):
# class DeviceBayTemplateBulkEditView(BulkEditView):
-# permission_required = 'dcim.change_devicebaytemplate'
# queryset = DeviceBayTemplate.objects.all()
# table = tables.DeviceBayTemplateTable
# form = forms.DeviceBayTemplateBulkEditForm
@@ -1105,7 +1096,7 @@ class DeviceStatusView(ObjectView):
})
-class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
+class DeviceLLDPNeighborsView(ObjectView):
permission_required = ('dcim.view_device', 'dcim.napalm_read')
queryset = Device.objects.all()
@@ -1123,7 +1114,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
})
-class DeviceConfigView(PermissionRequiredMixin, View):
+class DeviceConfigView(ObjectView):
permission_required = ('dcim.view_device', 'dcim.napalm_read')
queryset = Device.objects.all()
@@ -1209,9 +1200,8 @@ class ConsolePortListView(ObjectListView):
action_buttons = ('import', 'export')
-class ConsolePortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleport'
- model = ConsolePort
+class ConsolePortCreateView(ComponentCreateView):
+ queryset = ConsolePort.objects.all()
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
template_name = 'dcim/device_component_add.html'
@@ -1259,9 +1249,8 @@ class ConsoleServerPortListView(ObjectListView):
action_buttons = ('import', 'export')
-class ConsoleServerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_consoleserverport'
- model = ConsoleServerPort
+class ConsoleServerPortCreateView(ComponentCreateView):
+ queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
template_name = 'dcim/device_component_add.html'
@@ -1319,9 +1308,8 @@ class PowerPortListView(ObjectListView):
action_buttons = ('import', 'export')
-class PowerPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_powerport'
- model = PowerPort
+class PowerPortCreateView(ComponentCreateView):
+ queryset = PowerPort.objects.all()
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
template_name = 'dcim/device_component_add.html'
@@ -1369,9 +1357,8 @@ class PowerOutletListView(ObjectListView):
action_buttons = ('import', 'export')
-class PowerOutletCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_poweroutlet'
- model = PowerOutlet
+class PowerOutletCreateView(ComponentCreateView):
+ queryset = PowerOutlet.objects.all()
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
template_name = 'dcim/device_component_add.html'
@@ -1465,9 +1452,8 @@ class InterfaceView(ObjectView):
})
-class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_interface'
- model = Interface
+class InterfaceCreateView(ComponentCreateView):
+ queryset = Interface.objects.all()
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'dcim/device_component_add.html'
@@ -1526,9 +1512,8 @@ class FrontPortListView(ObjectListView):
action_buttons = ('import', 'export')
-class FrontPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_frontport'
- model = FrontPort
+class FrontPortCreateView(ComponentCreateView):
+ queryset = FrontPort.objects.all()
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
template_name = 'dcim/device_component_add.html'
@@ -1586,9 +1571,8 @@ class RearPortListView(ObjectListView):
action_buttons = ('import', 'export')
-class RearPortCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_rearport'
- model = RearPort
+class RearPortCreateView(ComponentCreateView):
+ queryset = RearPort.objects.all()
form = forms.RearPortCreateForm
model_form = forms.RearPortForm
template_name = 'dcim/device_component_add.html'
@@ -1648,9 +1632,8 @@ class DeviceBayListView(ObjectListView):
action_buttons = ('import', 'export')
-class DeviceBayCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_devicebay'
- model = DeviceBay
+class DeviceBayCreateView(ComponentCreateView):
+ queryset = DeviceBay.objects.all()
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
template_name = 'dcim/device_component_add.html'
@@ -2144,9 +2127,8 @@ class InventoryItemEditView(ObjectEditView):
model_form = forms.InventoryItemForm
-class InventoryItemCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_inventoryitem'
- model = InventoryItem
+class InventoryItemCreateView(ComponentCreateView):
+ queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
template_name = 'dcim/device_component_add.html'
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index cc0c7596d..c008b0501 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -1033,28 +1033,32 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
#
# TODO: Replace with BulkCreateView
-class ComponentCreateView(GetReturnURLMixin, View):
+class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
"""
- model = None
+ queryset = None
form = None
model_form = None
template_name = None
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'add')
+
def get(self, request):
form = self.form(initial=request.GET)
return render(request, self.template_name, {
- 'component_type': self.model._meta.verbose_name,
+ 'component_type': self.queryset.model._meta.verbose_name,
'form': form,
'return_url': self.get_return_url(request),
})
def post(self, request):
-
+ logger = logging.getLogger('netbox.views.ComponentCreateView')
form = self.form(request.POST, initial=request.GET)
+
if form.is_valid():
new_components = []
@@ -1080,20 +1084,35 @@ class ComponentCreateView(GetReturnURLMixin, View):
if not form.errors:
- # Create the new components
- for component_form in new_components:
- component_form.save()
+ try:
- messages.success(request, "Added {} {}".format(
- len(new_components), self.model._meta.verbose_name_plural
- ))
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
- else:
- return redirect(self.get_return_url(request))
+ with transaction.atomic():
+
+ # Create the new components
+ new_objs = []
+ for component_form in new_components:
+ obj = component_form.save()
+ new_objs.append(obj)
+
+ # Enforce object-level permissions
+ if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
+ raise ObjectDoesNotExist
+
+ messages.success(request, "Added {} {}".format(
+ len(new_components), self.queryset.model._meta.verbose_name_plural
+ ))
+ if '_addanother' in request.POST:
+ return redirect(request.get_full_path())
+ else:
+ return redirect(self.get_return_url(request))
+
+ except ObjectDoesNotExist:
+ msg = "Component creation failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
return render(request, self.template_name, {
- 'component_type': self.model._meta.verbose_name,
+ 'component_type': self.queryset.model._meta.verbose_name,
'form': form,
'return_url': self.get_return_url(request),
})
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 53fcf9697..f7cf523d9 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -293,9 +293,8 @@ class VirtualMachineBulkDeleteView(BulkDeleteView):
# VM interfaces
#
-class InterfaceCreateView(PermissionRequiredMixin, ComponentCreateView):
- permission_required = 'dcim.add_interface'
- model = Interface
+class InterfaceCreateView(ComponentCreateView):
+ queryset = Interface.objects.all()
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
template_name = 'virtualization/virtualmachine_component_add.html'
From e7fde2795f9fdd4c223f397c7e4c8448f855e83b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 21 May 2020 16:34:15 -0400
Subject: [PATCH 064/300] Fix BulkDisconnectView
---
netbox/dcim/views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 41269d0e0..29d5498c6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -94,7 +94,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class BulkDisconnectView(GetReturnURLMixin, View):
+class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
An extendable view for disconnection console/power/interface components in bulk.
"""
From 7e64d3e6536191e920cf998591bf6494f1a0d982 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 09:23:00 -0400
Subject: [PATCH 065/300] Transition BulkComponentCreateView to use
ObjectPermissionRequiredMixin
---
netbox/dcim/views.py | 40 ++++++++++++++--------------------
netbox/utilities/views.py | 15 ++++++++++---
netbox/virtualization/views.py | 5 ++---
3 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 29d5498c6..0f5ea01a9 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1747,96 +1747,88 @@ class DeviceBayBulkDeleteView(BulkDeleteView):
# Bulk Device component creation
#
-class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_consoleport'
+class DeviceBulkAddConsolePortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.ConsolePortBulkCreateForm
- model = ConsolePort
+ queryset = ConsolePort.objects.all()
model_form = forms.ConsolePortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_consoleserverport'
+class DeviceBulkAddConsoleServerPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.ConsoleServerPortBulkCreateForm
- model = ConsoleServerPort
+ queryset = ConsoleServerPort.objects.all()
model_form = forms.ConsoleServerPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_powerport'
+class DeviceBulkAddPowerPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.PowerPortBulkCreateForm
- model = PowerPort
+ queryset = PowerPort.objects.all()
model_form = forms.PowerPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_poweroutlet'
+class DeviceBulkAddPowerOutletView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.PowerOutletBulkCreateForm
- model = PowerOutlet
+ queryset = PowerOutlet.objects.all()
model_form = forms.PowerOutletForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_interface'
+class DeviceBulkAddInterfaceView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.InterfaceBulkCreateForm
- model = Interface
+ queryset = Interface.objects.all()
model_form = forms.InterfaceForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView):
-# permission_required = 'dcim.add_frontport'
+# class DeviceBulkAddFrontPortView(BulkComponentCreateView):
# parent_model = Device
# parent_field = 'device'
# form = forms.FrontPortBulkCreateForm
-# model = FrontPort
+# queryset = FrontPort.objects.all()
# model_form = forms.FrontPortForm
# filterset = filters.DeviceFilterSet
# table = tables.DeviceTable
# default_return_url = 'dcim:device_list'
-class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_rearport'
+class DeviceBulkAddRearPortView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.RearPortBulkCreateForm
- model = RearPort
+ queryset = RearPort.objects.all()
model_form = forms.RearPortForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
-class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_devicebay'
+class DeviceBulkAddDeviceBayView(BulkComponentCreateView):
parent_model = Device
parent_field = 'device'
form = forms.DeviceBayBulkCreateForm
- model = DeviceBay
+ queryset = DeviceBay.objects.all()
model_form = forms.DeviceBayForm
filterset = filters.DeviceFilterSet
table = tables.DeviceTable
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index c008b0501..87f63678a 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -1118,14 +1118,14 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
})
-class BulkComponentCreateView(GetReturnURLMixin, View):
+class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines.
"""
parent_model = None
parent_field = None
form = None
- model = None
+ queryset = None
model_form = None
filterset = None
table = None
@@ -1134,7 +1134,7 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
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
+ model_name = self.queryset.model._meta.verbose_name_plural
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filterset is not None:
@@ -1179,9 +1179,18 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
for e in errors:
form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e)))
+ # Enforce object-level permissions
+ if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
+ raise ObjectDoesNotExist
+
except IntegrityError:
pass
+ except ObjectDoesNotExist:
+ msg = "Component creation failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
+
if not form.errors:
msg = "Added {} {} to {} {}.".format(
len(new_components),
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index f7cf523d9..e6d4f4946 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -325,12 +325,11 @@ class InterfaceBulkDeleteView(BulkDeleteView):
# Bulk Device component creation
#
-class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateView):
- permission_required = 'dcim.add_interface'
+class VirtualMachineBulkAddInterfaceView(BulkComponentCreateView):
parent_model = VirtualMachine
parent_field = 'virtual_machine'
form = forms.InterfaceBulkCreateForm
- model = Interface
+ queryset = Interface.objects.all()
model_form = forms.InterfaceForm
filterset = filters.VirtualMachineFilterSet
table = tables.VirtualMachineTable
From 71d4b5c5df03bdc4479207670f763686e597cb3d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 09:45:29 -0400
Subject: [PATCH 066/300] Enforce object-level permissions for circuit
termination swap view
---
netbox/circuits/urls.py | 3 +-
netbox/circuits/views.py | 71 +++++++++++++++++++++++++---------------
2 files changed, 46 insertions(+), 28 deletions(-)
diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py
index 1a7fa283b..1c0f0715b 100644
--- a/netbox/circuits/urls.py
+++ b/netbox/circuits/urls.py
@@ -37,10 +37,9 @@ urlpatterns = [
path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
- path('circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
+ path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations
-
path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index 1f5f05230..bb4d787c8 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -1,6 +1,5 @@
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.decorators import permission_required
from django.db import transaction
from django.db.models import Count, OuterRef
from django.shortcuts import get_object_or_404, redirect, render
@@ -191,25 +190,47 @@ class CircuitBulkDeleteView(BulkDeleteView):
default_return_url = 'circuits:circuit_list'
-@permission_required('circuits.change_circuittermination')
-def circuit_terminations_swap(request, pk):
+class CircuitSwapTerminations(ObjectEditView):
+ """
+ Swap the A and Z terminations of a circuit.
+ """
+ queryset = Circuit.objects.all()
- circuit = get_object_or_404(Circuit, pk=pk)
- termination_a = CircuitTermination.objects.filter(
- circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
- ).first()
- termination_z = CircuitTermination.objects.filter(
- circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
- ).first()
- if not termination_a and not termination_z:
- messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
- return redirect('circuits:circuit', pk=circuit.pk)
+ def get(self, request, pk):
+ circuit = get_object_or_404(self.queryset, pk=pk)
+ form = ConfirmationForm()
- if request.method == 'POST':
+ # Circuit must have at least one termination to swap
+ if not circuit.termination_a and not circuit.termination_z:
+ messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
+ return redirect('circuits:circuit', pk=circuit.pk)
+
+ return render(request, 'circuits/circuit_terminations_swap.html', {
+ 'circuit': circuit,
+ 'termination_a': circuit.termination_a,
+ 'termination_z': circuit.termination_z,
+ 'form': form,
+ 'panel_class': 'default',
+ 'button_class': 'primary',
+ 'return_url': circuit.get_absolute_url(),
+ })
+
+ def post(self, request, pk):
+ circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
+
if form.is_valid():
+
+ termination_a = CircuitTermination.objects.filter(
+ circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
+ ).first()
+ termination_z = CircuitTermination.objects.filter(
+ circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
+ ).first()
+
if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
+ print('swapping')
with transaction.atomic():
termination_a.term_side = '_'
termination_a.save()
@@ -223,21 +244,19 @@ def circuit_terminations_swap(request, pk):
else:
termination_z.term_side = 'A'
termination_z.save()
+
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
- else:
- form = ConfirmationForm()
-
- return render(request, 'circuits/circuit_terminations_swap.html', {
- 'circuit': circuit,
- 'termination_a': termination_a,
- 'termination_z': termination_z,
- 'form': form,
- 'panel_class': 'default',
- 'button_class': 'primary',
- 'return_url': circuit.get_absolute_url(),
- })
+ return render(request, 'circuits/circuit_terminations_swap.html', {
+ 'circuit': circuit,
+ 'termination_a': circuit.termination_a,
+ 'termination_z': circuit.termination_z,
+ 'form': form,
+ 'panel_class': 'default',
+ 'button_class': 'primary',
+ 'return_url': circuit.get_absolute_url(),
+ })
#
From ab60a5d73d1519df25182e89e56a9ef45e94b687 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 09:51:57 -0400
Subject: [PATCH 067/300] Enforce object-level permissions for
IPAddressAssignView, VLANGroupVLANsView
---
netbox/ipam/views.py | 18 +++++++-----------
1 file changed, 7 insertions(+), 11 deletions(-)
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 476943b13..14c6a6864 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -1,6 +1,5 @@
import netaddr
from django.conf import settings
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
@@ -11,7 +10,7 @@ from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
- ObjectListView,
+ ObjectListView, ObjectPermissionRequiredMixin,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -672,11 +671,11 @@ class IPAddressEditView(ObjectEditView):
return obj
-class IPAddressAssignView(PermissionRequiredMixin, View):
+class IPAddressAssignView(ObjectPermissionRequiredMixin, View):
"""
Search for IPAddresses to be assigned to an Interface.
"""
- permission_required = 'ipam.change_ipaddress'
+ queryset = IPAddress.objects.all()
def dispatch(self, request, *args, **kwargs):
@@ -687,7 +686,6 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
return super().dispatch(request, *args, **kwargs)
def get(self, request):
-
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
@@ -696,13 +694,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
})
def post(self, request):
-
form = forms.IPAddressAssignForm(request.POST)
table = None
if form.is_valid():
- addresses = IPAddress.objects.prefetch_related(
+ addresses = self.queryset.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
)
# Limit to 100 results
@@ -784,12 +781,11 @@ class VLANGroupBulkDeleteView(BulkDeleteView):
default_return_url = 'ipam:vlangroup_list'
-class VLANGroupVLANsView(PermissionRequiredMixin, View):
- permission_required = 'ipam.view_vlangroup'
+class VLANGroupVLANsView(ObjectView):
+ queryset = VLANGroup.objects.all()
def get(self, request, pk):
-
- vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
+ vlan_group = get_object_or_404(self.queryset, pk=pk)
vlans = VLAN.objects.filter(group_id=pk)
vlans = add_available_vlans(vlan_group, vlans)
From bae050e68952525d02d518024ceba992f8d86e5a Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 11:24:49 -0400
Subject: [PATCH 068/300] Replace legacy add/edit secret views with
SecretEditView
---
netbox/secrets/decorators.py | 24 ----
netbox/secrets/urls.py | 4 +-
netbox/secrets/views.py | 135 ++++++++--------------
netbox/templates/secrets/secret_edit.html | 14 +--
4 files changed, 58 insertions(+), 119 deletions(-)
delete mode 100644 netbox/secrets/decorators.py
diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py
deleted file mode 100644
index e2f44ac90..000000000
--- a/netbox/secrets/decorators.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from django.contrib import messages
-from django.shortcuts import redirect
-
-from .models import UserKey
-
-
-def userkey_required():
- """
- Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of
- Secrets).
- """
- def _decorator(view):
- def wrapped_view(request, *args, **kwargs):
- try:
- uk = UserKey.objects.get(user=request.user)
- except UserKey.DoesNotExist:
- messages.warning(request, "This operation requires an active user key, but you don't have one.")
- return redirect('user:userkey')
- if not uk.is_active():
- messages.warning(request, "This operation is not available. Your user key has not been activated.")
- return redirect('user:userkey')
- return view(request, *args, **kwargs)
- return wrapped_view
- return _decorator
diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py
index ac75a7ed4..84c2da398 100644
--- a/netbox/secrets/urls.py
+++ b/netbox/secrets/urls.py
@@ -17,12 +17,12 @@ urlpatterns = [
# Secrets
path('secrets/', views.SecretListView.as_view(), name='secret_list'),
- path('secrets/add/', views.secret_add, name='secret_add'),
+ path('secrets/add/', views.SecretEditView.as_view(), name='secret_add'),
path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),
path('secrets//', views.SecretView.as_view(), name='secret'),
- path('secrets//edit/', views.secret_edit, name='secret_edit'),
+ path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'),
path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'),
path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}),
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index a2e627a7c..a5aabaecd 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -1,20 +1,17 @@
import base64
+import logging
from django.contrib import messages
-from django.contrib.auth.decorators import permission_required
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
-from django.urls import reverse
-from django.views.generic import View
+from django.utils.html import escape
+from django.utils.safestring import mark_safe
from utilities.views import (
- BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectView, ObjectDeleteView, ObjectEditView,
- ObjectListView,
+ BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
-from .decorators import userkey_required
-from .models import SecretRole, Secret, SessionKey
+from .models import SecretRole, Secret, SessionKey, UserKey
def get_session_key(request):
@@ -79,107 +76,73 @@ class SecretView(ObjectView):
})
-@permission_required('secrets.add_secret')
-@userkey_required()
-def secret_add(request):
+class SecretEditView(ObjectEditView):
+ queryset = Secret.objects.all()
+ model_form = forms.SecretForm
+ template_name = 'secrets/secret_edit.html'
- secret = Secret()
- session_key = get_session_key(request)
+ def dispatch(self, request, *args, **kwargs):
+
+ # Check that the user has a valid UserKey
+ try:
+ uk = UserKey.objects.get(user=request.user)
+ except UserKey.DoesNotExist:
+ messages.warning(request, "This operation requires an active user key, but you don't have one.")
+ return redirect('user:userkey')
+ if not uk.is_active():
+ messages.warning(request, "This operation is not available. Your user key has not been activated.")
+ return redirect('user:userkey')
+
+ return super().dispatch(request, *args, **kwargs)
+
+ def post(self, request, *args, **kwargs):
+ logger = logging.getLogger('netbox.views.ObjectEditView')
+ session_key = get_session_key(request)
+ secret = self.get_object(kwargs)
+ form = self.model_form(request.POST, instance=secret)
- if request.method == 'POST':
- form = forms.SecretForm(request.POST, instance=secret)
if form.is_valid():
+ logger.debug("Form validation was successful")
- # We need a valid session key in order to create a Secret
- if session_key is None:
+ # We must have a session key in order to create a secret or update the plaintext of an existing secret
+ if (form.cleaned_data['plaintext'] or secret.pk is None) and session_key is None:
+ logger.debug("Unable to proceed: No session key was provided with the request")
form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
- # Create and encrypt the new Secret
else:
master_key = None
try:
sk = SessionKey.objects.get(userkey__user=request.user)
master_key = sk.get_master_key(session_key)
except SessionKey.DoesNotExist:
+ logger.debug("Unable to proceed: User has no session key assigned")
form.add_error(None, "No session key found for this user.")
if master_key is not None:
+ logger.debug("Successfully resolved master key for encryption")
secret = form.save(commit=False)
- secret.plaintext = str(form.cleaned_data['plaintext'])
+ if form.cleaned_data['plaintext']:
+ secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key)
secret.save()
form.save_m2m()
- messages.success(request, "Added new secret: {}.".format(secret))
- if '_addanother' in request.POST:
- return redirect('secrets:secret_add')
- else:
- return redirect('secrets:secret', pk=secret.pk)
+ msg = '{} secret'.format('Created' if not form.instance.pk else 'Modified')
+ logger.info(f"{msg} {secret} (PK: {secret.pk})")
+ msg = '{} {}'.format(msg, secret.get_absolute_url(), escape(secret))
+ messages.success(request, mark_safe(msg))
- else:
- initial_data = {
- 'device': request.GET.get('device'),
- }
- form = forms.SecretForm(initial=initial_data)
+ return redirect(self.get_return_url(request, secret))
- return render(request, 'secrets/secret_edit.html', {
- 'secret': secret,
- 'form': form,
- 'return_url': GetReturnURLMixin().get_return_url(request, secret)
- })
+ else:
+ logger.debug("Form validation failed")
-
-@permission_required('secrets.change_secret')
-@userkey_required()
-def secret_edit(request, pk):
-
- secret = get_object_or_404(Secret, pk=pk)
- session_key = get_session_key(request)
-
- if request.method == 'POST':
- form = forms.SecretForm(request.POST, instance=secret)
- if form.is_valid():
-
- # Re-encrypt the Secret if a plaintext and session key have been provided.
- if form.cleaned_data['plaintext'] and session_key is not None:
-
- # Retrieve the master key using the provided session key
- master_key = None
- try:
- sk = SessionKey.objects.get(userkey__user=request.user)
- master_key = sk.get_master_key(session_key)
- except SessionKey.DoesNotExist:
- form.add_error(None, "No session key found for this user.")
-
- # Create and encrypt the new Secret
- if master_key is not None:
- secret = form.save(commit=False)
- secret.plaintext = form.cleaned_data['plaintext']
- secret.encrypt(master_key)
- secret.save()
- messages.success(request, "Modified secret {}.".format(secret))
- return redirect('secrets:secret', pk=secret.pk)
- else:
- form.add_error(None, "Invalid session key. Unable to encrypt secret data.")
-
- # We can't save the plaintext without a session key.
- elif form.cleaned_data['plaintext']:
- form.add_error(None, "No session key was provided with the request. Unable to encrypt secret data.")
-
- # If no new plaintext was specified, a session key is not needed.
- else:
- secret = form.save()
- messages.success(request, "Modified secret {}.".format(secret))
- return redirect('secrets:secret', pk=secret.pk)
-
- else:
- form = forms.SecretForm(instance=secret)
-
- return render(request, 'secrets/secret_edit.html', {
- 'secret': secret,
- 'form': form,
- 'return_url': reverse('secrets:secret', kwargs={'pk': secret.pk}),
- })
+ return render(request, self.template_name, {
+ 'obj': secret,
+ 'obj_type': self.queryset.model._meta.verbose_name,
+ 'form': form,
+ 'return_url': self.get_return_url(request, secret),
+ })
class SecretDeleteView(ObjectDeleteView):
diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html
index cb3935521..6893e2d14 100644
--- a/netbox/templates/secrets/secret_edit.html
+++ b/netbox/templates/secrets/secret_edit.html
@@ -9,7 +9,7 @@
{{ form.private_key }}
-
{% block title %}{% if secret.pk %}Editing {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %}
+
{% block title %}{% if obj.pk %}Editing {{ obj }}{% else %}Add a Secret{% endif %}{% endblock %}
{% if form.non_field_errors %}
Errors
@@ -30,17 +30,17 @@
Secret Data
- {% if secret.pk and secret|decryptable_by:request.user %}
+ {% if obj.pk and obj|decryptable_by:request.user %}
-
********
+
********
-
@@ -69,9 +69,9 @@
- {% if secret.pk %}
+ {% if obj.pk %}
Update
- Cancel
+ Cancel
{% else %}
CreateCreate and Add Another
From 5282ae2250adebf1c1cdb7e3581bc492025ff4d2 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 11:30:46 -0400
Subject: [PATCH 069/300] Enforce object-level permissions for cluster
add/remove devices views
---
netbox/virtualization/views.py | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index e6d4f4946..20cd5e9b1 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -4,7 +4,6 @@ from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
-from django.views.generic import View
from dcim.models import Device, Interface
from dcim.tables import DeviceTable
@@ -137,14 +136,13 @@ class ClusterBulkDeleteView(BulkDeleteView):
default_return_url = 'virtualization:cluster_list'
-class ClusterAddDevicesView(PermissionRequiredMixin, View):
- permission_required = 'virtualization.change_cluster'
+class ClusterAddDevicesView(ObjectEditView):
+ queryset = Cluster.objects.all()
form = forms.ClusterAddDevicesForm
template_name = 'virtualization/cluster_add_devices.html'
def get(self, request, pk):
-
- cluster = get_object_or_404(Cluster, pk=pk)
+ cluster = get_object_or_404(self.queryset, pk=pk)
form = self.form(cluster, initial=request.GET)
return render(request, self.template_name, {
@@ -154,8 +152,7 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
})
def post(self, request, pk):
-
- cluster = get_object_or_404(Cluster, pk=pk)
+ cluster = get_object_or_404(self.queryset, pk=pk)
form = self.form(cluster, request.POST)
if form.is_valid():
@@ -180,14 +177,14 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
})
-class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
- permission_required = 'virtualization.change_cluster'
+class ClusterRemoveDevicesView(ObjectEditView):
+ queryset = Cluster.objects.all()
form = forms.ClusterRemoveDevicesForm
template_name = 'utilities/obj_bulk_remove.html'
def post(self, request, pk):
- cluster = get_object_or_404(Cluster, pk=pk)
+ cluster = get_object_or_404(self.queryset, pk=pk)
if '_confirm' in request.POST:
form = self.form(request.POST)
From 781334b6156df6f55590f2c92ce7253a41fc4281 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 11:51:04 -0400
Subject: [PATCH 070/300] Enforce object-level permissions for
RackElevationListView
---
netbox/dcim/views.py | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 0f5ea01a9..d7e0a336a 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -329,16 +329,15 @@ class RackListView(ObjectListView):
table = tables.RackDetailTable
-class RackElevationListView(PermissionRequiredMixin, View):
+class RackElevationListView(ObjectListView):
"""
Display a set of rack elevations side-by-side.
"""
- permission_required = 'dcim.view_rack'
+ queryset = Rack.objects.prefetch_related('role')
def get(self, request):
- racks = Rack.objects.prefetch_related('role')
- racks = filters.RackFilterSet(request.GET, racks).qs
+ racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count()
# Pagination
From eb9147a5752e7288005a18e195b9ec0c8a2933d6 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 11:52:19 -0400
Subject: [PATCH 071/300] Enforce object-level permissions for DeviceBay
population views
---
netbox/dcim/views.py | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d7e0a336a..733571369 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1647,12 +1647,11 @@ class DeviceBayDeleteView(ObjectDeleteView):
queryset = DeviceBay.objects.all()
-class DeviceBayPopulateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayPopulateView(ObjectEditView):
+ queryset = DeviceBay.objects.all()
def get(self, request, pk):
-
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = forms.PopulateDeviceBayForm(device_bay)
return render(request, 'dcim/devicebay_populate.html', {
@@ -1662,8 +1661,7 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View):
})
def post(self, request, pk):
-
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = forms.PopulateDeviceBayForm(device_bay, request.POST)
if form.is_valid():
@@ -1681,12 +1679,12 @@ class DeviceBayPopulateView(PermissionRequiredMixin, View):
})
-class DeviceBayDepopulateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.change_devicebay'
+class DeviceBayDepopulateView(ObjectEditView):
+ queryset = DeviceBay.objects.all()
def get(self, request, pk):
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm()
return render(request, 'dcim/devicebay_depopulate.html', {
@@ -1697,7 +1695,7 @@ class DeviceBayDepopulateView(PermissionRequiredMixin, View):
def post(self, request, pk):
- device_bay = get_object_or_404(DeviceBay, pk=pk)
+ device_bay = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
From 1bce148be24216374f35fe486057b767a789465e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 11:55:56 -0400
Subject: [PATCH 072/300] Enforce object-level permissions for
ObjectConfigContextView
---
netbox/dcim/views.py | 5 ++---
netbox/extras/views.py | 7 +++----
netbox/virtualization/views.py | 6 ++----
3 files changed, 7 insertions(+), 11 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 733571369..f55d9fd96 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1127,9 +1127,8 @@ class DeviceConfigView(ObjectView):
})
-class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
- permission_required = 'dcim.view_device'
- object_class = Device
+class DeviceConfigContextView(ObjectConfigContextView):
+ queryset = Device.objects.all()
base_template = 'dcim/device.html'
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 78db8f24a..77e5cb0e0 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -154,15 +154,14 @@ class ConfigContextBulkDeleteView(BulkDeleteView):
default_return_url = 'extras:configcontext_list'
-class ObjectConfigContextView(View):
- object_class = None
+class ObjectConfigContextView(ObjectView):
base_template = None
def get(self, request, pk):
- obj = get_object_or_404(self.object_class, pk=pk)
+ obj = get_object_or_404(self.queryset, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj)
- model_name = self.object_class._meta.model_name
+ model_name = self.queryset.model._meta.model_name
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 20cd5e9b1..79a807c21 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -1,5 +1,4 @@
from django.contrib import messages
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render
@@ -246,9 +245,8 @@ class VirtualMachineView(ObjectView):
})
-class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
- permission_required = 'virtualization.view_virtualmachine'
- object_class = VirtualMachine
+class VirtualMachineConfigContextView(ObjectConfigContextView):
+ queryset = VirtualMachine.objects.all()
base_template = 'virtualization/virtualmachine.html'
From 581dc4e0703adc996737bb0e2092623ff386c729 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 12:05:34 -0400
Subject: [PATCH 073/300] Enforce object-level permissions for CableTraceView
---
netbox/dcim/views.py | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index f55d9fd96..3c0010859 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1857,15 +1857,21 @@ class CableView(ObjectView):
})
-class CableTraceView(PermissionRequiredMixin, View):
+class CableTraceView(ObjectPermissionRequiredMixin, View):
"""
Trace a cable path beginning from the given termination.
"""
permission_required = 'dcim.view_cable'
- def get(self, request, model, pk):
+ def dispatch(self, request, *args, **kwargs):
+ model = kwargs.pop('model')
+ self.queryset = model.objects.all()
- obj = get_object_or_404(model, pk=pk)
+ return super().dispatch(request, *args, **kwargs)
+
+ def get(self, request, pk):
+
+ obj = get_object_or_404(self.queryset, pk=pk)
path, split_ends = obj.trace()
total_length = sum(
[entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length]
From 3ef4287d57b95462ff63f2cfb0eb8d0fcf4b8c8a Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 12:41:20 -0400
Subject: [PATCH 074/300] Add additional_permissions to
ObjectPermissionRequiredMixin
---
netbox/dcim/views.py | 12 +++++-------
netbox/ipam/views.py | 4 +---
netbox/utilities/views.py | 16 ++++++++++------
3 files changed, 16 insertions(+), 16 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 3c0010859..2dfe0f207 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1082,7 +1082,7 @@ class DeviceInventoryView(ObjectView):
class DeviceStatusView(ObjectView):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ additional_permissions = ['dcim.napalm_read']
queryset = Device.objects.all()
def get(self, request, pk):
@@ -1096,7 +1096,7 @@ class DeviceStatusView(ObjectView):
class DeviceLLDPNeighborsView(ObjectView):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ additional_permissions = ['dcim.napalm_read']
queryset = Device.objects.all()
def get(self, request, pk):
@@ -1114,7 +1114,7 @@ class DeviceLLDPNeighborsView(ObjectView):
class DeviceConfigView(ObjectView):
- permission_required = ('dcim.view_device', 'dcim.napalm_read')
+ additional_permissions = ['dcim.napalm_read']
queryset = Device.objects.all()
def get(self, request, pk):
@@ -1857,11 +1857,11 @@ class CableView(ObjectView):
})
-class CableTraceView(ObjectPermissionRequiredMixin, View):
+class CableTraceView(ObjectView):
"""
Trace a cable path beginning from the given termination.
"""
- permission_required = 'dcim.view_cable'
+ additional_permissions = ['dcim.view_cable']
def dispatch(self, request, *args, **kwargs):
model = kwargs.pop('model')
@@ -2006,7 +2006,6 @@ class CableBulkDeleteView(BulkDeleteView):
#
class ConsoleConnectionsListView(ObjectListView):
- permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
@@ -2038,7 +2037,6 @@ class ConsoleConnectionsListView(ObjectListView):
class PowerConnectionsListView(ObjectListView):
- permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device'
).filter(
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 14c6a6864..d3b604be6 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -671,7 +671,7 @@ class IPAddressEditView(ObjectEditView):
return obj
-class IPAddressAssignView(ObjectPermissionRequiredMixin, View):
+class IPAddressAssignView(ObjectView):
"""
Search for IPAddresses to be assigned to an Interface.
"""
@@ -719,7 +719,6 @@ class IPAddressDeleteView(ObjectDeleteView):
class IPAddressBulkCreateView(BulkCreateView):
- permission_required = 'ipam.add_ipaddress'
form = forms.IPAddressBulkCreateForm
model_form = forms.IPAddressBulkAddForm
pattern_target = 'address'
@@ -761,7 +760,6 @@ class VLANGroupListView(ObjectListView):
class VLANGroupEditView(ObjectEditView):
- permission_required = 'ipam.add_vlangroup'
queryset = VLANGroup.objects.all()
model_form = forms.VLANGroupForm
default_return_url = 'ipam:vlangroup_list'
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 87f63678a..b586342e1 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -43,18 +43,24 @@ class ObjectPermissionRequiredMixin(AccessMixin):
Similar to Django's built-in PermissionRequiredMixin, but extended to check for both model-level and object-level
permission assignments. If the user has only object-level permissions assigned, the view's queryset is filtered
to return only those objects on which the user is permitted to perform the specified action.
+
+ additional_permissions: An optional iterable of statically declared permissions to evaluate in addition to those
+ derived from the object type
"""
- permission_required = None
+ additional_permissions = list()
def get_required_permission(self):
- return self.permission_required
+ """
+ Return the specific permission necessary to perform the requested action on an object.
+ """
+ raise NotImplementedError(f"{self.__class__.__name__} must implement get_required_permission()")
def has_permission(self):
user = self.request.user
permission_required = self.get_required_permission()
- # First, check that the user is granted the required permission at either the model or object level.
- if not user.has_perm(permission_required):
+ # First, check that the user is granted the required permission(s) at either the model or object level.
+ if not user.has_perms((permission_required, *self.additional_permissions)):
return False
# Superusers implicitly have all permissions
@@ -148,8 +154,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
action_buttons = ('add', 'import', 'export')
def get_required_permission(self):
- if getattr(self, 'permission_required') is not None:
- return self.permission_required
return get_permission_for_model(self.queryset.model, 'view')
def queryset_to_yaml(self):
From ae7445ee8e6651bd0d99521ab6e21c014840ffab Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 14:53:52 -0400
Subject: [PATCH 075/300] Test object permissions for individual/list model
views
---
netbox/utilities/testing/testcases.py | 294 ++++++++++++++++++++------
netbox/utilities/views.py | 1 +
2 files changed, 233 insertions(+), 62 deletions(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index de8b93232..f6b5cdfd4 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -1,3 +1,4 @@
+from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.models import Permission, User
from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
@@ -5,7 +6,8 @@ from django.test import Client, TestCase as _TestCase, override_settings
from django.urls import reverse, NoReverseMatch
from rest_framework.test import APIClient
-from users.models import Token
+from users.models import ObjectPermission, Token
+from utilities.permissions import get_permission_for_model
from .utils import disable_warnings, post_data
@@ -150,19 +152,41 @@ class ViewTestCases:
Retrieve a single instance.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_get_object(self):
+ def test_get_object_without_permission(self):
instance = self.model.objects.first()
- # Attempt to make the request without required permissions
+ # Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403)
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_get_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Add model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'view'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_get_object_with_object_permission(self):
+ instance1, instance2 = self.model.objects.all()[:2]
+
+ # Add object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk': instance1.pk},
+ can_view=True
)
- response = self.client.get(instance.get_absolute_url())
- self.assertHttpStatus(response, 200)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try GET to permitted object
+ self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200)
+
+ # Try GET to non-permitted object
+ self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404)
class CreateObjectViewTestCase(ModelViewTestCase):
"""
@@ -171,33 +195,74 @@ class ViewTestCases:
form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_create_object(self):
+ def test_create_object_without_permission(self):
# Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('add')), 403)
- # Try GET with permission
- self.add_permissions(
- '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(path=self._get_url('add'))
- self.assertHttpStatus(response, 200)
+ # Try POST without permission
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(self.form_data),
+ }
+ response = self.client.post(**request)
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(response, 403)
- # Try POST with permission
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_create_object_with_model_permission(self):
+ initial_count = self.model.objects.count()
+
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'add'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertEqual(initial_count + 1, self.model.objects.count())
+ self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_create_object_with_object_permission(self):
+ initial_count = self.model.objects.count()
+ next_pk = self.model.objects.order_by('pk').last().pk + 1
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk__gt': next_pk},
+ can_add=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try GET with object-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
+
+ # Try to create permitted object
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertEqual(initial_count + 1, self.model.objects.count())
+ self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
+
+ # Try to create a non-permitted object
initial_count = self.model.objects.count()
request = {
'path': self._get_url('add'),
'data': post_data(self.form_data),
- 'follow': False, # Do not follow 302 redirects
}
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
-
- # Validate object creation
- self.assertEqual(initial_count + 1, self.model.objects.count())
- instance = self.model.objects.order_by('-pk').first()
- self.assertInstanceEqual(instance, self.form_data)
+ self.assertHttpStatus(self.client.post(**request), 200)
+ self.assertEqual(initial_count, self.model.objects.count()) # Check that no object was created
class EditObjectViewTestCase(ModelViewTestCase):
"""
@@ -206,80 +271,167 @@ class ViewTestCases:
form_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_edit_object(self):
+ def test_edit_object_without_permission(self):
instance = self.model.objects.first()
# Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403)
- # Try GET with permission
- self.add_permissions(
- '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(path=self._get_url('edit', instance))
- self.assertHttpStatus(response, 200)
-
- # Try POST with permission
+ # Try POST without permission
request = {
'path': self._get_url('edit', instance),
'data': post_data(self.form_data),
- 'follow': False, # Do not follow 302 redirects
}
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.post(**request), 403)
- # Validate object modifications
- instance = self.model.objects.get(pk=instance.pk)
- self.assertInstanceEqual(instance, self.form_data)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_edit_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'change'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('edit', instance),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_edit_object_with_object_permission(self):
+ instance1, instance2 = self.model.objects.all()[:2]
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk': instance1.pk},
+ can_change=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try GET with a permitted object
+ self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200)
+
+ # Try GET with a non-permitted object
+ self.assertHttpStatus(self.client.get(self._get_url('edit', instance2)), 404)
+
+ # Try to edit a permitted object
+ request = {
+ 'path': self._get_url('edit', instance1),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertInstanceEqual(self.model.objects.get(pk=instance1.pk), self.form_data)
+
+ # Try to edit a non-permitted object
+ request = {
+ 'path': self._get_url('edit', instance2),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 404)
class DeleteObjectViewTestCase(ModelViewTestCase):
"""
Delete a single instance.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_delete_object(self):
+ def test_delete_object_without_permission(self):
instance = self.model.objects.first()
- # Try GET without permissions
+ # Try GET without permission
with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403)
-
- # Try GET with permission
- self.add_permissions(
- '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(path=self._get_url('delete', instance))
- self.assertHttpStatus(response, 200)
+ self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403)
+ # Try POST without permission
request = {
'path': self._get_url('delete', instance),
- 'data': {'confirm': True},
- 'follow': False, # Do not follow 302 redirects
+ 'data': post_data({'confirm': True}),
}
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.post(**request), 403)
- # Validate object deletion
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_delete_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'delete'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('delete', instance),
+ 'data': post_data({'confirm': True}),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
with self.assertRaises(ObjectDoesNotExist):
self.model.objects.get(pk=instance.pk)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_delete_object_with_object_permission(self):
+ instance1, instance2 = self.model.objects.all()[:2]
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk': instance1.pk},
+ can_delete=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try GET with a permitted object
+ self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200)
+
+ # Try GET with a non-permitted object
+ self.assertHttpStatus(self.client.get(self._get_url('delete', instance2)), 404)
+
+ # Try to delete a permitted object
+ request = {
+ 'path': self._get_url('delete', instance1),
+ 'data': post_data({'confirm': True}),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ with self.assertRaises(ObjectDoesNotExist):
+ self.model.objects.get(pk=instance1.pk)
+
+ # Try to delete a non-permitted object
+ request = {
+ 'path': self._get_url('delete', instance2),
+ 'data': post_data({'confirm': True}),
+ }
+ self.assertHttpStatus(self.client.post(**request), 404)
+ self.assertTrue(self.model.objects.filter(pk=instance2.pk).exists())
+
class ListObjectsViewTestCase(ModelViewTestCase):
"""
Retrieve multiple instances.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_list_objects(self):
- # Attempt to make the request without required permissions
+ def test_list_objects_without_permission(self):
+
+ # Try GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(self._get_url('list'))
- self.assertHttpStatus(response, 200)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_list_objects_with_model_permission(self):
+
+ # Add model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'view'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
# Built-in CSV export
if hasattr(self.model, 'csv_headers'):
@@ -287,6 +439,24 @@ class ViewTestCases:
self.assertHttpStatus(response, 200)
self.assertEqual(response.get('Content-Type'), 'text/csv')
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_list_objects_with_object_permission(self):
+ instance1, instance2 = self.model.objects.all()[:2]
+
+ # Add object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk': instance1.pk},
+ can_view=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try GET with object-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
+
+ # TODO: Verify that only the permitted object is returned
+
class BulkCreateObjectsViewTestCase(ModelViewTestCase):
"""
Create multiple instances using a single form. Expects the creation of three new instances by default.
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index b586342e1..5bba3fbe9 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -347,6 +347,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
def post(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.views.ObjectEditView')
+ obj = self.alter_obj(self.get_object(kwargs), request, args, kwargs)
form = self.model_form(
data=request.POST,
files=request.FILES,
From 5273b9d0ee2384a70141f39b2fd0ba1243c046d4 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 14:57:35 -0400
Subject: [PATCH 076/300] Rename ImportObjectsViewTestCase
---
netbox/utilities/testing/testcases.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index f6b5cdfd4..1da5e28ac 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -488,7 +488,7 @@ class ViewTestCases:
for instance in self.model.objects.order_by('-pk')[:self.bulk_create_count]:
self.assertInstanceEqual(instance, self.bulk_create_data)
- class ImportObjectsViewTestCase(ModelViewTestCase):
+ class BulkImportObjectsViewTestCase(ModelViewTestCase):
"""
Create multiple instances from imported data.
"""
@@ -598,7 +598,7 @@ class ViewTestCases:
EditObjectViewTestCase,
DeleteObjectViewTestCase,
ListObjectsViewTestCase,
- ImportObjectsViewTestCase,
+ BulkImportObjectsViewTestCase,
BulkEditObjectsViewTestCase,
BulkDeleteObjectsViewTestCase,
):
@@ -611,7 +611,7 @@ class ViewTestCases:
CreateObjectViewTestCase,
EditObjectViewTestCase,
ListObjectsViewTestCase,
- ImportObjectsViewTestCase,
+ BulkImportObjectsViewTestCase,
BulkDeleteObjectsViewTestCase,
):
"""
@@ -636,7 +636,7 @@ class ViewTestCases:
DeleteObjectViewTestCase,
ListObjectsViewTestCase,
BulkCreateObjectsViewTestCase,
- ImportObjectsViewTestCase,
+ BulkImportObjectsViewTestCase,
BulkEditObjectsViewTestCase,
BulkDeleteObjectsViewTestCase,
):
From 77a49fa40e3c8bee8007acbcdbd464f685992114 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 16:04:43 -0400
Subject: [PATCH 077/300] Extend bulk import/edit/delete view tests to support
object-level permissions
---
netbox/utilities/testing/testcases.py | 214 ++++++++++++++++++--------
1 file changed, 152 insertions(+), 62 deletions(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 1da5e28ac..ca9df4ac8 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -232,12 +232,11 @@ class ViewTestCases:
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object_with_object_permission(self):
initial_count = self.model.objects.count()
- next_pk = self.model.objects.order_by('pk').last().pk + 1
# Assign object-level permission
obj_perm = ObjectPermission(
model=ContentType.objects.get_for_model(self.model),
- attrs={'pk__gt': next_pk},
+ attrs={'pk__gt': 0}, # Dummy permission to allow all
can_add=True
)
obj_perm.save()
@@ -255,6 +254,10 @@ class ViewTestCases:
self.assertEqual(initial_count + 1, self.model.objects.count())
self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
+ # Nullify ObjectPermission to disallow new object creation
+ obj_perm.attrs = {'pk': 0}
+ obj_perm.save()
+
# Try to create a non-permitted object
initial_count = self.model.objects.count()
request = {
@@ -470,7 +473,6 @@ class ViewTestCases:
request = {
'path': self._get_url('add'),
'data': post_data(self.bulk_create_data),
- 'follow': False, # Do not follow 302 redirects
}
# Attempt to make the request without required permissions
@@ -494,35 +496,63 @@ class ViewTestCases:
"""
csv_data = ()
+ def _get_csv_data(self):
+ return '\n'.join(self.csv_data)
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_import_objects(self):
+ def test_bulk_import_objects_without_permission(self):
+ data = {
+ 'csv': self._get_csv_data(),
+ }
# Test GET without permission
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('import')), 403)
- # Test GET with permission
- self.add_permissions(
- '{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name),
- '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.get(self._get_url('import'))
- self.assertHttpStatus(response, 200)
+ # Try POST without permission
+ response = self.client.post(self._get_url('import'), data)
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(response, 403)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_import_objects_with_model_permission(self):
+ initial_count = self.model.objects.count()
+ data = {
+ 'csv': self._get_csv_data(),
+ }
+
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'add'))
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
# Test POST with permission
- initial_count = self.model.objects.count()
- request = {
- 'path': self._get_url('import'),
- 'data': {
- 'csv': '\n'.join(self.csv_data)
- }
- }
- response = self.client.post(**request)
- self.assertHttpStatus(response, 200)
-
- # Validate import of new objects
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_import_objects_with_object_permission(self):
+ initial_count = self.model.objects.count()
+ data = {
+ 'csv': self._get_csv_data(),
+ }
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk__gt': 0}, # Dummy permission to allow all
+ can_add=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Test import with object-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+
+ # TODO: Test importing non-permitted objects
+
class BulkEditObjectsViewTestCase(ModelViewTestCase):
"""
Edit multiple instances.
@@ -530,68 +560,128 @@ class ViewTestCases:
bulk_edit_data = {}
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_bulk_edit_objects(self):
- # Bulk edit the first three objects only
+ def test_bulk_edit_objects_without_permission(self):
pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ data = {
+ 'pk': pk_list,
+ '_apply': True, # Form button
+ }
- request = {
- 'path': self._get_url('bulk_edit'),
- 'data': {
- 'pk': pk_list,
- '_apply': True, # Form button
- },
- 'follow': False, # Do not follow 302 redirects
+ # Test GET without permission
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.get(self._get_url('bulk_edit')), 403)
+
+ # Try POST without permission
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403)
+
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_edit_objects_with_model_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ data = {
+ 'pk': pk_list,
+ '_apply': True, # Form button
}
# Append the form data to the request
- request['data'].update(post_data(self.bulk_edit_data))
+ data.update(post_data(self.bulk_edit_data))
- # Attempt to make the request without required permissions
- with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.post(**request), 403)
-
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'change'))
+ # Try POST with model-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
self.assertInstanceEqual(instance, self.bulk_edit_data)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_edit_objects_with_object_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ data = {
+ 'pk': pk_list,
+ '_apply': True, # Form button
+ }
+
+ # Append the form data to the request
+ data.update(post_data(self.bulk_edit_data))
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk__in': list(pk_list)},
+ can_change=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try POST with model-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
+ for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
+ self.assertInstanceEqual(instance, self.bulk_edit_data)
+
+ # TODO: Test editing non-permitted objects
+
class BulkDeleteObjectsViewTestCase(ModelViewTestCase):
"""
Delete multiple instances.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_bulk_delete_objects(self):
- pk_list = self.model.objects.values_list('pk', flat=True)
-
- request = {
- 'path': self._get_url('bulk_delete'),
- 'data': {
- 'pk': pk_list,
- 'confirm': True,
- '_confirm': True, # Form button
- },
- 'follow': False, # Do not follow 302 redirects
+ def test_bulk_delete_objects_without_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
}
- # Attempt to make the request without required permissions
+ # Test GET without permission
with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.post(**request), 403)
+ self.assertHttpStatus(self.client.get(self._get_url('bulk_delete')), 403)
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
- )
- response = self.client.post(**request)
- self.assertHttpStatus(response, 302)
+ # Try POST without permission
+ with disable_warnings('django.request'):
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
- # Check that all objects were deleted
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_delete_objects_with_model_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Assign model-level permission
+ self.add_permissions(get_permission_for_model(self.model, 'delete'))
+
+ # Try POST with model-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
self.assertEqual(self.model.objects.count(), 0)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_delete_objects_with_object_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ attrs={'pk__in': list(pk_list)},
+ can_delete=True
+ )
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
+ # Try POST with object-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
+ self.assertEqual(self.model.objects.count(), 0)
+
+ # TODO: Test deleting non-permitted objects
+
class PrimaryObjectViewTestCase(
GetObjectViewTestCase,
CreateObjectViewTestCase,
From 27700d316f7ae94522c073f4533822003288fd6c Mon Sep 17 00:00:00 2001
From: Sander Steffann
Date: Fri, 22 May 2020 22:24:05 +0200
Subject: [PATCH 078/300] Add `perms` to PluginTemplateExtension context
---
netbox/extras/templatetags/plugins.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py
index b66cce0a6..3f593fa10 100644
--- a/netbox/extras/templatetags/plugins.py
+++ b/netbox/extras/templatetags/plugins.py
@@ -18,6 +18,7 @@ def _get_registered_content(obj, method, template_context):
'object': obj,
'request': template_context['request'],
'settings': template_context['settings'],
+ 'perms': template_context['perms'],
}
model_name = obj._meta.label_lower
From ff3b348771c07802b13c6a813ef6e4aed4e0b46c Mon Sep 17 00:00:00 2001
From: Sander Steffann
Date: Fri, 22 May 2020 22:28:04 +0200
Subject: [PATCH 079/300] Add `csrf_token` to PluginTemplateExtension context
---
netbox/extras/templatetags/plugins.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/netbox/extras/templatetags/plugins.py b/netbox/extras/templatetags/plugins.py
index b66cce0a6..e63d25df1 100644
--- a/netbox/extras/templatetags/plugins.py
+++ b/netbox/extras/templatetags/plugins.py
@@ -18,6 +18,7 @@ def _get_registered_content(obj, method, template_context):
'object': obj,
'request': template_context['request'],
'settings': template_context['settings'],
+ 'csrf_token': template_context['csrf_token'],
}
model_name = obj._meta.label_lower
From 635fefcb5c5bad6a224e62b2a6a47b3bf2561415 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 22 May 2020 16:33:56 -0400
Subject: [PATCH 080/300] Update exempted tests
---
netbox/dcim/tests/test_views.py | 20 +++++++++++++++-----
netbox/extras/tests/test_views.py | 20 +++++++++++++++-----
netbox/ipam/tests/test_views.py | 4 +++-
netbox/secrets/tests/test_views.py | 8 ++++++--
netbox/utilities/testing/testcases.py | 2 +-
netbox/virtualization/tests/test_views.py | 8 ++++++--
6 files changed, 46 insertions(+), 16 deletions(-)
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 65f37c1d5..ef8bd3d5f 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -794,7 +794,9 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
model = DeviceBayTemplate
# Disable inapplicable views
- test_bulk_edit_objects = None
+ test_bulk_edit_objects_without_permission = None
+ test_bulk_edit_objects_with_model_permission = None
+ test_bulk_edit_objects_with_object_permission = None
@classmethod
def setUpTestData(cls):
@@ -1439,7 +1441,9 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Cable
# TODO: Creation URL needs termination context
- test_create_object = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
@classmethod
def setUpTestData(cls):
@@ -1513,11 +1517,17 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
# Disable inapplicable tests
- test_import_objects = None
+ test_bulk_import_objects_without_permission = None
+ test_bulk_import_objects_with_model_permission = None
+ test_bulk_import_objects_with_object_permission = None
# TODO: Requires special form handling
- test_create_object = None
- test_edit_object = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
+ test_edit_object_without_permission = None
+ test_edit_object_with_model_permission = None
+ test_edit_object_with_object_permission = None
@classmethod
def setUpTestData(cls):
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index 370055b26..f52054cc1 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -14,8 +14,12 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Tag
# Disable inapplicable tests
- test_create_object = None
- test_import_objects = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
+ test_bulk_import_objects_without_permission = None
+ test_bulk_import_objects_with_model_permission = None
+ test_bulk_import_objects_with_object_permission = None
@classmethod
def setUpTestData(cls):
@@ -42,11 +46,17 @@ class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = ConfigContext
# Disable inapplicable tests
- test_import_objects = None
+ test_bulk_import_objects_without_permission = None
+ test_bulk_import_objects_with_model_permission = None
+ test_bulk_import_objects_with_object_permission = None
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
- test_create_object = None
- test_edit_object = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
+ test_edit_object_without_permission = None
+ test_edit_object_with_model_permission = None
+ test_edit_object_with_object_permission = None
@classmethod
def setUpTestData(cls):
diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py
index 8867a6b43..bbd252473 100644
--- a/netbox/ipam/tests/test_views.py
+++ b/netbox/ipam/tests/test_views.py
@@ -337,7 +337,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service
# TODO: Resolve URL for Service creation
- test_create_object = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
@classmethod
def setUpTestData(cls):
diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py
index 96439a10d..7796be63d 100644
--- a/netbox/secrets/tests/test_views.py
+++ b/netbox/secrets/tests/test_views.py
@@ -40,10 +40,14 @@ class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Secret
# Disable inapplicable tests
- test_create_object = None
+ test_create_object_without_permission = None
+ test_create_object_with_model_permission = None
+ test_create_object_with_object_permission = None
# TODO: Check permissions enforcement on secrets.views.secret_edit
- test_edit_object = None
+ test_edit_object_without_permission = None
+ test_edit_object_with_model_permission = None
+ test_edit_object_with_object_permission = None
@classmethod
def setUpTestData(cls):
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index ca9df4ac8..475cdb09f 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -351,7 +351,7 @@ class ViewTestCases:
# Try GET without permission
with disable_warnings('django.request'):
- self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403)
+ self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 403)
# Try POST without permission
request = {
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index e7bb19285..006db34d6 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -192,8 +192,12 @@ class InterfaceTestCase(
model = Interface
# Disable inapplicable tests
- test_list_objects = None
- test_import_objects = None
+ test_list_objects_without_permission = None
+ test_list_objects_with_model_permission = None
+ test_list_objects_with_object_permission = None
+ test_bulk_import_objects_without_permission = None
+ test_bulk_import_objects_with_model_permission = None
+ test_bulk_import_objects_with_object_permission = None
def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL
From 74c29b0bb72bd6ba9a2ba1e19f8121da1d0f043e Mon Sep 17 00:00:00 2001
From: kobayashi
Date: Tue, 26 May 2020 01:17:10 -0400
Subject: [PATCH 081/300] Fixes #4684: Fix ignored comment when importing
DeviceType
---
docs/release-notes/version-2.8.md | 1 +
netbox/dcim/forms.py | 1 +
netbox/dcim/tests/test_views.py | 2 ++
3 files changed, 4 insertions(+)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 16712bb79..5507d420e 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -16,6 +16,7 @@
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
+* [#4684](https://github.com/netbox-community/netbox/issues/4684) - Fix ignored comment field when adding device type via YAML/JSON import.
---
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index cdd42ddae..94cf51fcd 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -932,6 +932,7 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
model = DeviceType
fields = [
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
+ 'comments',
]
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 65f37c1d5..7ee5d7845 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -366,6 +366,7 @@ manufacturer: Generic
model: TEST-1000
slug: test-1000
u_height: 2
+comments: test comment
console-ports:
- name: Console Port 1
type: de-9
@@ -456,6 +457,7 @@ device-bays:
self.assertHttpStatus(response, 200)
dt = DeviceType.objects.get(model='TEST-1000')
+ self.assertEqual(dt.comments, 'test comment')
# Verify all of the components were created
self.assertEqual(dt.consoleport_templates.count(), 3)
From 9cde377133af3ac0e61384757733afa4cc09f653 Mon Sep 17 00:00:00 2001
From: kobayashi
Date: Tue, 26 May 2020 01:23:23 -0400
Subject: [PATCH 082/300] Closes #4676: Set default value of
REMOTE_AUTH_AUTO_CREATE_USER as False in docs
---
docs/configuration/optional-settings.md | 2 +-
docs/release-notes/version-2.8.md | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 4d5251f25..617878fbb 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -385,7 +385,7 @@ When remote user authentication is in use, this is the name of the HTTP header w
## REMOTE_AUTH_AUTO_CREATE_USER
-Default: `True`
+Default: `False`
If true, NetBox will automatically create local accounts for users authenticated via a remote service. (Requires `REMOTE_AUTH_ENABLED`.)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 16712bb79..0827fc535 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -16,6 +16,7 @@
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
* [#4648](https://github.com/netbox-community/netbox/issues/4648) - Fix bulk CSV import of child devices
* [#4649](https://github.com/netbox-community/netbox/issues/4649) - Fix interface assignment for bulk-imported IP addresses
+* [#4676](https://github.com/netbox-community/netbox/issues/4676) - Set default value of `REMOTE_AUTH_AUTO_CREATE_USER` as `False` in docs
---
From a5785552d9d832029b288aa22e9d8e09164c955e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 09:05:18 -0400
Subject: [PATCH 083/300] Changelog for #4651, #4652
---
docs/release-notes/version-2.8.md | 2 ++
1 file changed, 2 insertions(+)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 16712bb79..9d3979398 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -6,6 +6,8 @@
### Enhancements
+* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
+* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
### Bug Fixes
From 92f49b471170f2c2c82d831694e9b7db8d6dd24f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 09:36:27 -0400
Subject: [PATCH 084/300] Closes #4672: Set default color for rack and devices
roles
---
docs/release-notes/version-2.8.md | 1 +
netbox/dcim/filters.py | 4 +-
.../migrations/0106_role_default_color.py | 24 +++++++
netbox/dcim/models/__init__.py | 9 ++-
netbox/extras/models/tags.py | 3 +-
netbox/utilities/choices.py | 64 +++++++++++++++++++
netbox/utilities/constants.py | 31 ---------
netbox/utilities/forms.py | 5 +-
8 files changed, 102 insertions(+), 39 deletions(-)
create mode 100644 netbox/dcim/migrations/0106_role_default_color.py
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 720e4b1a7..d761020ad 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -9,6 +9,7 @@
* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
+* [#4672](https://github.com/netbox-community/netbox/issues/4672) - Set default color for rack and devices roles
### Bug Fixes
diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py
index 5bc6dd7f0..8c24180bb 100644
--- a/netbox/dcim/filters.py
+++ b/netbox/dcim/filters.py
@@ -4,7 +4,7 @@ from django.contrib.auth.models import User
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet
from tenancy.filters import TenancyFilterSet
from tenancy.models import Tenant
-from utilities.constants import COLOR_CHOICES
+from utilities.choices import ColorChoices
from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter,
NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter,
@@ -1084,7 +1084,7 @@ class CableFilterSet(BaseFilterSet):
choices=CableStatusChoices
)
color = django_filters.MultipleChoiceFilter(
- choices=COLOR_CHOICES
+ choices=ColorChoices
)
device_id = MultiValueNumberFilter(
method='filter_device'
diff --git a/netbox/dcim/migrations/0106_role_default_color.py b/netbox/dcim/migrations/0106_role_default_color.py
new file mode 100644
index 000000000..c4df1b33f
--- /dev/null
+++ b/netbox/dcim/migrations/0106_role_default_color.py
@@ -0,0 +1,24 @@
+# Generated by Django 3.0.6 on 2020-05-26 13:33
+
+from django.db import migrations
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0105_interface_name_collation'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='devicerole',
+ name='color',
+ field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
+ ),
+ migrations.AlterField(
+ model_name='rackrole',
+ name='color',
+ field=utilities.fields.ColorField(default='9e9e9e', max_length=6),
+ ),
+ ]
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 490667153..1f6478119 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -23,6 +23,7 @@ from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
+from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters
@@ -379,7 +380,9 @@ class RackRole(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
- color = ColorField()
+ color = ColorField(
+ default=ColorChoices.COLOR_GREY
+ )
description = models.CharField(
max_length=200,
blank=True,
@@ -1190,7 +1193,9 @@ class DeviceRole(ChangeLoggedModel):
slug = models.SlugField(
unique=True
)
- color = ColorField()
+ color = ColorField(
+ default=ColorChoices.COLOR_GREY
+ )
vm_role = models.BooleanField(
default=True,
verbose_name='VM Role',
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index 3bad7fa8b..d68ca2ce6 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -3,6 +3,7 @@ from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
+from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel
@@ -13,7 +14,7 @@ from utilities.models import ChangeLoggedModel
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
- default='9e9e9e'
+ default=ColorChoices.COLOR_GREY
)
description = models.CharField(
max_length=200,
diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py
index aba64e63b..ce0929a8b 100644
--- a/netbox/utilities/choices.py
+++ b/netbox/utilities/choices.py
@@ -80,6 +80,70 @@ def unpack_grouped_choices(choices):
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
#
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/forms.py b/netbox/utilities/forms.py
index bfc783631..17ef4dd84 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -14,8 +14,7 @@ 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+)\]'
@@ -163,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'
From 88cffca2705dc3774680a1bc11441491df0d5e3e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 10:01:49 -0400
Subject: [PATCH 085/300] Closes #4650: Expose INTERNAL_IPS configuration
parameter
---
docs/configuration/optional-settings.md | 17 ++++++++++++++++-
docs/release-notes/version-2.8.md | 1 +
netbox/netbox/configuration.example.py | 4 ++++
netbox/netbox/settings.py | 10 +---------
4 files changed, 22 insertions(+), 10 deletions(-)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 617878fbb..3c4392915 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -86,7 +86,12 @@ CORS_ORIGIN_WHITELIST = [
Default: False
-This setting enables debugging. This should be done only during development or troubleshooting. Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
+This setting enables debugging. This should be done only during development or troubleshooting. Note that only clients
+which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
+interface.
+
+!!! warning
+ Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users.
---
@@ -184,6 +189,16 @@ HTTP_PROXIES = {
---
+## INTERNAL_IPS
+
+Default: `('127.0.0.1', '::1',)`
+
+A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
+example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
+addresses (and [`DEBUG`](#debug) is true).
+
+---
+
## LOGGING
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index d761020ad..f28f8af7d 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -6,6 +6,7 @@
### Enhancements
+* [#4650](https://github.com/netbox-community/netbox/issues/4650) - Expose `INTERNAL_IPS` configuration parameter
* [#4651](https://github.com/netbox-community/netbox/issues/4651) - Add `csrf_token` context for plugin templates
* [#4652](https://github.com/netbox-community/netbox/issues/4652) - Add permissions context for plugin templates
* [#4665](https://github.com/netbox-community/netbox/issues/4665) - Add NEMA L14 and L21 power port/outlet types
diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py
index a020c4322..941cbcd88 100644
--- a/netbox/netbox/configuration.example.py
+++ b/netbox/netbox/configuration.example.py
@@ -132,6 +132,10 @@ EXEMPT_VIEW_PERMISSIONS = [
# 'https': 'http://10.10.1.10:1080',
# }
+# IP addresses recognized as internal to the system. The debugging toolbar will be available only to clients accessing
+# NetBox from an internal IP.
+INTERNAL_IPS = ('127.0.0.1', '::1')
+
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
# https://docs.djangoproject.com/en/stable/topics/logging/
LOGGING = {}
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 56fd9bb0f..b1978d749 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -78,6 +78,7 @@ EMAIL = getattr(configuration, 'EMAIL', {})
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
+INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
@@ -615,15 +616,6 @@ RQ_QUEUES = {
'check_releases': RQ_PARAMS,
}
-#
-# Django debug toolbar
-#
-
-INTERNAL_IPS = (
- '127.0.0.1',
- '::1',
-)
-
#
# NetBox internal settings
From e54d44143324067e92460958a8228f75db8cb459 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 10:06:46 -0400
Subject: [PATCH 086/300] Remove "disable plugins" from bug report to prevent
irrelevant search results
---
.github/ISSUE_TEMPLATE/bug_report.md | 7 +++----
1 file changed, 3 insertions(+), 4 deletions(-)
diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md
index e1012212d..54dc5ca8c 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.md
+++ b/.github/ISSUE_TEMPLATE/bug_report.md
@@ -30,10 +30,9 @@ about: Report a reproducible bug in the current release of NetBox
library such as pynetbox.
-->
### Steps to Reproduce
-1. Disable any installed plugins by commenting out the `PLUGINS` setting in
- `configuration.py`.
-2.
-3.
+1.
+2.
+3.
### Expected Behavior
From ccc31b2c7c8a5cdae988af2f569b5b49a1d7a059 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 15:34:29 -0400
Subject: [PATCH 087/300] Fixes #4525: Allow passing initial data to custom
script MultiObjectVar
---
docs/release-notes/version-2.8.md | 1 +
netbox/extras/forms.py | 6 +++---
netbox/utilities/forms.py | 15 ++++++++++++---
3 files changed, 16 insertions(+), 6 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index f28f8af7d..9bae04896 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -15,6 +15,7 @@
### Bug Fixes
* [#3304](https://github.com/netbox-community/netbox/issues/3304) - Fix caching invalidation issue related to device/virtual machine primary IP addresses
+* [#4525](https://github.com/netbox-community/netbox/issues/4525) - Allow passing initial data to custom script MultiObjectVar
* [#4644](https://github.com/netbox-community/netbox/issues/4644) - Fix ordering of services table by parent
* [#4646](https://github.com/netbox-community/netbox/issues/4646) - Correct UI link for reports with custom name
* [#4647](https://github.com/netbox-community/netbox/issues/4647) - Fix caching invalidation issue related to assigning new IP addresses to interfaces
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index 469b55efd..cb9930ae2 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -432,11 +432,11 @@ class ScriptForm(BootstrapMixin, forms.Form):
def __init__(self, vars, *args, commit_default=True, **kwargs):
- super().__init__(*args, **kwargs)
-
# Dynamically populate fields for variables
for name, var in vars.items():
- self.fields[name] = var.as_field()
+ self.base_fields[name] = var.as_field()
+
+ super().__init__(*args, **kwargs)
# Toggle default commit behavior based on Meta option
if not commit_default:
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 17ef4dd84..979b6ac32 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -606,15 +606,18 @@ class DynamicModelChoiceMixin:
filter = django_filters.ModelChoiceFilter
widget = APISelect
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
+ 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)
@@ -647,6 +650,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
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):
"""
From c9a7527f33dfdfb62190e3c41a56b323f95bb95f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 16:17:01 -0400
Subject: [PATCH 088/300] Release v2.8.5
---
docs/release-notes/version-2.8.md | 2 +-
netbox/netbox/settings.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 9bae04896..5ca86217a 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,6 +1,6 @@
# NetBox v2.8
-## v2.8.5 (FUTURE)
+## v2.8.5 (2020-05-26)
**Note:** The minimum required version of PostgreSQL is now 9.6.
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index b1978d749..3b4971ce1 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.5-dev'
+VERSION = '2.8.5'
# Hostname
HOSTNAME = platform.node()
From 56b7ab17340f880fc67b3c0893dba6dc5c4e71f2 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 26 May 2020 16:30:36 -0400
Subject: [PATCH 089/300] Post-release version bump
---
netbox/netbox/settings.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 3b4971ce1..92c4a0cad 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.8.5'
+VERSION = '2.8.6-dev'
# Hostname
HOSTNAME = platform.node()
From 5dddf6846b22ef7d085981c23c560a8c3b9f1b6d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 10:48:56 -0400
Subject: [PATCH 090/300] Disable built-in model permissions
---
netbox/netbox/settings.py | 2 +-
.../users/migrations/0007_objectpermission.py | 8 +-
netbox/users/models.py | 17 +-
netbox/utilities/auth_backends.py | 84 +++---
netbox/utilities/testing/testcases.py | 250 +++++++++---------
netbox/utilities/views.py | 26 +-
6 files changed, 197 insertions(+), 190 deletions(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 266f1afd7..f4ee6fff2 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -339,7 +339,7 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
'utilities.auth_backends.ObjectPermissionBackend',
- REMOTE_AUTH_BACKEND,
+ # REMOTE_AUTH_BACKEND,
]
# Internationalization
diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py
index d805c3379..1fadcc9a5 100644
--- a/netbox/users/migrations/0007_objectpermission.py
+++ b/netbox/users/migrations/0007_objectpermission.py
@@ -1,4 +1,4 @@
-# Generated by Django 3.0.6 on 2020-05-08 20:18
+# Generated by Django 3.0.6 on 2020-05-27 14:17
from django.conf import settings
import django.contrib.postgres.fields.jsonb
@@ -9,9 +9,9 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('auth', '0011_update_proxy_permissions'),
('contenttypes', '0002_remove_content_type_name'),
+ ('auth', '0011_update_proxy_permissions'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('users', '0006_create_userconfigs'),
]
@@ -20,7 +20,7 @@ class Migration(migrations.Migration):
name='ObjectPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
- ('attrs', django.contrib.postgres.fields.jsonb.JSONField()),
+ ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('can_view', models.BooleanField(default=False)),
('can_add', models.BooleanField(default=False)),
('can_change', models.BooleanField(default=False)),
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 70e7254e6..b9ab6cbb5 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -240,6 +240,8 @@ class ObjectPermission(models.Model):
on_delete=models.CASCADE
)
attrs = JSONField(
+ blank=True,
+ null=True,
verbose_name='Attributes'
)
can_view = models.BooleanField(
@@ -264,10 +266,11 @@ class ObjectPermission(models.Model):
# Validate the specified model attributes by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified attributes are valid.
- model = self.model.model_class()
- try:
- model.objects.filter(**self.attrs).exists()
- except FieldError as e:
- raise ValidationError({
- 'attrs': f'Invalid attributes for {model}: {e}'
- })
+ if self.attrs:
+ model = self.model.model_class()
+ try:
+ model.objects.filter(**self.attrs).exists()
+ except FieldError as e:
+ raise ValidationError({
+ 'attrs': f'Invalid attributes for {model}: {e}'
+ })
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index e540a04e0..8cf8b621c 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -12,43 +12,53 @@ class ObjectPermissionBackend(ModelBackend):
def get_object_permissions(self, user_obj):
"""
- Return all model-level permissions granted to the user by an ObjectPermission.
+ Return all permissions granted to the user by an ObjectPermission.
"""
if not hasattr(user_obj, '_object_perm_cache'):
- # Cache all assigned ObjectPermissions on the User instance
- perms = set()
- for obj_perm in ObjectPermission.objects.filter(
+ # Retrieve all assigned ObjectPermissions
+ object_permissions = ObjectPermission.objects.filter(
Q(users=user_obj) |
Q(groups__user=user_obj)
- ).prefetch_related('model'):
+ ).prefetch_related('model')
+
+ # Create a dictionary mapping permissions to their attributes
+ perms = dict()
+ for obj_perm in object_permissions:
for action in ['view', 'add', 'change', 'delete']:
if getattr(obj_perm, f"can_{action}"):
- perms.add(f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}")
+ perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}"
+ if perm_name in perms:
+ perms[perm_name].append(obj_perm.attrs)
+ else:
+ perms[perm_name] = [obj_perm.attrs]
+
+ # Cache resolved permissions on the User instance
setattr(user_obj, '_object_perm_cache', perms)
return user_obj._object_perm_cache
- def get_all_permissions(self, user_obj, obj=None):
-
- # Handle inactive/anonymous users
- if not user_obj.is_active or user_obj.is_anonymous:
- return set()
-
- # Cache model-level permissions on the User instance
- if not hasattr(user_obj, '_perm_cache'):
- user_obj._perm_cache = {
- *self.get_user_permissions(user_obj, obj=obj),
- *self.get_group_permissions(user_obj, obj=obj),
- *self.get_object_permissions(user_obj)
- }
-
- return user_obj._perm_cache
+ # def get_all_permissions(self, user_obj, obj=None):
+ #
+ # # Handle inactive/anonymous users
+ # if not user_obj.is_active or user_obj.is_anonymous:
+ # return set()
+ #
+ # # Cache object permissions on the User instance
+ # if not hasattr(user_obj, '_perm_cache'):
+ # user_obj._perm_cache = self.get_object_permissions(user_obj)
+ #
+ # return user_obj._perm_cache
def has_perm(self, user_obj, perm, obj=None):
+ # print(f'has_perm({perm})')
app_label, codename = perm.split('.')
action, model_name = codename.split('_')
+ # Superusers implicitly have all permissions
+ if user_obj.is_active and user_obj.is_superuser:
+ return True
+
# If this is a view permission, check whether the model has been exempted from enforcement
if action == 'view':
if (
@@ -60,29 +70,29 @@ class ObjectPermissionBackend(ModelBackend):
):
return True
- # If no object is specified, evaluate model-level permissions. The presence of a permission in this set tells
- # us that the user has permission for *some* objects, but not necessarily a specific object.
+ # Handle inactive/anonymous users
+ if not user_obj.is_active or user_obj.is_anonymous:
+ return False
+
+ # If no applicable ObjectPermissions have been created for this user/permission, deny permission
+ if perm not in self.get_object_permissions(user_obj):
+ return False
+
+ # If no object has been specified, grant permission. (The presence of a permission in this set tells
+ # us that the user has permission for *some* objects, but not necessarily a specific object.)
if obj is None:
- return perm in self.get_all_permissions(user_obj)
+ return True
# Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.model
if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}")
- # If the user has been granted model-level permission for the object, return True
- model_perms = {
- *self.get_user_permissions(user_obj),
- *self.get_group_permissions(user_obj),
- }
- if perm in model_perms:
- return True
-
- # Gather all ObjectPermissions pertinent to the requested permission. If none are found, the User has no
- # applicable permissions.
- attrs = ObjectPermission.objects.get_attr_constraints(user_obj, perm)
- if not attrs:
- return False
+ # Compile a query filter that matches all instances of the specified model
+ obj_perm_attrs = self.get_object_permissions(user_obj)[perm]
+ attrs = Q()
+ for perm_attrs in obj_perm_attrs:
+ attrs |= Q(**perm_attrs.attrs)
# Permission to perform the requested action on the object depends on whether the specified object matches
# the specified attributes. Note that this check is made against the *database* record representing the object,
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 6f878986b..3d0ad1ef3 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -159,15 +159,15 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_get_object_with_model_permission(self):
- instance = self.model.objects.first()
-
- # Add model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'view'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_get_object_with_model_permission(self):
+ # instance = self.model.objects.first()
+ #
+ # # Add model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'view'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object_with_object_permission(self):
@@ -217,24 +217,24 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(response, 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_create_object_with_model_permission(self):
- initial_count = self.model.objects.count()
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'add'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
-
- # Try POST with model-level permission
- request = {
- 'path': self._get_url('add'),
- 'data': post_data(self.form_data),
- }
- self.assertHttpStatus(self.client.post(**request), 302)
- self.assertEqual(initial_count + 1, self.model.objects.count())
- self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_create_object_with_model_permission(self):
+ # initial_count = self.model.objects.count()
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'add'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
+ #
+ # # Try POST with model-level permission
+ # request = {
+ # 'path': self._get_url('add'),
+ # 'data': post_data(self.form_data),
+ # }
+ # self.assertHttpStatus(self.client.post(**request), 302)
+ # self.assertEqual(initial_count + 1, self.model.objects.count())
+ # self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object_with_object_permission(self):
@@ -296,23 +296,23 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_edit_object_with_model_permission(self):
- instance = self.model.objects.first()
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'change'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
-
- # Try POST with model-level permission
- request = {
- 'path': self._get_url('edit', instance),
- 'data': post_data(self.form_data),
- }
- self.assertHttpStatus(self.client.post(**request), 302)
- self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_edit_object_with_model_permission(self):
+ # instance = self.model.objects.first()
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'change'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
+ #
+ # # Try POST with model-level permission
+ # request = {
+ # 'path': self._get_url('edit', instance),
+ # 'data': post_data(self.form_data),
+ # }
+ # self.assertHttpStatus(self.client.post(**request), 302)
+ # self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object_with_object_permission(self):
@@ -368,24 +368,24 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_delete_object_with_model_permission(self):
- instance = self.model.objects.first()
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'delete'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
-
- # Try POST with model-level permission
- request = {
- 'path': self._get_url('delete', instance),
- 'data': post_data({'confirm': True}),
- }
- self.assertHttpStatus(self.client.post(**request), 302)
- with self.assertRaises(ObjectDoesNotExist):
- self.model.objects.get(pk=instance.pk)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_delete_object_with_model_permission(self):
+ # instance = self.model.objects.first()
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'delete'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
+ #
+ # # Try POST with model-level permission
+ # request = {
+ # 'path': self._get_url('delete', instance),
+ # 'data': post_data({'confirm': True}),
+ # }
+ # self.assertHttpStatus(self.client.post(**request), 302)
+ # with self.assertRaises(ObjectDoesNotExist):
+ # self.model.objects.get(pk=instance.pk)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object_with_object_permission(self):
@@ -434,20 +434,20 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_list_objects_with_model_permission(self):
-
- # Add model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'view'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
-
- # Built-in CSV export
- if hasattr(self.model, 'csv_headers'):
- response = self.client.get('{}?export'.format(self._get_url('list')))
- self.assertHttpStatus(response, 200)
- self.assertEqual(response.get('Content-Type'), 'text/csv')
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_list_objects_with_model_permission(self):
+ #
+ # # Add model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'view'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
+ #
+ # # Built-in CSV export
+ # if hasattr(self.model, 'csv_headers'):
+ # response = self.client.get('{}?export'.format(self._get_url('list')))
+ # self.assertHttpStatus(response, 200)
+ # self.assertEqual(response.get('Content-Type'), 'text/csv')
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_with_object_permission(self):
@@ -528,22 +528,22 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(response, 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_bulk_import_objects_with_model_permission(self):
- initial_count = self.model.objects.count()
- data = {
- 'csv': self._get_csv_data(),
- }
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'add'))
-
- # Try GET with model-level permission
- self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
-
- # Test POST with permission
- self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
- self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_bulk_import_objects_with_model_permission(self):
+ # initial_count = self.model.objects.count()
+ # data = {
+ # 'csv': self._get_csv_data(),
+ # }
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'add'))
+ #
+ # # Try GET with model-level permission
+ # self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+ #
+ # # Test POST with permission
+ # self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ # self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_import_objects_with_object_permission(self):
@@ -589,24 +589,24 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_bulk_edit_objects_with_model_permission(self):
- pk_list = self.model.objects.values_list('pk', flat=True)[:3]
- data = {
- 'pk': pk_list,
- '_apply': True, # Form button
- }
-
- # Append the form data to the request
- data.update(post_data(self.bulk_edit_data))
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'change'))
-
- # Try POST with model-level permission
- self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
- for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
- self.assertInstanceEqual(instance, self.bulk_edit_data)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_bulk_edit_objects_with_model_permission(self):
+ # pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ # data = {
+ # 'pk': pk_list,
+ # '_apply': True, # Form button
+ # }
+ #
+ # # Append the form data to the request
+ # data.update(post_data(self.bulk_edit_data))
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'change'))
+ #
+ # # Try POST with model-level permission
+ # self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
+ # for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
+ # self.assertInstanceEqual(instance, self.bulk_edit_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects_with_object_permission(self):
@@ -656,21 +656,21 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_bulk_delete_objects_with_model_permission(self):
- pk_list = self.model.objects.values_list('pk', flat=True)
- data = {
- 'pk': pk_list,
- 'confirm': True,
- '_confirm': True, # Form button
- }
-
- # Assign model-level permission
- self.add_permissions(get_permission_for_model(self.model, 'delete'))
-
- # Try POST with model-level permission
- self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
- self.assertEqual(self.model.objects.count(), 0)
+ # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ # def test_bulk_delete_objects_with_model_permission(self):
+ # pk_list = self.model.objects.values_list('pk', flat=True)
+ # data = {
+ # 'pk': pk_list,
+ # 'confirm': True,
+ # '_confirm': True, # Form button
+ # }
+ #
+ # # Assign model-level permission
+ # self.add_permissions(get_permission_for_model(self.model, 'delete'))
+ #
+ # # Try POST with model-level permission
+ # self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
+ # self.assertEqual(self.model.objects.count(), 0)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects_with_object_permission(self):
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index cbedecd4d..6e93c2369 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
-from django.db.models import ManyToManyField, ProtectedError
+from django.db.models import ManyToManyField, ProtectedError, Q
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponse, HttpResponseServerError
from django.shortcuts import get_object_or_404, redirect, render
@@ -65,22 +65,16 @@ class ObjectPermissionRequiredMixin(AccessMixin):
if not user.has_perms((permission_required, *self.additional_permissions)):
return False
- # Superusers implicitly have all permissions
- if user.is_superuser:
- return True
-
- # Determine whether the permission is model-level or object-level. Model-level permissions grant the
- # specified action to *all* objects, so no further action is needed.
- if permission_required in {*user._user_perm_cache, *user._group_perm_cache}:
- return True
-
- # If the permission is granted only at the object level, filter the view's queryset to return only objects
- # on which the user is permitted to perform the specified action.
- attrs = ObjectPermission.objects.get_attr_constraints(user, permission_required)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
+ # Update the view's QuerySet to filter only the permitted objects
+ if user.is_authenticated:
+ obj_perm_attrs = user._object_perm_cache[permission_required]
+ attrs = Q()
+ for perm_attrs in obj_perm_attrs:
+ if perm_attrs:
+ attrs |= Q(**perm_attrs)
self.queryset = self.queryset.filter(attrs)
- return True
+
+ return True
def dispatch(self, request, *args, **kwargs):
From 4cee506710fd9862542044bbac5fd8198482b104 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 10:52:59 -0400
Subject: [PATCH 091/300] Rebase RemoteUserBackend on BaseBackend
---
netbox/netbox/settings.py | 2 +-
netbox/utilities/auth_backends.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index f4ee6fff2..266f1afd7 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -339,7 +339,7 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
'utilities.auth_backends.ObjectPermissionBackend',
- # REMOTE_AUTH_BACKEND,
+ REMOTE_AUTH_BACKEND,
]
# Internationalization
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 8cf8b621c..3d5ec1830 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -1,7 +1,7 @@
import logging
from django.conf import settings
-from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as RemoteUserBackend_
+from django.contrib.auth.backends import BaseBackend, ModelBackend
from django.contrib.auth.models import Group, Permission
from django.db.models import Q
@@ -100,7 +100,7 @@ class ObjectPermissionBackend(ModelBackend):
return model.objects.filter(attrs, pk=obj.pk).exists()
-class RemoteUserBackend(RemoteUserBackend_):
+class RemoteUserBackend(BaseBackend):
"""
Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
"""
From a6a88a0d2ead5f011bce4ce2a0f73dc0e0d50244 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 11:30:36 -0400
Subject: [PATCH 092/300] Delete extraneous test case
---
netbox/users/tests/test_permissions.py | 62 --------------------------
1 file changed, 62 deletions(-)
delete mode 100644 netbox/users/tests/test_permissions.py
diff --git a/netbox/users/tests/test_permissions.py b/netbox/users/tests/test_permissions.py
deleted file mode 100644
index 487543bd3..000000000
--- a/netbox/users/tests/test_permissions.py
+++ /dev/null
@@ -1,62 +0,0 @@
-from django.contrib.contenttypes.models import ContentType
-from django.contrib.auth.models import User
-from django.test import TestCase, override_settings
-
-from dcim.models import Site
-from tenancy.models import Tenant
-from users.models import ObjectPermission
-
-
-class ObjectPermissionTest(TestCase):
-
- def setUp(self):
-
- self.user = User.objects.create_user(username='testuser')
-
- @classmethod
- def setUpTestData(cls):
-
- tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
- Site.objects.bulk_create((
- Site(name='Site 1', slug='site-1'),
- Site(name='Site 2', slug='site-2', tenant=tenant),
- Site(name='Site 3', slug='site-3'),
- ))
-
- @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- def test_permission_view_object(self):
-
- # Sanity check to ensure the user has no model-level permission
- self.assertFalse(self.user.has_perm('dcim.view_site'))
-
- # The permission check for a specific object should fail.
- sites = Site.objects.all()
- self.assertFalse(self.user.has_perm('dcim.view_site', sites[0]))
-
- # Create and assign a new ObjectPermission specifying the first site by name.
- ct = ContentType.objects.get_for_model(sites[0])
- object_perm = ObjectPermission(
- model=ct,
- attrs={'name': 'Site 1'},
- can_view=True
- )
- object_perm.save()
- object_perm.users.add(self.user)
-
- # The test user should have permission to view only the first site.
- self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
- self.assertFalse(self.user.has_perm('dcim.view_site', sites[1]))
-
- # Create a second ObjectPermission matching sites by assigned tenant.
- object_perm = ObjectPermission(
- model=ct,
- attrs={'tenant__name': 'Tenant 1'},
- can_view=True
- )
- object_perm.save()
- object_perm.users.add(self.user)
-
- # The user should now able to view the first two sites, but not the third.
- self.assertTrue(self.user.has_perm('dcim.view_site', sites[0]))
- self.assertTrue(self.user.has_perm('dcim.view_site', sites[1]))
- self.assertFalse(self.user.has_perm('dcim.view_site', sites[2]))
From fb7446487e7e54db8a8feac14f4e69c506fd22e7 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 11:31:07 -0400
Subject: [PATCH 093/300] Fix up permissions evaluation
---
netbox/utilities/api.py | 11 +++++------
netbox/utilities/auth_backends.py | 14 +-------------
2 files changed, 6 insertions(+), 19 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 745f812ff..2d7ae2385 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -340,12 +340,11 @@ class ModelViewSet(_ModelViewSet):
permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
# Enforce object-level permissions
- if permission_required not in {*request.user._user_perm_cache, *request.user._group_perm_cache}:
- attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(attrs)
- return True
+ attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required)
+ if attrs:
+ # Update the view's QuerySet to filter only the permitted objects
+ self.queryset = self.queryset.filter(attrs)
+ return True
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 3d5ec1830..bcf2fa119 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -38,18 +38,6 @@ class ObjectPermissionBackend(ModelBackend):
return user_obj._object_perm_cache
- # def get_all_permissions(self, user_obj, obj=None):
- #
- # # Handle inactive/anonymous users
- # if not user_obj.is_active or user_obj.is_anonymous:
- # return set()
- #
- # # Cache object permissions on the User instance
- # if not hasattr(user_obj, '_perm_cache'):
- # user_obj._perm_cache = self.get_object_permissions(user_obj)
- #
- # return user_obj._perm_cache
-
def has_perm(self, user_obj, perm, obj=None):
# print(f'has_perm({perm})')
app_label, codename = perm.split('.')
@@ -92,7 +80,7 @@ class ObjectPermissionBackend(ModelBackend):
obj_perm_attrs = self.get_object_permissions(user_obj)[perm]
attrs = Q()
for perm_attrs in obj_perm_attrs:
- attrs |= Q(**perm_attrs.attrs)
+ attrs |= Q(**perm_attrs)
# Permission to perform the requested action on the object depends on whether the specified object matches
# the specified attributes. Note that this check is made against the *database* record representing the object,
From ce46512c74f6e27cd73213bcd85310c5e437d390 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 16:53:30 -0400
Subject: [PATCH 094/300] Fix permission assignment in tests
---
netbox/extras/tests/test_customfields.py | 18 ++++--------
netbox/netbox/settings.py | 2 +-
netbox/utilities/auth_backends.py | 8 ++++--
netbox/utilities/testing/testcases.py | 35 ++++++++++++++++++------
4 files changed, 38 insertions(+), 25 deletions(-)
diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py
index c94d8cd3f..4df06e12f 100644
--- a/netbox/extras/tests/test_customfields.py
+++ b/netbox/extras/tests/test_customfields.py
@@ -1,7 +1,6 @@
from datetime import date
from django.contrib.contenttypes.models import ContentType
-from django.test import Client, TestCase
from django.urls import reverse
from rest_framework import status
@@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
-from utilities.testing import APITestCase, create_test_user
+from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@@ -470,17 +469,10 @@ class CustomFieldChoiceAPITest(APITestCase):
class CustomFieldImportTest(TestCase):
-
- def setUp(self):
-
- user = create_test_user(
- permissions=[
- 'dcim.view_site',
- 'dcim.add_site',
- ]
- )
- self.client = Client()
- self.client.force_login(user)
+ user_permissions = (
+ 'dcim.view_site',
+ 'dcim.add_site',
+ )
@classmethod
def setUpTestData(cls):
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 266f1afd7..3b345638b 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -338,8 +338,8 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
- 'utilities.auth_backends.ObjectPermissionBackend',
REMOTE_AUTH_BACKEND,
+ 'utilities.auth_backends.ObjectPermissionBackend',
]
# Internationalization
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index bcf2fa119..41d7033af 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -1,7 +1,7 @@
import logging
from django.conf import settings
-from django.contrib.auth.backends import BaseBackend, ModelBackend
+from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group, Permission
from django.db.models import Q
@@ -88,7 +88,7 @@ class ObjectPermissionBackend(ModelBackend):
return model.objects.filter(attrs, pk=obj.pk).exists()
-class RemoteUserBackend(BaseBackend):
+class RemoteUserBackend(_RemoteUserBackend):
"""
Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
"""
@@ -124,7 +124,11 @@ class RemoteUserBackend(BaseBackend):
"._. (Example: dcim.add_site)"
)
if permissions_list:
+ # TODO: Create an ObjectPermission
user.user_permissions.add(*permissions_list)
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
return user
+
+ def has_perm(self, user_obj, perm, obj=None):
+ return False
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 3d0ad1ef3..8346f5d04 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -33,18 +33,31 @@ class TestCase(_TestCase):
Assign a set of permissions to the test user. Accepts permission names in the form ._.
"""
for name in names:
- app, codename = name.split('.')
- perm = Permission.objects.get(content_type__app_label=app, codename=codename)
- self.user.user_permissions.add(perm)
+ app_label, codename = name.split('.')
+ action, model_name = codename.split('_')
+
+ kwargs = {
+ 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ f'can_{action}': True
+ }
+ obj_perm = ObjectPermission(**kwargs)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
def remove_permissions(self, *names):
"""
Remove a set of permissions from the test user, if assigned.
"""
for name in names:
- app, codename = name.split('.')
- perm = Permission.objects.get(content_type__app_label=app, codename=codename)
- self.user.user_permissions.remove(perm)
+ app_label, codename = name.split('.')
+ action, model_name = codename.split('_')
+
+ kwargs = {
+ 'user': self.user,
+ 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ f'can_{action}': True
+ }
+ ObjectPermission.objects.filter(**kwargs).delete()
#
# Convenience methods
@@ -493,10 +506,14 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403)
- # Assign the required permission and submit again
- self.add_permissions(
- '{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
+ # Assign object-level permission
+ obj_perm = ObjectPermission(
+ model=ContentType.objects.get_for_model(self.model),
+ can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
From a261d10bfd0440a68b68f811eebddc07641f6d1b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 27 May 2020 17:10:45 -0400
Subject: [PATCH 095/300] Fix permissions assignment for SecretTest
---
netbox/secrets/tests/test_api.py | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py
index 339c370d8..c21ac9d72 100644
--- a/netbox/secrets/tests/test_api.py
+++ b/netbox/secrets/tests/test_api.py
@@ -122,18 +122,15 @@ class SecretRoleTest(APITestCase):
class SecretTest(APITestCase):
+ user_permissions = (
+ 'secrets.add_secret',
+ 'secrets.change_secret',
+ 'secrets.delete_secret',
+ 'secrets.view_secret',
+ )
def setUp(self):
-
- # Create a non-superuser test user
- self.user = create_test_user('testuser', permissions=(
- 'secrets.add_secret',
- 'secrets.change_secret',
- 'secrets.delete_secret',
- 'secrets.view_secret',
- ))
- self.token = Token.objects.create(user=self.user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
+ super().setUp()
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
From 814aff78b580a14c13df4e2ee58df3c7e7495576 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 09:39:27 -0400
Subject: [PATCH 096/300] Update ObjectPermission evaluation to support null
attrs
---
netbox/utilities/api.py | 15 ++++++++-------
netbox/utilities/auth_backends.py | 7 ++++++-
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 2d7ae2385..41002dd20 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied
from django.db import transaction
-from django.db.models import ManyToManyField, ProtectedError
+from django.db.models import ManyToManyField, ProtectedError, Q
from django.urls import reverse
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
@@ -339,12 +339,13 @@ class ModelViewSet(_ModelViewSet):
}
permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
- # Enforce object-level permissions
- attrs = ObjectPermission.objects.get_attr_constraints(request.user, permission_required)
- if attrs:
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = self.queryset.filter(attrs)
- return True
+ # Update the view's QuerySet to filter only the permitted objects
+ obj_perm_attrs = request.user._object_perm_cache[permission_required]
+ attrs = Q()
+ for perm_attrs in obj_perm_attrs:
+ if perm_attrs:
+ attrs |= Q(**perm_attrs)
+ self.queryset = self.queryset.filter(attrs)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 41d7033af..6d34678be 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -80,7 +80,12 @@ class ObjectPermissionBackend(ModelBackend):
obj_perm_attrs = self.get_object_permissions(user_obj)[perm]
attrs = Q()
for perm_attrs in obj_perm_attrs:
- attrs |= Q(**perm_attrs)
+ if perm_attrs:
+ attrs |= Q(**perm_attrs)
+ else:
+ # Found ObjectPermission with null attrs; allow model-level access
+ attrs = Q()
+ break
# Permission to perform the requested action on the object depends on whether the specified object matches
# the specified attributes. Note that this check is made against the *database* record representing the object,
From 00ce3588d3f9e8b74e2564879256b5952ebfdcec Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 09:51:02 -0400
Subject: [PATCH 097/300] Fix secrets API tests
---
netbox/secrets/models.py | 1 -
netbox/secrets/tests/test_api.py | 42 ++++++++++++++++++++------------
2 files changed, 27 insertions(+), 16 deletions(-)
diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py
index 830e91096..61d8adb6b 100644
--- a/netbox/secrets/models.py
+++ b/netbox/secrets/models.py
@@ -1,5 +1,4 @@
import os
-import sys
from Crypto.Cipher import AES
from Crypto.PublicKey import RSA
diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py
index c21ac9d72..8d716a465 100644
--- a/netbox/secrets/tests/test_api.py
+++ b/netbox/secrets/tests/test_api.py
@@ -5,8 +5,7 @@ from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole, SessionKey, UserKey
-from users.models import Token
-from utilities.testing import APITestCase, create_test_user
+from utilities.testing import APITestCase
from .constants import PRIVATE_KEY, PUBLIC_KEY
@@ -122,16 +121,19 @@ class SecretRoleTest(APITestCase):
class SecretTest(APITestCase):
- user_permissions = (
- 'secrets.add_secret',
- 'secrets.change_secret',
- 'secrets.delete_secret',
- 'secrets.view_secret',
- )
def setUp(self):
super().setUp()
+ self.user.is_superuser = False
+ self.user.save()
+ self.add_permissions(
+ 'secrets.add_secret',
+ 'secrets.change_secret',
+ 'secrets.delete_secret',
+ 'secrets.view_secret',
+ )
+
userkey = UserKey(user=self.user, public_key=PUBLIC_KEY)
userkey.save()
self.master_key = userkey.get_master_key(PRIVATE_KEY)
@@ -175,24 +177,25 @@ class SecretTest(APITestCase):
self.secret3.save()
def test_get_secret(self):
-
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
- # Secret plaintext not be decrypted as the user has not been assigned to the role
+ # Secret plaintext should not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertIsNone(response.data['plaintext'])
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['plaintext'], self.plaintexts[0])
def test_list_secrets(self):
-
url = reverse('secrets-api:secret-list')
- # Secret plaintext not be decrypted as the user has not been assigned to the role
+ # Secret plaintext should not be decrypted as the user has not been assigned to the role
response = self.client.get(url, **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3)
for secret in response.data['results']:
self.assertIsNone(secret['plaintext'])
@@ -200,12 +203,12 @@ class SecretTest(APITestCase):
# The plaintext should be present once the user has been assigned to the role
self.secretrole1.users.add(self.user)
response = self.client.get(url, **self.header)
+ self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 3)
for i, secret in enumerate(response.data['results']):
self.assertEqual(secret['plaintext'], self.plaintexts[i])
def test_create_secret(self):
-
data = {
'device': self.device.pk,
'role': self.secretrole1.pk,
@@ -213,6 +216,9 @@ class SecretTest(APITestCase):
'plaintext': 'Secret #4 Plaintext',
}
+ # Assign test user to secret role
+ self.secretrole1.users.add(self.user)
+
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -225,7 +231,6 @@ class SecretTest(APITestCase):
self.assertEqual(secret4.plaintext, data['plaintext'])
def test_create_secret_bulk(self):
-
data = [
{
'device': self.device.pk,
@@ -247,6 +252,9 @@ class SecretTest(APITestCase):
},
]
+ # Assign test user to secret role
+ self.secretrole1.users.add(self.user)
+
url = reverse('secrets-api:secret-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -257,13 +265,15 @@ class SecretTest(APITestCase):
self.assertEqual(response.data[2]['plaintext'], data[2]['plaintext'])
def test_update_secret(self):
-
data = {
'device': self.device.pk,
'role': self.secretrole2.pk,
'plaintext': 'NewPlaintext',
}
+ # Assign test user to secret role
+ self.secretrole1.users.add(self.user)
+
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.put(url, data, format='json', **self.header)
@@ -276,6 +286,8 @@ class SecretTest(APITestCase):
self.assertEqual(secret1.plaintext, data['plaintext'])
def test_delete_secret(self):
+ # Assign test user to secret role
+ self.secretrole1.users.add(self.user)
url = reverse('secrets-api:secret-detail', kwargs={'pk': self.secret1.pk})
response = self.client.delete(url, **self.header)
From b2ba9d68c9b82e7dd0869a5641e76c251be69ded Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 10:04:19 -0400
Subject: [PATCH 098/300] Fix default permissions assignment under
RemoteUserBackend
---
netbox/utilities/auth_backends.py | 22 ++++++++++++++--------
1 file changed, 14 insertions(+), 8 deletions(-)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 6d34678be..99e4f559a 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -2,7 +2,8 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
-from django.contrib.auth.models import Group, Permission
+from django.contrib.auth.models import Group
+from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from users.models import ObjectPermission
@@ -115,22 +116,27 @@ class RemoteUserBackend(_RemoteUserBackend):
user.groups.add(*group_list)
logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}")
- # Assign default permissions to the user
+ # Assign default object 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):
+ action, model_name = codename.split('_')
+
+ kwargs = {
+ 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ f'can_{action}': True
+ }
+ obj_perm = ObjectPermission(**kwargs)
+ obj_perm.save()
+ obj_perm.users.add(user)
+ permissions_list.append(permission_name)
+ except ValueError:
logging.error(
"Invalid permission name: '{permission_name}'. Permissions must be in the form "
"._. (Example: dcim.add_site)"
)
if permissions_list:
- # TODO: Create an ObjectPermission
- user.user_permissions.add(*permissions_list)
logger.debug(f"Assigned permissions to remotely-authenticated user {user}: {permissions_list}")
return user
From ca199cdefe9acd8ecfb7b266ccf45566fef6ea84 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 10:27:25 -0400
Subject: [PATCH 099/300] Reduce ObjectPermission creation boilerplate
---
netbox/netbox/tests/test_authentication.py | 60 ++++++----------------
netbox/utilities/auth_backends.py | 8 +--
netbox/utilities/testing/testcases.py | 16 ++----
3 files changed, 22 insertions(+), 62 deletions(-)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 39e82df61..74f4c411a 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -201,13 +201,11 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Retrieve permitted object
response = self.client.get(self.prefixes[0].get_absolute_url())
@@ -225,13 +223,11 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(reverse('ipam:prefix_list'))
@@ -259,14 +255,12 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True,
can_add=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to create a non-permitted object
request = {
@@ -307,14 +301,12 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True,
can_change=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to edit a non-permitted object
request = {
@@ -351,14 +343,12 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True,
can_delete=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Delete permitted object
request = {
@@ -400,13 +390,11 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_add=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to create non-permitted objects
request = {
@@ -449,13 +437,11 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_change=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to edit non-permitted objects
request = {
@@ -493,14 +479,12 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_view=True,
can_delete=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to delete non-permitted object
request = {
@@ -565,15 +549,11 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Retrieve permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
@@ -594,15 +574,11 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
- attrs={
- 'site__name': 'Site 1',
- },
+ attrs={'site__name': 'Site 1'},
can_view=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(url, **self.header)
@@ -623,13 +599,11 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_add=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to create a non-permitted object
response = self.client.post(url, data, format='json', **self.header)
@@ -652,13 +626,11 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_change=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to edit a non-permitted object
data = {'site': self.sites[0].pk}
@@ -687,13 +659,11 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(Prefix),
attrs={'site__name': 'Site 1'},
can_delete=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Attempt to delete a non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 99e4f559a..bb705a6df 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -122,14 +122,10 @@ class RemoteUserBackend(_RemoteUserBackend):
try:
app_label, codename = permission_name.split('.')
action, model_name = codename.split('_')
-
- kwargs = {
+ user.object_permissions.create(**{
'model': ContentType.objects.get(app_label=app_label, model=model_name),
f'can_{action}': True
- }
- obj_perm = ObjectPermission(**kwargs)
- obj_perm.save()
- obj_perm.users.add(user)
+ })
permissions_list.append(permission_name)
except ValueError:
logging.error(
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 8346f5d04..86f465364 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -1,5 +1,5 @@
from django.contrib.contenttypes.models import ContentType
-from django.contrib.auth.models import Permission, User
+from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings
@@ -7,7 +7,6 @@ from django.urls import reverse, NoReverseMatch
from rest_framework.test import APIClient
from users.models import ObjectPermission, Token
-from utilities.permissions import get_permission_for_model
from .utils import disable_warnings, post_data
@@ -36,13 +35,10 @@ class TestCase(_TestCase):
app_label, codename = name.split('.')
action, model_name = codename.split('_')
- kwargs = {
+ self.user.object_permissions.create(**{
'model': ContentType.objects.get(app_label=app_label, model=model_name),
f'can_{action}': True
- }
- obj_perm = ObjectPermission(**kwargs)
- obj_perm.save()
- obj_perm.users.add(self.user)
+ })
def remove_permissions(self, *names):
"""
@@ -52,12 +48,10 @@ class TestCase(_TestCase):
app_label, codename = name.split('.')
action, model_name = codename.split('_')
- kwargs = {
- 'user': self.user,
+ self.user.object_permissions.filter(**{
'model': ContentType.objects.get(app_label=app_label, model=model_name),
f'can_{action}': True
- }
- ObjectPermission.objects.filter(**kwargs).delete()
+ }).delete()
#
# Convenience methods
From dc56e49410c00821260cc5870dddd258dd4cd65c Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 10:35:59 -0400
Subject: [PATCH 100/300] Introduce resolve_permission() utility function
---
netbox/users/models.py | 5 ++---
netbox/utilities/auth_backends.py | 7 +++----
netbox/utilities/permissions.py | 20 ++++++++++++++++++++
netbox/utilities/testing/testcases.py | 13 +++++--------
4 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/netbox/users/models.py b/netbox/users/models.py
index b9ab6cbb5..17c5a3a65 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -12,6 +12,7 @@ from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
+from utilities.permissions import resolve_permission
from utilities.utils import flatten_dict
@@ -202,11 +203,9 @@ class ObjectPermissionManager(models.Manager):
Compile all ObjectPermission attributes applicable to a specific combination of user, model, and action. Returns
a dictionary that can be passed directly to .filter() on a QuerySet.
"""
- app_label, codename = perm.split('.')
- action, model_name = codename.split('_')
+ content_type, action = resolve_permission(perm)
assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
- content_type = ContentType.objects.get(app_label=app_label, model=model_name)
qs = self.get_queryset().filter(
Q(users=user) | Q(groups__user=user),
model=content_type,
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index bb705a6df..a490115bb 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -7,6 +7,7 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from users.models import ObjectPermission
+from utilities.permissions import resolve_permission
class ObjectPermissionBackend(ModelBackend):
@@ -40,7 +41,6 @@ class ObjectPermissionBackend(ModelBackend):
return user_obj._object_perm_cache
def has_perm(self, user_obj, perm, obj=None):
- # print(f'has_perm({perm})')
app_label, codename = perm.split('.')
action, model_name = codename.split('_')
@@ -120,10 +120,9 @@ class RemoteUserBackend(_RemoteUserBackend):
permissions_list = []
for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
try:
- app_label, codename = permission_name.split('.')
- action, model_name = codename.split('_')
+ content_type, action = resolve_permission(permission_name)
user.object_permissions.create(**{
- 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ 'model': content_type,
f'can_{action}': True
})
permissions_list.append(permission_name)
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index 516d6fe5b..80d564db4 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -1,3 +1,6 @@
+from django.contrib.contenttypes.models import ContentType
+
+
def get_permission_for_model(model, action):
"""
Resolve the named permission for a given model (or instance) and action (e.g. view or add).
@@ -13,3 +16,20 @@ def get_permission_for_model(model, action):
action,
model._meta.model_name
)
+
+
+def resolve_permission(name):
+ """
+ Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns
+ (Site, "view").
+
+ :param name: Permission name in the format ._
+ """
+ app_label, codename = name.split('.')
+ action, model_name = codename.split('_')
+ try:
+ content_type = ContentType.objects.get(app_label=app_label, model=model_name)
+ except ContentType.DoesNotExist:
+ raise ValueError(f"Unknown app/model for {name}")
+
+ return content_type, action
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 86f465364..a505e6e03 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -7,6 +7,7 @@ from django.urls import reverse, NoReverseMatch
from rest_framework.test import APIClient
from users.models import ObjectPermission, Token
+from utilities.permissions import resolve_permission
from .utils import disable_warnings, post_data
@@ -32,11 +33,9 @@ class TestCase(_TestCase):
Assign a set of permissions to the test user. Accepts permission names in the form ._.
"""
for name in names:
- app_label, codename = name.split('.')
- action, model_name = codename.split('_')
-
+ ct, action = resolve_permission(name)
self.user.object_permissions.create(**{
- 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ 'model': ct,
f'can_{action}': True
})
@@ -45,11 +44,9 @@ class TestCase(_TestCase):
Remove a set of permissions from the test user, if assigned.
"""
for name in names:
- app_label, codename = name.split('.')
- action, model_name = codename.split('_')
-
+ ct, action = resolve_permission(name)
self.user.object_permissions.filter(**{
- 'model': ContentType.objects.get(app_label=app_label, model=model_name),
+ 'model': ct,
f'can_{action}': True
}).delete()
From 5d36d81ae1fbd466a0e4f5331defb5562176a8f6 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 11:08:35 -0400
Subject: [PATCH 101/300] Restore model-level permission tests
---
netbox/users/models.py | 1 +
netbox/utilities/testing/testcases.py | 306 ++++++++++++----------
netbox/virtualization/tests/test_views.py | 1 +
3 files changed, 166 insertions(+), 142 deletions(-)
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 17c5a3a65..721ca2f26 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -17,6 +17,7 @@ from utilities.utils import flatten_dict
__all__ = (
+ 'ObjectPermission',
'Token',
'UserConfig',
)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index a505e6e03..e665b2277 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -155,6 +155,13 @@ class ViewTestCases:
"""
Retrieve a single instance.
"""
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+ def test_get_object_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)
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object_without_permission(self):
instance = self.model.objects.first()
@@ -163,28 +170,29 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_get_object_with_model_permission(self):
- # instance = self.model.objects.first()
- #
- # # Add model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'view'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_get_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Add model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_view=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_get_object_with_object_permission(self):
instance1, instance2 = self.model.objects.all()[:2]
# Add object-level permission
- obj_perm = ObjectPermission(
+ self.user.object_permissions.create(
model=ContentType.objects.get_for_model(self.model),
attrs={'pk': instance1.pk},
can_view=True
)
- obj_perm.save()
- obj_perm.users.add(self.user)
# Try GET to permitted object
self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200)
@@ -192,13 +200,6 @@ class ViewTestCases:
# Try GET to non-permitted object
self.assertHttpStatus(self.client.get(instance2.get_absolute_url()), 404)
- @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.
@@ -221,24 +222,27 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(response, 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_create_object_with_model_permission(self):
- # initial_count = self.model.objects.count()
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'add'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
- #
- # # Try POST with model-level permission
- # request = {
- # 'path': self._get_url('add'),
- # 'data': post_data(self.form_data),
- # }
- # self.assertHttpStatus(self.client.post(**request), 302)
- # self.assertEqual(initial_count + 1, self.model.objects.count())
- # self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_create_object_with_model_permission(self):
+ initial_count = self.model.objects.count()
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_add=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('add'),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertEqual(initial_count + 1, self.model.objects.count())
+ self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_create_object_with_object_permission(self):
@@ -300,23 +304,26 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_edit_object_with_model_permission(self):
- # instance = self.model.objects.first()
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'change'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
- #
- # # Try POST with model-level permission
- # request = {
- # 'path': self._get_url('edit', instance),
- # 'data': post_data(self.form_data),
- # }
- # self.assertHttpStatus(self.client.post(**request), 302)
- # self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_edit_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_change=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('edit', instance),
+ 'data': post_data(self.form_data),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ self.assertInstanceEqual(self.model.objects.get(pk=instance.pk), self.form_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_edit_object_with_object_permission(self):
@@ -372,24 +379,27 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(**request), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_delete_object_with_model_permission(self):
- # instance = self.model.objects.first()
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'delete'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
- #
- # # Try POST with model-level permission
- # request = {
- # 'path': self._get_url('delete', instance),
- # 'data': post_data({'confirm': True}),
- # }
- # self.assertHttpStatus(self.client.post(**request), 302)
- # with self.assertRaises(ObjectDoesNotExist):
- # self.model.objects.get(pk=instance.pk)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_delete_object_with_model_permission(self):
+ instance = self.model.objects.first()
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_delete=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
+
+ # Try POST with model-level permission
+ request = {
+ 'path': self._get_url('delete', instance),
+ 'data': post_data({'confirm': True}),
+ }
+ self.assertHttpStatus(self.client.post(**request), 302)
+ with self.assertRaises(ObjectDoesNotExist):
+ self.model.objects.get(pk=instance.pk)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_delete_object_with_object_permission(self):
@@ -431,6 +441,13 @@ class ViewTestCases:
"""
Retrieve multiple instances.
"""
+ @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)
+
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_without_permission(self):
@@ -438,20 +455,23 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.get(self._get_url('list')), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_list_objects_with_model_permission(self):
- #
- # # Add model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'view'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
- #
- # # Built-in CSV export
- # if hasattr(self.model, 'csv_headers'):
- # response = self.client.get('{}?export'.format(self._get_url('list')))
- # self.assertHttpStatus(response, 200)
- # self.assertEqual(response.get('Content-Type'), 'text/csv')
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_list_objects_with_model_permission(self):
+
+ # Add model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_view=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
+
+ # Built-in CSV export
+ if hasattr(self.model, 'csv_headers'):
+ response = self.client.get('{}?export'.format(self._get_url('list')))
+ self.assertHttpStatus(response, 200)
+ self.assertEqual(response.get('Content-Type'), 'text/csv')
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_list_objects_with_object_permission(self):
@@ -471,13 +491,6 @@ class ViewTestCases:
# TODO: Verify that only the permitted object is returned
- @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.
@@ -536,22 +549,25 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(response, 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_bulk_import_objects_with_model_permission(self):
- # initial_count = self.model.objects.count()
- # data = {
- # 'csv': self._get_csv_data(),
- # }
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'add'))
- #
- # # Try GET with model-level permission
- # self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
- #
- # # Test POST with permission
- # self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
- # self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_import_objects_with_model_permission(self):
+ initial_count = self.model.objects.count()
+ data = {
+ 'csv': self._get_csv_data(),
+ }
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_add=True
+ )
+
+ # Try GET with model-level permission
+ self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+
+ # Test POST with permission
+ self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
+ self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_import_objects_with_object_permission(self):
@@ -597,24 +613,27 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_bulk_edit_objects_with_model_permission(self):
- # pk_list = self.model.objects.values_list('pk', flat=True)[:3]
- # data = {
- # 'pk': pk_list,
- # '_apply': True, # Form button
- # }
- #
- # # Append the form data to the request
- # data.update(post_data(self.bulk_edit_data))
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'change'))
- #
- # # Try POST with model-level permission
- # self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
- # for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
- # self.assertInstanceEqual(instance, self.bulk_edit_data)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_edit_objects_with_model_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)[:3]
+ data = {
+ 'pk': pk_list,
+ '_apply': True, # Form button
+ }
+
+ # Append the form data to the request
+ data.update(post_data(self.bulk_edit_data))
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_change=True
+ )
+
+ # Try POST with model-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
+ for i, instance in enumerate(self.model.objects.filter(pk__in=pk_list)):
+ self.assertInstanceEqual(instance, self.bulk_edit_data)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_edit_objects_with_object_permission(self):
@@ -664,21 +683,24 @@ class ViewTestCases:
with disable_warnings('django.request'):
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 403)
- # @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
- # def test_bulk_delete_objects_with_model_permission(self):
- # pk_list = self.model.objects.values_list('pk', flat=True)
- # data = {
- # 'pk': pk_list,
- # 'confirm': True,
- # '_confirm': True, # Form button
- # }
- #
- # # Assign model-level permission
- # self.add_permissions(get_permission_for_model(self.model, 'delete'))
- #
- # # Try POST with model-level permission
- # self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
- # self.assertEqual(self.model.objects.count(), 0)
+ @override_settings(EXEMPT_VIEW_PERMISSIONS=[])
+ def test_bulk_delete_objects_with_model_permission(self):
+ pk_list = self.model.objects.values_list('pk', flat=True)
+ data = {
+ 'pk': pk_list,
+ 'confirm': True,
+ '_confirm': True, # Form button
+ }
+
+ # Assign model-level permission
+ self.user.object_permissions.create(
+ model=ContentType.objects.get_for_model(self.model),
+ can_delete=True
+ )
+
+ # Try POST with model-level permission
+ self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
+ self.assertEqual(self.model.objects.count(), 0)
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
def test_bulk_delete_objects_with_object_permission(self):
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 006db34d6..067606648 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -192,6 +192,7 @@ class InterfaceTestCase(
model = Interface
# Disable inapplicable tests
+ test_list_objects_anonymous = None
test_list_objects_without_permission = None
test_list_objects_with_model_permission = None
test_list_objects_with_object_permission = None
From 486f1a74abdef2561800c994ede207e7e4f96823 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 12:05:54 -0400
Subject: [PATCH 102/300] Standardize base classes for view test cases
---
netbox/dcim/tests/test_views.py | 62 +++++++++++++----------
netbox/extras/tests/test_views.py | 40 +++++++--------
netbox/ipam/tests/test_views.py | 16 +++---
netbox/secrets/tests/test_views.py | 19 +++----
netbox/virtualization/tests/test_views.py | 16 +++---
5 files changed, 76 insertions(+), 77 deletions(-)
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 6e28700c8..cfbb2b95f 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -321,7 +321,16 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
-class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class DeviceTypeTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.CreateObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = DeviceType
@classmethod
@@ -792,14 +801,15 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
}
-class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+# TODO: Change base class to DeviceComponentTemplateViewTestCase
+class DeviceBayTemplateTestCase(
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.BulkCreateObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = DeviceBayTemplate
- # Disable inapplicable views
- test_bulk_edit_objects_without_permission = None
- test_bulk_edit_objects_with_model_permission = None
- test_bulk_edit_objects_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -1439,14 +1449,18 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
-class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class CableTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = Cable
- # TODO: Creation URL needs termination context
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
@@ -1515,22 +1529,16 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
-class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class VirtualChassisTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = VirtualChassis
- # Disable inapplicable tests
- test_bulk_import_objects_without_permission = None
- test_bulk_import_objects_with_model_permission = None
- test_bulk_import_objects_with_object_permission = None
-
- # TODO: Requires special form handling
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
- test_edit_object_without_permission = None
- test_edit_object_with_model_permission = None
- test_edit_object_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py
index f52054cc1..6d41886fc 100644
--- a/netbox/extras/tests/test_views.py
+++ b/netbox/extras/tests/test_views.py
@@ -10,17 +10,17 @@ from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase
-class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class TagTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = Tag
- # Disable inapplicable tests
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
- test_bulk_import_objects_without_permission = None
- test_bulk_import_objects_with_model_permission = None
- test_bulk_import_objects_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
@@ -42,22 +42,16 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
-class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class ConfigContextTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = ConfigContext
- # Disable inapplicable tests
- test_bulk_import_objects_without_permission = None
- test_bulk_import_objects_with_model_permission = None
- test_bulk_import_objects_with_object_permission = None
-
- # TODO: Resolve model discrepancies when creating/editing ConfigContexts
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
- test_edit_object_without_permission = None
- test_edit_object_with_model_permission = None
- test_edit_object_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py
index bbd252473..794284dba 100644
--- a/netbox/ipam/tests/test_views.py
+++ b/netbox/ipam/tests/test_views.py
@@ -333,14 +333,18 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
-class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Update base class to PrimaryObjectViewTestCase
+class ServiceTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkImportObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = Service
- # TODO: Resolve URL for Service creation
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py
index 7796be63d..577ba4ef4 100644
--- a/netbox/secrets/tests/test_views.py
+++ b/netbox/secrets/tests/test_views.py
@@ -36,19 +36,16 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
-class SecretTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+# TODO: Change base class to PrimaryObjectViewTestCase
+class SecretTestCase(
+ ViewTestCases.GetObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.ListObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase
+):
model = Secret
- # Disable inapplicable tests
- test_create_object_without_permission = None
- test_create_object_with_model_permission = None
- test_create_object_with_object_permission = None
-
- # TODO: Check permissions enforcement on secrets.views.secret_edit
- test_edit_object_without_permission = None
- test_edit_object_with_model_permission = None
- test_edit_object_with_object_permission = None
-
@classmethod
def setUpTestData(cls):
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 067606648..9fde12186 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -185,21 +185,17 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
+# TODO: Update base class to DeviceComponentViewTestCase
class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
- ViewTestCases.DeviceComponentViewTestCase,
+ ViewTestCases.EditObjectViewTestCase,
+ ViewTestCases.DeleteObjectViewTestCase,
+ ViewTestCases.BulkCreateObjectsViewTestCase,
+ ViewTestCases.BulkEditObjectsViewTestCase,
+ ViewTestCases.BulkDeleteObjectsViewTestCase,
):
model = Interface
- # Disable inapplicable tests
- test_list_objects_anonymous = None
- test_list_objects_without_permission = None
- test_list_objects_with_model_permission = None
- test_list_objects_with_object_permission = None
- test_bulk_import_objects_without_permission = None
- test_bulk_import_objects_with_model_permission = None
- test_bulk_import_objects_with_object_permission = None
-
def _get_base_url(self):
# Interface belongs to the DCIM app, so we have to override the base URL
return 'virtualization:interface_{}'
From 73b7eb0c7fab5d304da88e04e3fb95c0b5819621 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 13:25:12 -0400
Subject: [PATCH 103/300] Skip queryset filtering for superusers
---
netbox/utilities/views.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 6e93c2369..e73a55dc7 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -66,7 +66,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
return False
# Update the view's QuerySet to filter only the permitted objects
- if user.is_authenticated:
+ if user.is_authenticated and not user.is_superuser:
obj_perm_attrs = user._object_perm_cache[permission_required]
attrs = Q()
for perm_attrs in obj_perm_attrs:
From a8ed04c4d20b352b713ca191de9d1ba62f20a7af Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 13:25:37 -0400
Subject: [PATCH 104/300] Expose assigned ObjectPermissions on User instance
---
netbox/utilities/auth_backends.py | 49 ++++++++++++++++---------------
1 file changed, 25 insertions(+), 24 deletions(-)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index a490115bb..ecb3ea652 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -3,7 +3,6 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group
-from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from users.models import ObjectPermission
@@ -12,33 +11,35 @@ from utilities.permissions import resolve_permission
class ObjectPermissionBackend(ModelBackend):
+ def get_all_permissions(self, user_obj, obj=None):
+ if not user_obj.is_active or user_obj.is_anonymous:
+ return set()
+ if not hasattr(user_obj, '_object_perm_cache'):
+ user_obj._object_perm_cache = self.get_object_permissions(user_obj)
+ return user_obj._object_perm_cache
+
def get_object_permissions(self, user_obj):
"""
Return all permissions granted to the user by an ObjectPermission.
"""
- if not hasattr(user_obj, '_object_perm_cache'):
+ # Retrieve all assigned ObjectPermissions
+ object_permissions = ObjectPermission.objects.filter(
+ Q(users=user_obj) |
+ Q(groups__user=user_obj)
+ ).prefetch_related('model')
- # Retrieve all assigned ObjectPermissions
- object_permissions = ObjectPermission.objects.filter(
- Q(users=user_obj) |
- Q(groups__user=user_obj)
- ).prefetch_related('model')
+ # Create a dictionary mapping permissions to their attributes
+ perms = dict()
+ for obj_perm in object_permissions:
+ for action in ['view', 'add', 'change', 'delete']:
+ if getattr(obj_perm, f"can_{action}"):
+ perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}"
+ if perm_name in perms:
+ perms[perm_name].append(obj_perm.attrs)
+ else:
+ perms[perm_name] = [obj_perm.attrs]
- # Create a dictionary mapping permissions to their attributes
- perms = dict()
- for obj_perm in object_permissions:
- for action in ['view', 'add', 'change', 'delete']:
- if getattr(obj_perm, f"can_{action}"):
- perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}"
- if perm_name in perms:
- perms[perm_name].append(obj_perm.attrs)
- else:
- perms[perm_name] = [obj_perm.attrs]
-
- # Cache resolved permissions on the User instance
- setattr(user_obj, '_object_perm_cache', perms)
-
- return user_obj._object_perm_cache
+ return perms
def has_perm(self, user_obj, perm, obj=None):
app_label, codename = perm.split('.')
@@ -64,7 +65,7 @@ class ObjectPermissionBackend(ModelBackend):
return False
# If no applicable ObjectPermissions have been created for this user/permission, deny permission
- if perm not in self.get_object_permissions(user_obj):
+ if perm not in self.get_all_permissions(user_obj):
return False
# If no object has been specified, grant permission. (The presence of a permission in this set tells
@@ -78,7 +79,7 @@ class ObjectPermissionBackend(ModelBackend):
raise ValueError(f"Invalid permission {perm} for model {model}")
# Compile a query filter that matches all instances of the specified model
- obj_perm_attrs = self.get_object_permissions(user_obj)[perm]
+ obj_perm_attrs = self.get_all_permissions(user_obj)[perm]
attrs = Q()
for perm_attrs in obj_perm_attrs:
if perm_attrs:
From f8e29ea66a3415050e988643b9601b2d741cd09b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 13:47:52 -0400
Subject: [PATCH 105/300] Remove ObjectPermissionManager
---
netbox/users/models.py | 25 -------------------------
1 file changed, 25 deletions(-)
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 721ca2f26..cf2ee3953 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -197,29 +197,6 @@ class Token(models.Model):
return True
-class ObjectPermissionManager(models.Manager):
-
- def get_attr_constraints(self, user, perm):
- """
- Compile all ObjectPermission attributes applicable to a specific combination of user, model, and action. Returns
- a dictionary that can be passed directly to .filter() on a QuerySet.
- """
- content_type, action = resolve_permission(perm)
- assert action in ['view', 'add', 'change', 'delete'], f"Invalid action: {action}"
-
- qs = self.get_queryset().filter(
- Q(users=user) | Q(groups__user=user),
- model=content_type,
- **{f'can_{action}': True}
- )
-
- attrs = Q()
- for perm in qs:
- attrs |= Q(**perm.attrs)
-
- return attrs
-
-
class ObjectPermission(models.Model):
"""
A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
@@ -257,8 +234,6 @@ class ObjectPermission(models.Model):
default=False
)
- objects = ObjectPermissionManager()
-
class Meta:
unique_together = ('model', 'attrs')
From 65bd3fbddb5769b07335bb00ffc8e44cfb33cf27 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 14:03:08 -0400
Subject: [PATCH 106/300] Remove built-in permission assignment from admin UI
---
netbox/users/admin.py | 24 ++++++++++++++++++++++--
1 file changed, 22 insertions(+), 2 deletions(-)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index fcaeb4ef0..8ea33514a 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -1,14 +1,26 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
-from django.contrib.auth.models import User
+from django.contrib.auth.models import Group, User
from .models import ObjectPermission, Token, UserConfig
-# Unregister the built-in UserAdmin so that we can use our custom admin view below
+# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
+admin.site.unregister(Group)
admin.site.unregister(User)
+@admin.register(Group)
+class GroupAdmin(admin.ModelAdmin):
+ fields = ('name',)
+ list_display = ('name', 'user_count')
+ ordering = ('name',)
+ search_fields = ('name',)
+
+ def user_count(self, obj):
+ return obj.user_set.count()
+
+
class UserConfigInline(admin.TabularInline):
model = UserConfig
readonly_fields = ('data',)
@@ -21,6 +33,14 @@ class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
+ fieldsets = (
+ (None, {'fields': ('username', 'password')}),
+ ('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
+ ('Permissions', {
+ 'fields': ('is_active', 'is_staff', 'is_superuser'),
+ }),
+ ('Important dates', {'fields': ('last_login', 'date_joined')}),
+ )
inlines = (UserConfigInline,)
From bdfc0364d520e93fd38c51a412d805eff01c3a89 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 14:20:18 -0400
Subject: [PATCH 107/300] Fix up ObjectPermission content type assignment
---
netbox/users/admin.py | 29 +++++++++++++++++++
.../users/migrations/0007_objectpermission.py | 2 +-
netbox/users/models.py | 5 ++++
3 files changed, 35 insertions(+), 1 deletion(-)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 8ea33514a..e13904eea 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -3,8 +3,14 @@ from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group, User
+from extras.admin import order_content_types
from .models import ObjectPermission, Token, UserConfig
+
+#
+# Users & groups
+#
+
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
admin.site.unregister(Group)
admin.site.unregister(User)
@@ -44,6 +50,10 @@ class UserAdmin(UserAdmin_):
inlines = (UserConfigInline,)
+#
+# REST API tokens
+#
+
class TokenAdminForm(forms.ModelForm):
key = forms.CharField(
required=False,
@@ -65,8 +75,27 @@ class TokenAdmin(admin.ModelAdmin):
]
+#
+# Permissions
+#
+
+class ObjectPermissionForm(forms.ModelForm):
+
+ class Meta:
+ model = ObjectPermission
+ exclude = []
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ # Format ContentType choices
+ order_content_types(self.fields['model'])
+ self.fields['model'].choices.insert(0, ('', '---------'))
+
+
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
+ form = ObjectPermissionForm
list_display = [
'model', 'can_view', 'can_add', 'can_change', 'can_delete'
]
diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py
index 1fadcc9a5..da176dd5d 100644
--- a/netbox/users/migrations/0007_objectpermission.py
+++ b/netbox/users/migrations/0007_objectpermission.py
@@ -26,7 +26,7 @@ class Migration(migrations.Migration):
('can_change', models.BooleanField(default=False)),
('can_delete', models.BooleanField(default=False)),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
- ('model', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
+ ('model', models.ForeignKey(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
],
options={
diff --git a/netbox/users/models.py b/netbox/users/models.py
index cf2ee3953..6de7bf01a 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -214,6 +214,11 @@ class ObjectPermission(models.Model):
)
model = models.ForeignKey(
to=ContentType,
+ limit_choices_to={
+ 'app_label__in': [
+ 'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization',
+ ],
+ },
on_delete=models.CASCADE
)
attrs = JSONField(
From f65b2278f0a64ea0bf747d00b532a7aa5cb45812 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 28 May 2020 15:04:46 -0400
Subject: [PATCH 108/300] Enable many-to-many model assignment for
ObjectPermissions
---
netbox/netbox/tests/test_authentication.py | 78 ++++++++++------
netbox/users/admin.py | 4 +-
.../users/migrations/0007_objectpermission.py | 8 +-
netbox/users/models.py | 8 +-
netbox/utilities/auth_backends.py | 25 ++---
netbox/utilities/testing/testcases.py | 93 ++++++++++---------
6 files changed, 122 insertions(+), 94 deletions(-)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index 74f4c411a..ad900bdc0 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -201,11 +201,13 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
response = self.client.get(self.prefixes[0].get_absolute_url())
@@ -223,11 +225,13 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(reverse('ipam:prefix_list'))
@@ -255,12 +259,14 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True,
can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create a non-permitted object
request = {
@@ -301,12 +307,14 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True,
can_change=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit a non-permitted object
request = {
@@ -343,12 +351,14 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True,
can_delete=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Delete permitted object
request = {
@@ -390,11 +400,13 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertEqual(initial_count, Prefix.objects.count())
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create non-permitted objects
request = {
@@ -437,11 +449,13 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_change=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit non-permitted objects
request = {
@@ -479,12 +493,14 @@ class ObjectPermissionViewTestCase(TestCase):
self.assertHttpStatus(response, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True,
can_delete=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete non-permitted object
request = {
@@ -549,11 +565,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
@@ -574,11 +592,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(url, **self.header)
@@ -599,11 +619,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create a non-permitted object
response = self.client.post(url, data, format='json', **self.header)
@@ -626,11 +648,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_change=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit a non-permitted object
data = {'site': self.sites[0].pk}
@@ -659,11 +683,13 @@ class ObjectPermissionAPIViewTestCase(TestCase):
self.assertEqual(response.status_code, 403)
# Assign object permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(Prefix),
+ obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
can_delete=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete a non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index e13904eea..89aa3f49a 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -89,8 +89,8 @@ class ObjectPermissionForm(forms.ModelForm):
super().__init__(*args, **kwargs)
# Format ContentType choices
- order_content_types(self.fields['model'])
- self.fields['model'].choices.insert(0, ('', '---------'))
+ order_content_types(self.fields['content_types'])
+ self.fields['content_types'].choices.insert(0, ('', '---------'))
@admin.register(ObjectPermission)
diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py
index da176dd5d..2052ffbb2 100644
--- a/netbox/users/migrations/0007_objectpermission.py
+++ b/netbox/users/migrations/0007_objectpermission.py
@@ -1,9 +1,8 @@
-# Generated by Django 3.0.6 on 2020-05-27 14:17
+# Generated by Django 3.0.6 on 2020-05-28 18:24
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):
@@ -25,12 +24,9 @@ class Migration(migrations.Migration):
('can_add', models.BooleanField(default=False)),
('can_change', models.BooleanField(default=False)),
('can_delete', models.BooleanField(default=False)),
+ ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
- ('model', models.ForeignKey(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
],
- options={
- 'unique_together': {('model', 'attrs')},
- },
),
]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 6de7bf01a..bddae2ff7 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -212,14 +212,14 @@ class ObjectPermission(models.Model):
blank=True,
related_name='object_permissions'
)
- model = models.ForeignKey(
+ content_types = models.ManyToManyField(
to=ContentType,
limit_choices_to={
'app_label__in': [
'circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization',
],
},
- on_delete=models.CASCADE
+ related_name='object_permissions'
)
attrs = JSONField(
blank=True,
@@ -239,8 +239,8 @@ class ObjectPermission(models.Model):
default=False
)
- class Meta:
- unique_together = ('model', 'attrs')
+ def __str__(self):
+ return "Object permission"
def clean(self):
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index ecb3ea652..36796194e 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -26,18 +26,19 @@ class ObjectPermissionBackend(ModelBackend):
object_permissions = ObjectPermission.objects.filter(
Q(users=user_obj) |
Q(groups__user=user_obj)
- ).prefetch_related('model')
+ ).prefetch_related('content_types')
# Create a dictionary mapping permissions to their attributes
perms = dict()
for obj_perm in object_permissions:
- for action in ['view', 'add', 'change', 'delete']:
- if getattr(obj_perm, f"can_{action}"):
- perm_name = f"{obj_perm.model.app_label}.{action}_{obj_perm.model.model}"
- if perm_name in perms:
- perms[perm_name].append(obj_perm.attrs)
- else:
- perms[perm_name] = [obj_perm.attrs]
+ for content_type in obj_perm.content_types.all():
+ for action in ['view', 'add', 'change', 'delete']:
+ if getattr(obj_perm, f"can_{action}"):
+ perm_name = f"{content_type.app_label}.{action}_{content_type.model}"
+ if perm_name in perms:
+ perms[perm_name].append(obj_perm.attrs)
+ else:
+ perms[perm_name] = [obj_perm.attrs]
return perms
@@ -122,10 +123,10 @@ class RemoteUserBackend(_RemoteUserBackend):
for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
try:
content_type, action = resolve_permission(permission_name)
- user.object_permissions.create(**{
- 'model': content_type,
- f'can_{action}': True
- })
+ obj_perm = ObjectPermission(**{f'can_{action}': True})
+ obj_perm.save()
+ obj_perm.users.add(user)
+ obj_perm.content_types.add(content_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index e665b2277..cde394422 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -34,21 +34,10 @@ class TestCase(_TestCase):
"""
for name in names:
ct, action = resolve_permission(name)
- self.user.object_permissions.create(**{
- 'model': ct,
- f'can_{action}': True
- })
-
- def remove_permissions(self, *names):
- """
- Remove a set of permissions from the test user, if assigned.
- """
- for name in names:
- ct, action = resolve_permission(name)
- self.user.object_permissions.filter(**{
- 'model': ct,
- f'can_{action}': True
- }).delete()
+ obj_perm = ObjectPermission(**{f'can_{action}': True})
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ct)
#
# Convenience methods
@@ -175,10 +164,12 @@ class ViewTestCases:
instance = self.model.objects.first()
# Add model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
@@ -188,11 +179,13 @@ class ViewTestCases:
instance1, instance2 = self.model.objects.all()[:2]
# Add object-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
attrs={'pk': instance1.pk},
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET to permitted object
self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200)
@@ -227,10 +220,12 @@ class ViewTestCases:
initial_count = self.model.objects.count()
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -250,12 +245,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk__gt': 0}, # Dummy permission to allow all
can_add=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with object-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -309,10 +304,12 @@ class ViewTestCases:
instance = self.model.objects.first()
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_change=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
@@ -331,12 +328,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk': instance1.pk},
can_change=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with a permitted object
self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200)
@@ -384,10 +381,12 @@ class ViewTestCases:
instance = self.model.objects.first()
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_delete=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
@@ -407,12 +406,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk': instance1.pk},
can_delete=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with a permitted object
self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200)
@@ -459,10 +458,12 @@ class ViewTestCases:
def test_list_objects_with_model_permission(self):
# Add model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_view=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
@@ -479,12 +480,12 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk': instance1.pk},
can_view=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with object-level permission
self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
@@ -511,12 +512,10 @@ class ViewTestCases:
self.assertHttpStatus(self.client.post(**request), 403)
# Assign object-level permission
- obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
- can_add=True
- )
+ obj_perm = ObjectPermission(can_add=True)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
@@ -557,10 +556,12 @@ class ViewTestCases:
}
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_add=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
@@ -578,12 +579,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk__gt': 0}, # Dummy permission to allow all
can_add=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Test import with object-level permission
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
@@ -625,10 +626,12 @@ class ViewTestCases:
data.update(post_data(self.bulk_edit_data))
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_change=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
@@ -648,12 +651,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk__in': list(pk_list)},
can_change=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
@@ -693,10 +696,12 @@ class ViewTestCases:
}
# Assign model-level permission
- self.user.object_permissions.create(
- model=ContentType.objects.get_for_model(self.model),
+ obj_perm = ObjectPermission(
can_delete=True
)
+ obj_perm.save()
+ obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
@@ -713,12 +718,12 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- model=ContentType.objects.get_for_model(self.model),
attrs={'pk__in': list(pk_list)},
can_delete=True
)
obj_perm.save()
obj_perm.users.add(self.user)
+ obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with object-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
From 90828cedae230d75b2bec4cb568db27161207aa4 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 10:31:34 -0400
Subject: [PATCH 109/300] Introduce proxy models for User and Group to organize
admin UI
---
netbox/users/admin.py | 8 ++--
.../users/migrations/0007_objectpermission.py | 32 --------------
.../users/migrations/0007_proxy_group_user.py | 44 +++++++++++++++++++
netbox/users/models.py | 36 +++++++++++++--
4 files changed, 81 insertions(+), 39 deletions(-)
delete mode 100644 netbox/users/migrations/0007_objectpermission.py
create mode 100644 netbox/users/migrations/0007_proxy_group_user.py
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 89aa3f49a..9482efd5c 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -1,10 +1,10 @@
from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group as StockGroup, User as StockUser
from extras.admin import order_content_types
-from .models import ObjectPermission, Token, UserConfig
+from .models import Group, User, ObjectPermission, Token, UserConfig
#
@@ -12,8 +12,8 @@ from .models import ObjectPermission, Token, UserConfig
#
# Unregister the built-in GroupAdmin and UserAdmin classes so that we can use our custom admin classes below
-admin.site.unregister(Group)
-admin.site.unregister(User)
+admin.site.unregister(StockGroup)
+admin.site.unregister(StockUser)
@admin.register(Group)
diff --git a/netbox/users/migrations/0007_objectpermission.py b/netbox/users/migrations/0007_objectpermission.py
deleted file mode 100644
index 2052ffbb2..000000000
--- a/netbox/users/migrations/0007_objectpermission.py
+++ /dev/null
@@ -1,32 +0,0 @@
-# Generated by Django 3.0.6 on 2020-05-28 18:24
-
-from django.conf import settings
-import django.contrib.postgres.fields.jsonb
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('contenttypes', '0002_remove_content_type_name'),
- ('auth', '0011_update_proxy_permissions'),
- migrations.swappable_dependency(settings.AUTH_USER_MODEL),
- ('users', '0006_create_userconfigs'),
- ]
-
- operations = [
- migrations.CreateModel(
- name='ObjectPermission',
- fields=[
- ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
- ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
- ('can_view', models.BooleanField(default=False)),
- ('can_add', models.BooleanField(default=False)),
- ('can_change', models.BooleanField(default=False)),
- ('can_delete', models.BooleanField(default=False)),
- ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
- ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
- ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
- ],
- ),
- ]
diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py
new file mode 100644
index 000000000..4a72eedd2
--- /dev/null
+++ b/netbox/users/migrations/0007_proxy_group_user.py
@@ -0,0 +1,44 @@
+# Generated by Django 3.0.6 on 2020-05-29 14:30
+
+import django.contrib.auth.models
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('auth', '0011_update_proxy_permissions'),
+ ('users', '0006_create_userconfigs'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Group',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('auth.group',),
+ managers=[
+ ('objects', django.contrib.auth.models.GroupManager()),
+ ],
+ ),
+ migrations.CreateModel(
+ name='User',
+ fields=[
+ ],
+ options={
+ 'proxy': True,
+ 'indexes': [],
+ 'constraints': [],
+ },
+ bases=('auth.user',),
+ managers=[
+ ('objects', django.contrib.auth.models.UserManager()),
+ ],
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index bddae2ff7..d2a4a152a 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -1,18 +1,16 @@
import binascii
import os
-from django.contrib.auth.models import Group, User
+from django.contrib.auth.models import Group as Group_, User as User_
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.exceptions import FieldError, ValidationError
from django.core.validators import MinLengthValidator
from django.db import models
-from django.db.models import Q
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
-from utilities.permissions import resolve_permission
from utilities.utils import flatten_dict
@@ -23,6 +21,30 @@ __all__ = (
)
+#
+# Proxy models for admin
+#
+
+class Group(Group_):
+ """
+ Proxy contrib.auth.models.Group for the admin UI
+ """
+ class Meta:
+ proxy = True
+
+
+class User(User_):
+ """
+ Proxy contrib.auth.models.User for the admin UI
+ """
+ class Meta:
+ proxy = True
+
+
+#
+# User preferences
+#
+
class UserConfig(models.Model):
"""
This model stores arbitrary user-specific preferences in a JSON data structure.
@@ -143,6 +165,10 @@ def create_userconfig(instance, created, **kwargs):
UserConfig(user=instance).save()
+#
+# REST API
+#
+
class Token(models.Model):
"""
An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens.
@@ -197,6 +223,10 @@ class Token(models.Model):
return True
+#
+# Permissions
+#
+
class ObjectPermission(models.Model):
"""
A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
From 02687453f2cf8068d6ca999cbf27eb5b761f4ca3 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 11:18:22 -0400
Subject: [PATCH 110/300] Add ArrayField on ObjectPermission to store actions
---
netbox/netbox/tests/test_authentication.py | 30 ++++++--------
netbox/users/admin.py | 40 +++++++++++++++++--
.../users/migrations/0007_proxy_group_user.py | 4 +-
.../users/migrations/0008_objectpermission.py | 33 +++++++++++++++
netbox/users/models.py | 39 +++++-------------
netbox/utilities/auth_backends.py | 16 ++++----
netbox/utilities/testing/testcases.py | 36 ++++++++---------
7 files changed, 120 insertions(+), 78 deletions(-)
create mode 100644 netbox/users/migrations/0008_objectpermission.py
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index ad900bdc0..bef8f004a 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -203,7 +203,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -227,7 +227,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -261,8 +261,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True,
- can_add=True
+ actions=['view', 'add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -309,8 +308,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True,
- can_change=True
+ actions=['view', 'change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -353,8 +351,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True,
- can_delete=True
+ actions=['view', 'delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -402,7 +399,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -451,7 +448,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -495,8 +492,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True,
- can_delete=True
+ actions=['view', 'delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -567,7 +563,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -594,7 +590,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -621,7 +617,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -650,7 +646,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -685,7 +681,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
attrs={'site__name': 'Site 1'},
- can_delete=True
+ actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 9482efd5c..507b75869 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -2,9 +2,10 @@ from django import forms
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin as UserAdmin_
from django.contrib.auth.models import Group as StockGroup, User as StockUser
+from django.core.exceptions import FieldError, ValidationError
from extras.admin import order_content_types
-from .models import Group, User, ObjectPermission, Token, UserConfig
+from .models import AdminGroup, AdminUser, ObjectPermission, Token, UserConfig
#
@@ -16,7 +17,7 @@ admin.site.unregister(StockGroup)
admin.site.unregister(StockUser)
-@admin.register(Group)
+@admin.register(AdminGroup)
class GroupAdmin(admin.ModelAdmin):
fields = ('name',)
list_display = ('name', 'user_count')
@@ -34,7 +35,7 @@ class UserConfigInline(admin.TabularInline):
verbose_name = 'Preferences'
-@admin.register(User)
+@admin.register(AdminUser)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
@@ -92,10 +93,41 @@ class ObjectPermissionForm(forms.ModelForm):
order_content_types(self.fields['content_types'])
self.fields['content_types'].choices.insert(0, ('', '---------'))
+ def clean(self):
+ content_types = self.cleaned_data['content_types']
+ attrs = self.cleaned_data['attrs']
+
+ # Validate the specified model attributes by attempting to execute a query. We don't care whether the query
+ # returns anything; we just want to make sure the specified attributes are valid.
+ if attrs:
+ for ct in content_types:
+ model = ct.model_class()
+ try:
+ model.objects.filter(**attrs).exists()
+ except FieldError as e:
+ raise ValidationError({
+ 'attrs': f'Invalid attributes for {model}: {e}'
+ })
+
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
form = ObjectPermissionForm
list_display = [
- 'model', 'can_view', 'can_add', 'can_change', 'can_delete'
+ 'list_models', 'list_users', 'list_groups', 'actions', 'attrs',
]
+
+ def get_queryset(self, request):
+ return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups')
+
+ def list_models(self, obj):
+ return ', '.join([f"{ct}" for ct in obj.content_types.all()])
+ list_models.short_description = 'Models'
+
+ def list_users(self, obj):
+ return ', '.join([u.username for u in obj.users.all()])
+ list_users.short_description = 'Users'
+
+ def list_groups(self, obj):
+ return ', '.join([g.name for g in obj.groups.all()])
+ list_groups.short_description = 'Groups'
diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py
index 4a72eedd2..dfd0512bd 100644
--- a/netbox/users/migrations/0007_proxy_group_user.py
+++ b/netbox/users/migrations/0007_proxy_group_user.py
@@ -14,7 +14,7 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
- name='Group',
+ name='AdminGroup',
fields=[
],
options={
@@ -28,7 +28,7 @@ class Migration(migrations.Migration):
],
),
migrations.CreateModel(
- name='User',
+ name='AdminUser',
fields=[
],
options={
diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py
new file mode 100644
index 000000000..f2ecb98b0
--- /dev/null
+++ b/netbox/users/migrations/0008_objectpermission.py
@@ -0,0 +1,33 @@
+# Generated by Django 3.0.6 on 2020-05-29 14:59
+
+from django.conf import settings
+import django.contrib.postgres.fields
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('auth', '0011_update_proxy_permissions'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ('users', '0007_proxy_group_user'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='ObjectPermission',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
+ ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
+ ('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
+ ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
+ ('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
+ ('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Permission',
+ },
+ ),
+ ]
diff --git a/netbox/users/models.py b/netbox/users/models.py
index d2a4a152a..1c8775699 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -1,10 +1,9 @@
import binascii
import os
-from django.contrib.auth.models import Group as Group_, User as User_
+from django.contrib.auth.models import Group, User
from django.contrib.contenttypes.models import ContentType
-from django.contrib.postgres.fields import JSONField
-from django.core.exceptions import FieldError, ValidationError
+from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.validators import MinLengthValidator
from django.db import models
from django.db.models.signals import post_save
@@ -25,7 +24,7 @@ __all__ = (
# Proxy models for admin
#
-class Group(Group_):
+class AdminGroup(Group):
"""
Proxy contrib.auth.models.Group for the admin UI
"""
@@ -33,7 +32,7 @@ class Group(Group_):
proxy = True
-class User(User_):
+class AdminUser(User):
"""
Proxy contrib.auth.models.User for the admin UI
"""
@@ -256,31 +255,13 @@ class ObjectPermission(models.Model):
null=True,
verbose_name='Attributes'
)
- can_view = models.BooleanField(
- default=False
- )
- can_add = models.BooleanField(
- default=False
- )
- can_change = models.BooleanField(
- default=False
- )
- can_delete = models.BooleanField(
- default=False
+ actions = ArrayField(
+ base_field=models.CharField(max_length=30),
+ help_text="The list of actions granted by this permission"
)
+ class Meta:
+ verbose_name = "Permission"
+
def __str__(self):
return "Object permission"
-
- def clean(self):
-
- # Validate the specified model attributes by attempting to execute a query. We don't care whether the query
- # returns anything; we just want to make sure the specified attributes are valid.
- if self.attrs:
- model = self.model.model_class()
- try:
- model.objects.filter(**self.attrs).exists()
- except FieldError as e:
- raise ValidationError({
- 'attrs': f'Invalid attributes for {model}: {e}'
- })
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index 36796194e..bc263480f 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -32,13 +32,12 @@ class ObjectPermissionBackend(ModelBackend):
perms = dict()
for obj_perm in object_permissions:
for content_type in obj_perm.content_types.all():
- for action in ['view', 'add', 'change', 'delete']:
- if getattr(obj_perm, f"can_{action}"):
- perm_name = f"{content_type.app_label}.{action}_{content_type.model}"
- if perm_name in perms:
- perms[perm_name].append(obj_perm.attrs)
- else:
- perms[perm_name] = [obj_perm.attrs]
+ for action in obj_perm.actions:
+ perm_name = f"{content_type.app_label}.{action}_{content_type.model}"
+ if perm_name in perms:
+ perms[perm_name].append(obj_perm.attrs)
+ else:
+ perms[perm_name] = [obj_perm.attrs]
return perms
@@ -123,7 +122,8 @@ class RemoteUserBackend(_RemoteUserBackend):
for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
try:
content_type, action = resolve_permission(permission_name)
- obj_perm = ObjectPermission(**{f'can_{action}': True})
+ # TODO: Merge multiple actions into a single ObjectPermission per content type
+ obj_perm = ObjectPermission(actions=[action])
obj_perm.save()
obj_perm.users.add(user)
obj_perm.content_types.add(content_type)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index cde394422..3514f9060 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -34,7 +34,7 @@ class TestCase(_TestCase):
"""
for name in names:
ct, action = resolve_permission(name)
- obj_perm = ObjectPermission(**{f'can_{action}': True})
+ obj_perm = ObjectPermission(actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.content_types.add(ct)
@@ -165,7 +165,7 @@ class ViewTestCases:
# Add model-level permission
obj_perm = ObjectPermission(
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -181,7 +181,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
attrs={'pk': instance1.pk},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -221,7 +221,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -246,7 +246,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk__gt': 0}, # Dummy permission to allow all
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -305,7 +305,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -329,7 +329,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk': instance1.pk},
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -382,7 +382,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_delete=True
+ actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -407,7 +407,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk': instance1.pk},
- can_delete=True
+ actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -459,7 +459,7 @@ class ViewTestCases:
# Add model-level permission
obj_perm = ObjectPermission(
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -481,7 +481,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
attrs={'pk': instance1.pk},
- can_view=True
+ actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -512,7 +512,7 @@ class ViewTestCases:
self.assertHttpStatus(self.client.post(**request), 403)
# Assign object-level permission
- obj_perm = ObjectPermission(can_add=True)
+ obj_perm = ObjectPermission(actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
@@ -557,7 +557,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -580,7 +580,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk__gt': 0}, # Dummy permission to allow all
- can_add=True
+ actions=['add']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -627,7 +627,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -652,7 +652,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk__in': list(pk_list)},
- can_change=True
+ actions=['change']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -697,7 +697,7 @@ class ViewTestCases:
# Assign model-level permission
obj_perm = ObjectPermission(
- can_delete=True
+ actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
@@ -719,7 +719,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
attrs={'pk__in': list(pk_list)},
- can_delete=True
+ actions=['delete']
)
obj_perm.save()
obj_perm.users.add(self.user)
From 85c54703ec13dbd912f8d3ab12f0f783b7211cfa Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 12:08:51 -0400
Subject: [PATCH 111/300] Improve the admin form for ObjectPermissions
---
netbox/users/admin.py | 45 +++++++++++++++++++++++++++++++++++++++++++
1 file changed, 45 insertions(+)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 507b75869..e76150fc4 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -81,22 +81,53 @@ class TokenAdmin(admin.ModelAdmin):
#
class ObjectPermissionForm(forms.ModelForm):
+ can_view = forms.BooleanField(required=False)
+ can_add = forms.BooleanField(required=False)
+ can_change = forms.BooleanField(required=False)
+ can_delete = forms.BooleanField(required=False)
class Meta:
model = ObjectPermission
exclude = []
+ help_texts = {
+ 'actions': 'Actions granted in addition to those listed above'
+ }
+ labels = {
+ 'actions': 'Additional actions'
+ }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ # Make the actions field optional since the admin form uses it only for non-CRUD actions
+ self.fields['actions'].required = False
+
# Format ContentType choices
order_content_types(self.fields['content_types'])
self.fields['content_types'].choices.insert(0, ('', '---------'))
+ # Check the appropriate checkboxes when editing an existing ObjectPermission
+ if self.instance:
+ for action in ['view', 'add', 'change', 'delete']:
+ if action in self.instance.actions:
+ self.fields[f'can_{action}'].initial = True
+ self.instance.actions.remove(action)
+
def clean(self):
content_types = self.cleaned_data['content_types']
attrs = self.cleaned_data['attrs']
+ # Append any of the selected CRUD checkboxes to the actions list
+ if not self.cleaned_data.get('actions'):
+ self.cleaned_data['actions'] = list()
+ for action in ['view', 'add', 'change', 'delete']:
+ if self.cleaned_data[f'can_{action}'] and action not in self.cleaned_data['actions']:
+ self.cleaned_data['actions'].append(action)
+
+ # At least one action must be specified
+ if not self.cleaned_data['actions']:
+ raise ValidationError("At least one action must be selected.")
+
# Validate the specified model attributes by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified attributes are valid.
if attrs:
@@ -112,6 +143,20 @@ class ObjectPermissionForm(forms.ModelForm):
@admin.register(ObjectPermission)
class ObjectPermissionAdmin(admin.ModelAdmin):
+ fieldsets = (
+ ('Objects', {
+ 'fields': ('content_types',)
+ }),
+ ('Assignment', {
+ 'fields': (('groups', 'users'),)
+ }),
+ ('Actions', {
+ 'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
+ }),
+ ('Constraints', {
+ 'fields': ('attrs',)
+ }),
+ )
form = ObjectPermissionForm
list_display = [
'list_models', 'list_users', 'list_groups', 'actions', 'attrs',
From 5d3cf8074bc50e5e269fab047d64db8cc60d16e6 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 13:42:38 -0400
Subject: [PATCH 112/300] Add migration for replicating legact permissions to
ObjectPermissions
---
.../migrations/0009_replicate_permissions.py | 41 +++++++++++++++++++
1 file changed, 41 insertions(+)
create mode 100644 netbox/users/migrations/0009_replicate_permissions.py
diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py
new file mode 100644
index 000000000..ba0663a0c
--- /dev/null
+++ b/netbox/users/migrations/0009_replicate_permissions.py
@@ -0,0 +1,41 @@
+from django.db import migrations
+
+
+ACTIONS = ['view', 'add', 'change', 'delete']
+
+
+def replicate_permissions(apps, schema_editor):
+ """
+ Replicate all Permission assignments as ObjectPermissions.
+ """
+ Permission = apps.get_model('auth', 'Permission')
+ ObjectPermission = apps.get_model('users', 'ObjectPermission')
+
+ # TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups
+ # are combined into a single ObjectPermission instance.
+ for perm in Permission.objects.all():
+ print(f'Replicating permission {perm.codename}')
+ action, model_name = perm.codename.split('_')
+
+ if perm.group_set.exists() or perm.user_set.exists():
+ obj_perm = ObjectPermission(actions=[action])
+ obj_perm.save()
+ obj_perm.content_types.add(perm.content_type)
+ if perm.group_set.exists():
+ obj_perm.groups.add(*list(perm.group_set.all()))
+ if perm.user_set.exists():
+ obj_perm.users.add(*list(perm.user_set.all()))
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('users', '0008_objectpermission'),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ code=replicate_permissions,
+ reverse_code=migrations.RunPython.noop
+ )
+ ]
From 670139492d1a8c7f70aeb715d78f7f22d00f2d9b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 13:47:19 -0400
Subject: [PATCH 113/300] Fix permission action evaluation
---
netbox/users/migrations/0009_replicate_permissions.py | 7 +++++--
1 file changed, 5 insertions(+), 2 deletions(-)
diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py
index ba0663a0c..c5e4d364c 100644
--- a/netbox/users/migrations/0009_replicate_permissions.py
+++ b/netbox/users/migrations/0009_replicate_permissions.py
@@ -14,8 +14,11 @@ def replicate_permissions(apps, schema_editor):
# TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups
# are combined into a single ObjectPermission instance.
for perm in Permission.objects.all():
- print(f'Replicating permission {perm.codename}')
- action, model_name = perm.codename.split('_')
+ # Account for non-standard permission names; e.g. napalm_read
+ if perm.codename.split('_')[0] in ACTIONS:
+ action = perm.codename.split('_')[0]
+ else:
+ action = perm.codename
if perm.group_set.exists() or perm.user_set.exists():
obj_perm = ObjectPermission(actions=[action])
From 8786bb25c519a71bd6f8d205b400e8413cf4e456 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 13:57:38 -0400
Subject: [PATCH 114/300] Fix instance evaluation
---
netbox/users/admin.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index e76150fc4..c1b659a8e 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -107,7 +107,7 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields['content_types'].choices.insert(0, ('', '---------'))
# Check the appropriate checkboxes when editing an existing ObjectPermission
- if self.instance:
+ if self.instance.pk:
for action in ['view', 'add', 'change', 'delete']:
if action in self.instance.actions:
self.fields[f'can_{action}'].initial = True
From 58989b85c866cd526b1f6a21d0635f823783d625 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 14:12:24 -0400
Subject: [PATCH 115/300] Introduce restrict_queryset()
---
netbox/utilities/api.py | 11 +++--------
netbox/utilities/permissions.py | 18 ++++++++++++++++++
netbox/utilities/views.py | 12 +++---------
3 files changed, 24 insertions(+), 17 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index 41002dd20..ef2650535 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied
from django.db import transaction
-from django.db.models import ManyToManyField, ProtectedError, Q
+from django.db.models import ManyToManyField, ProtectedError
from django.urls import reverse
from rest_framework.exceptions import APIException
from rest_framework.permissions import BasePermission
@@ -16,7 +16,7 @@ from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
from netbox.api import TokenPermissions
-from users.models import ObjectPermission
+from utilities.permissions import restrict_queryset
from .utils import dict_to_filter_params, dynamic_import
@@ -340,12 +340,7 @@ class ModelViewSet(_ModelViewSet):
permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
# Update the view's QuerySet to filter only the permitted objects
- obj_perm_attrs = request.user._object_perm_cache[permission_required]
- attrs = Q()
- for perm_attrs in obj_perm_attrs:
- if perm_attrs:
- attrs |= Q(**perm_attrs)
- self.queryset = self.queryset.filter(attrs)
+ self.queryset = restrict_queryset(self.queryset, request.user, permission_required)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index 80d564db4..be5c0189e 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
+from django.db.models import Q
def get_permission_for_model(model, action):
@@ -33,3 +34,20 @@ def resolve_permission(name):
raise ValueError(f"Unknown app/model for {name}")
return content_type, action
+
+
+def restrict_queryset(queryset, user, permission_required):
+ """
+ Filters a QuerySet to return only the objects on which the specified user has been granted the specified
+ permission.
+
+ :param queryset: Base QuerySet to be restricted
+ :param user: User instance
+ :param permission_required: Name of the required permission (e.g. "dcim.view_site")
+ """
+ obj_perm_attrs = user._object_perm_cache[permission_required]
+ attrs = Q()
+ for perm_attrs in obj_perm_attrs:
+ if perm_attrs:
+ attrs |= Q(**perm_attrs)
+ return queryset.filter(attrs)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index e73a55dc7..a86b5ccc5 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -8,7 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.auth.mixins import AccessMixin
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured, ObjectDoesNotExist, ValidationError
from django.db import transaction, IntegrityError
-from django.db.models import ManyToManyField, ProtectedError, Q
+from django.db.models import ManyToManyField, ProtectedError
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea
from django.http import HttpResponse, HttpResponseServerError
from django.shortcuts import get_object_or_404, redirect, render
@@ -26,10 +26,9 @@ from django_tables2 import RequestConfig
from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
-from users.models import ObjectPermission
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
-from utilities.permissions import get_permission_for_model
+from utilities.permissions import get_permission_for_model, restrict_queryset
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
@@ -67,12 +66,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
# Update the view's QuerySet to filter only the permitted objects
if user.is_authenticated and not user.is_superuser:
- obj_perm_attrs = user._object_perm_cache[permission_required]
- attrs = Q()
- for perm_attrs in obj_perm_attrs:
- if perm_attrs:
- attrs |= Q(**perm_attrs)
- self.queryset = self.queryset.filter(attrs)
+ self.queryset = restrict_queryset(self.queryset, user, permission_required)
return True
From 5b6a6fb63e2d5a67649a9db40450b1e835cda561 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 15:09:08 -0400
Subject: [PATCH 116/300] Move restrict_queryset() function to
RestrictedQuerySet
---
netbox/utilities/api.py | 5 ++---
netbox/utilities/permissions.py | 17 -----------------
netbox/utilities/querysets.py | 30 ++++++++++++++++++++++++++++++
netbox/utilities/views.py | 4 ++--
4 files changed, 34 insertions(+), 22 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index ef2650535..ac21d298c 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -16,7 +16,6 @@ from rest_framework.serializers import Field, ModelSerializer, ValidationError
from rest_framework.viewsets import ModelViewSet as _ModelViewSet
from netbox.api import TokenPermissions
-from utilities.permissions import restrict_queryset
from .utils import dict_to_filter_params, dynamic_import
@@ -339,8 +338,8 @@ class ModelViewSet(_ModelViewSet):
}
permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
- # Update the view's QuerySet to filter only the permitted objects
- self.queryset = restrict_queryset(self.queryset, request.user, permission_required)
+ # Restrict the view's QuerySet to allow only the permitted objects
+ self.queryset = self.queryset.restrict(request.user, permission_required)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index be5c0189e..697e18828 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -34,20 +34,3 @@ def resolve_permission(name):
raise ValueError(f"Unknown app/model for {name}")
return content_type, action
-
-
-def restrict_queryset(queryset, user, permission_required):
- """
- Filters a QuerySet to return only the objects on which the specified user has been granted the specified
- permission.
-
- :param queryset: Base QuerySet to be restricted
- :param user: User instance
- :param permission_required: Name of the required permission (e.g. "dcim.view_site")
- """
- obj_perm_attrs = user._object_perm_cache[permission_required]
- attrs = Q()
- for perm_attrs in obj_perm_attrs:
- if perm_attrs:
- attrs |= Q(**perm_attrs)
- return queryset.filter(attrs)
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 34b7a0cf3..36460310e 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -1,3 +1,6 @@
+from django.db.models import Q, QuerySet
+
+
class DummyQuerySet:
"""
A fake QuerySet that can be used to cache relationships to objects that have been deleted.
@@ -7,3 +10,30 @@ class DummyQuerySet:
def all(self):
return self._cache
+
+
+class RestrictedQuerySet(QuerySet):
+
+ def restrict(self, user, permission_required):
+ """
+ Filter the QuerySet to return only objects on which the specified user has been granted the specified
+ permission.
+
+ :param queryset: Base QuerySet to be restricted
+ :param user: User instance
+ :param permission_required: Name of the required permission (e.g. "dcim.view_site")
+ """
+
+ # Determine what constraints (if any) have been placed on this user for this action and model
+ # TODO: Find a better way to ensure permissions are cached
+ if not hasattr(user, '_object_perm_cache'):
+ user.get_all_permisisons()
+ obj_perm_attrs = user._object_perm_cache[permission_required]
+
+ # Filter the queryset to include only objects with allowed attributes
+ attrs = Q()
+ for perm_attrs in obj_perm_attrs:
+ if perm_attrs:
+ attrs |= Q(**perm_attrs)
+
+ return self.filter(attrs)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index a86b5ccc5..fed774812 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
-from utilities.permissions import get_permission_for_model, restrict_queryset
+from utilities.permissions import get_permission_for_model
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
@@ -66,7 +66,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
# Update the view's QuerySet to filter only the permitted objects
if user.is_authenticated and not user.is_superuser:
- self.queryset = restrict_queryset(self.queryset, user, permission_required)
+ self.queryset = self.queryset.restrict(user, permission_required)
return True
From e23b2c4c4fdf9d7c77145823648dc1c1ede7274e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 29 May 2020 16:27:36 -0400
Subject: [PATCH 117/300] Implement RestrictedQuerySet as a manager
---
netbox/circuits/models.py | 8 ++++-
netbox/circuits/querysets.py | 6 ++--
netbox/dcim/models/__init__.py | 35 +++++++++++++++++--
.../dcim/models/device_component_templates.py | 2 ++
netbox/dcim/models/device_components.py | 3 ++
netbox/extras/models/models.py | 3 ++
netbox/extras/models/tags.py | 3 ++
netbox/extras/querysets.py | 4 ++-
netbox/ipam/managers.py | 3 +-
netbox/ipam/models.py | 23 ++++++++----
netbox/ipam/querysets.py | 4 +--
netbox/secrets/models.py | 6 +++-
netbox/tenancy/models.py | 7 +++-
netbox/utilities/mptt.py | 19 ++++++++++
netbox/utilities/querysets.py | 2 +-
netbox/virtualization/models.py | 11 ++++--
16 files changed, 118 insertions(+), 21 deletions(-)
create mode 100644 netbox/utilities/mptt.py
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index 57d41a994..dcf1c5118 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -8,6 +8,7 @@ from dcim.fields import ASNField
from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
+from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .choices import *
@@ -66,9 +67,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
@@ -115,6 +117,8 @@ class CircuitType(ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py
index 60956f32a..8a9bd50a4 100644
--- a/netbox/circuits/querysets.py
+++ b/netbox/circuits/querysets.py
@@ -1,7 +1,9 @@
-from django.db.models import OuterRef, QuerySet, Subquery
+from django.db.models import OuterRef, Subquery
+
+from utilities.querysets import RestrictedQuerySet
-class CircuitQuerySet(QuerySet):
+class CircuitQuerySet(RestrictedQuerySet):
def annotate_sites(self):
"""
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 1f6478119..3dd3b8c89 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -25,7 +25,9 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta
from extras.utils import extras_features
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
from utilities.models import ChangeLoggedModel
+from utilities.mptt import TreeManager
from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator
from .device_component_templates import (
@@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel):
blank=True
)
+ objects = TreeManager()
+
csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta:
@@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
@@ -326,6 +332,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
blank=True
)
+ objects = TreeManager()
+
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
@@ -388,6 +396,8 @@ class RackRole(ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
@@ -526,6 +536,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
@@ -821,6 +833,8 @@ class RackReservation(ChangeLoggedModel):
max_length=200
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
class Meta:
@@ -900,6 +914,8 @@ class Manufacturer(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -982,9 +998,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
]
@@ -1206,6 +1223,8 @@ class DeviceRole(ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta:
@@ -1263,6 +1282,8 @@ class Platform(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta:
@@ -1429,6 +1450,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
@@ -1741,9 +1764,10 @@ class VirtualChassis(ChangeLoggedModel):
max_length=30,
blank=True
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['master', 'domain']
class Meta:
@@ -1813,6 +1837,8 @@ class PowerPanel(ChangeLoggedModel):
max_length=50
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['site', 'rack_group', 'name']
class Meta:
@@ -1916,9 +1942,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments',
@@ -2084,6 +2111,8 @@ class Cable(ChangeLoggedModel):
null=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 164d37d77..e412a602e 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from extras.models import ObjectChange
from utilities.fields import NaturalOrderingField
+from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
@@ -26,6 +27,7 @@ __all__ = (
class ComponentTemplateModel(models.Model):
+ objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 4005d41a4..702455c7e 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -16,6 +16,7 @@ from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
+from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
@@ -41,6 +42,8 @@ class ComponentModel(models.Model):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
class Meta:
abstract = True
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index f98a7b34f..a94fc3eea 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -12,6 +12,7 @@ from django.template import Template, Context
from django.urls import reverse
from rest_framework.utils.encoders import JSONEncoder
+from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge, render_jinja2
from extras.choices import *
from extras.constants import *
@@ -670,6 +671,8 @@ class ObjectChange(models.Model):
editable=False
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py
index d68ca2ce6..d5792ebda 100644
--- a/netbox/extras/models/tags.py
+++ b/netbox/extras/models/tags.py
@@ -6,6 +6,7 @@ from taggit.models import TagBase, GenericTaggedItemBase
from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel
+from utilities.querysets import RestrictedQuerySet
#
@@ -21,6 +22,8 @@ class Tag(TagBase, ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py
index 812c66714..9d9b55778 100644
--- a/netbox/extras/querysets.py
+++ b/netbox/extras/querysets.py
@@ -2,6 +2,8 @@ from collections import OrderedDict
from django.db.models import Q, QuerySet
+from utilities.querysets import RestrictedQuerySet
+
class CustomFieldQueryset:
"""
@@ -19,7 +21,7 @@ class CustomFieldQueryset:
yield obj
-class ConfigContextQuerySet(QuerySet):
+class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj):
"""
diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py
index 8811e504a..245a3c891 100644
--- a/netbox/ipam/managers.py
+++ b/netbox/ipam/managers.py
@@ -1,6 +1,7 @@
from django.db import models
from ipam.lookups import Host, Inet
+from utilities.querysets import RestrictedQuerySet
class IPAddressManager(models.Manager):
@@ -13,5 +14,5 @@ class IPAddressManager(models.Manager):
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128.
"""
- qs = super().get_queryset()
+ qs = RestrictedQuerySet(self.model, using=self._db)
return qs.order_by(Inet(Host('address')))
diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py
index eeb985b7c..b99a6c919 100644
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -12,6 +12,7 @@ from dcim.models import Device, Interface
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
+from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from virtualization.models import VirtualMachine
from .choices import *
@@ -74,9 +75,10 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
clone_fields = [
'tenant', 'enforce_unique', 'description',
@@ -131,6 +133,8 @@ class RIR(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'is_private', 'description']
class Meta:
@@ -179,9 +183,10 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['prefix', 'rir', 'date_added', 'description']
clone_fields = [
'rir', 'date_added', 'description',
@@ -274,6 +279,8 @@ class Role(ChangeLoggedModel):
blank=True,
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'weight', 'description']
class Meta:
@@ -360,9 +367,9 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager(through=TaggedItem)
objects = PrefixQuerySet.as_manager()
- tags = TaggableManager(through=TaggedItem)
csv_headers = [
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'description',
@@ -631,9 +638,9 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
+ tags = TaggableManager(through=TaggedItem)
objects = IPAddressManager()
- tags = TaggableManager(through=TaggedItem)
csv_headers = [
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
@@ -828,6 +835,8 @@ class VLANGroup(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'site', 'description']
class Meta:
@@ -923,9 +932,10 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description']
clone_fields = [
'site', 'group', 'tenant', 'status', 'role', 'description',
@@ -1039,9 +1049,10 @@ class Service(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'port', 'description']
class Meta:
diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py
index 3a48be789..6d2dc6f33 100644
--- a/netbox/ipam/querysets.py
+++ b/netbox/ipam/querysets.py
@@ -1,7 +1,7 @@
-from django.db.models import QuerySet
+from utilities.querysets import RestrictedQuerySet
-class PrefixQuerySet(QuerySet):
+class PrefixQuerySet(RestrictedQuerySet):
def annotate_depth(self, limit=None):
"""
diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py
index 61d8adb6b..757ef88c7 100644
--- a/netbox/secrets/models.py
+++ b/netbox/secrets/models.py
@@ -17,6 +17,7 @@ from dcim.models import Device
from extras.models import CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
+from utilities.querysets import RestrictedQuerySet
from .exceptions import InvalidKey
from .hashers import SecretValidationHasher
from .querysets import UserKeyQuerySet
@@ -268,6 +269,8 @@ class SecretRole(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -333,9 +336,10 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
plaintext = None
csv_headers = ['device', 'role', 'name', 'plaintext']
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index 077fb6ad1..2e415b965 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -7,6 +7,8 @@ from taggit.managers import TaggableManager
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
+from utilities.mptt import TreeManager
+from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
@@ -40,6 +42,8 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
blank=True
)
+ objects = TreeManager()
+
csv_headers = ['name', 'slug', 'parent', 'description']
class Meta:
@@ -104,9 +108,10 @@ class Tenant(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'group', 'description', 'comments']
clone_fields = [
'group', 'description',
diff --git a/netbox/utilities/mptt.py b/netbox/utilities/mptt.py
new file mode 100644
index 000000000..1bae2053d
--- /dev/null
+++ b/netbox/utilities/mptt.py
@@ -0,0 +1,19 @@
+from mptt.managers import TreeManager as TreeManager_
+from mptt.querysets import TreeQuerySet as TreeQuerySet_
+
+from django.db.models import Manager
+from .querysets import RestrictedQuerySet
+
+
+class TreeQuerySet(TreeQuerySet_, RestrictedQuerySet):
+ """
+ Mate django-mptt's TreeQuerySet with our RestrictedQuerySet for permissions enforcement.
+ """
+ pass
+
+
+class TreeManager(Manager.from_queryset(TreeQuerySet), TreeManager_):
+ """
+ Extend django-mptt's TreeManager to incorporate RestrictedQuerySet().
+ """
+ pass
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 36460310e..3bc41e072 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -27,7 +27,7 @@ class RestrictedQuerySet(QuerySet):
# Determine what constraints (if any) have been placed on this user for this action and model
# TODO: Find a better way to ensure permissions are cached
if not hasattr(user, '_object_perm_cache'):
- user.get_all_permisisons()
+ user.get_all_permissions()
obj_perm_attrs = user._object_perm_cache[permission_required]
# Filter the queryset to include only objects with allowed attributes
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 3daeff013..8ad40bab7 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -9,6 +9,7 @@ from dcim.models import Device
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
+from utilities.querysets import RestrictedQuerySet
from .choices import *
@@ -40,6 +41,8 @@ class ClusterType(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -79,6 +82,8 @@ class ClusterGroup(ChangeLoggedModel):
blank=True
)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -145,9 +150,10 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = ['name', 'type', 'group', 'site', 'comments']
clone_fields = [
'type', 'group', 'tenant', 'site',
@@ -269,9 +275,10 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
-
tags = TaggableManager(through=TaggedItem)
+ objects = RestrictedQuerySet.as_manager()
+
csv_headers = [
'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments',
]
From d23b18beb52289f8108388514e3580a0c168983d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 09:40:58 -0400
Subject: [PATCH 118/300] Fixes #4704: Update example template code
---
docs/additional-features/custom-links.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/additional-features/custom-links.md b/docs/additional-features/custom-links.md
index 7c96eba8b..56d67a7be 100644
--- a/docs/additional-features/custom-links.md
+++ b/docs/additional-features/custom-links.md
@@ -24,7 +24,7 @@ Only links which render with non-empty text are included on the page. You can em
For example, if you only want to display a link for active devices, you could set the link text to
```
-{% if obj.status == 1 %}View NMS{% endif %}
+{% if obj.status == 'active' %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."
From edf15532d25ab94e5e92827f098d6418b8d5be96 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 10:00:32 -0400
Subject: [PATCH 119/300] Fixes #4702: Catch IntegrityError exception when
adding a non-unique secret
---
docs/release-notes/version-2.8.md | 8 ++++++++
netbox/secrets/forms.py | 10 ++++++++++
2 files changed, 18 insertions(+)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 5ca86217a..fb177252f 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -1,5 +1,13 @@
# NetBox v2.8
+## v2.8.6 (FUTURE)
+
+### Bug Fixes
+
+* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
+
+---
+
## v2.8.5 (2020-05-26)
**Note:** The minimum required version of PostgreSQL is now 9.6.
diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py
index 089771bd8..296469900 100644
--- a/netbox/secrets/forms.py
+++ b/netbox/secrets/forms.py
@@ -115,6 +115,16 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
'plaintext2': "The two given plaintext values do not match. Please check your input."
})
+ # Validate uniqueness
+ if Secret.objects.filter(
+ device=self.cleaned_data['device'],
+ role=self.cleaned_data['role'],
+ name=self.cleaned_data['name']
+ ).exists():
+ raise forms.ValidationError(
+ "Each secret assigned to a device must have a unique combination of role and name"
+ )
+
class SecretCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField(
From 5574aaa8cb8556e6b2cbe2d29a9137da54f36c61 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 10:45:49 -0400
Subject: [PATCH 120/300] Tweak restrict() to accept only an action keyword
---
netbox/utilities/api.py | 20 ++++++++++++--------
netbox/utilities/querysets.py | 8 ++++++--
netbox/utilities/views.py | 3 ++-
3 files changed, 20 insertions(+), 11 deletions(-)
diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py
index ac21d298c..50401dfd1 100644
--- a/netbox/utilities/api.py
+++ b/netbox/utilities/api.py
@@ -330,16 +330,20 @@ class ModelViewSet(_ModelViewSet):
if not request.user.is_authenticated or request.user.is_superuser:
return
- # TODO: Move this to a cleaner function
- # Determine the required permission based on the request method
- kwargs = {
- 'app_label': self.queryset.model._meta.app_label,
- 'model_name': self.queryset.model._meta.model_name
- }
- permission_required = TokenPermissions.perms_map[request.method][0] % kwargs
+ # TODO: Reconcile this with TokenPermissions.perms_map
+ action = {
+ 'GET': 'view',
+ 'OPTIONS': None,
+ 'HEAD': 'view',
+ 'POST': 'add',
+ 'PUT': 'change',
+ 'PATCH': 'change',
+ 'DELETE': 'delete',
+ }[request.method]
# Restrict the view's QuerySet to allow only the permitted objects
- self.queryset = self.queryset.restrict(request.user, permission_required)
+ if action:
+ self.queryset = self.queryset.restrict(request.user, action)
def dispatch(self, request, *args, **kwargs):
logger = logging.getLogger('netbox.api.views.ModelViewSet')
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 3bc41e072..07199e143 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -14,15 +14,19 @@ class DummyQuerySet:
class RestrictedQuerySet(QuerySet):
- def restrict(self, user, permission_required):
+ def restrict(self, user, action):
"""
Filter the QuerySet to return only objects on which the specified user has been granted the specified
permission.
:param queryset: Base QuerySet to be restricted
:param user: User instance
- :param permission_required: Name of the required permission (e.g. "dcim.view_site")
+ :param action: The action which must be permitted (e.g. "view" for "dcim.view_site")
"""
+ # Resolve the full name of the required permission
+ app_label = self.model._meta.app_label
+ model_name = self.model._meta.model_name
+ permission_required = f'{app_label}.{action}_{model_name}'
# Determine what constraints (if any) have been placed on this user for this action and model
# TODO: Find a better way to ensure permissions are cached
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index fed774812..f59492a0c 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -66,7 +66,8 @@ class ObjectPermissionRequiredMixin(AccessMixin):
# Update the view's QuerySet to filter only the permitted objects
if user.is_authenticated and not user.is_superuser:
- self.queryset = self.queryset.restrict(user, permission_required)
+ action = permission_required.split('.')[1].split('_')[0]
+ self.queryset = self.queryset.restrict(user, action)
return True
From 3c334a0238fdb4631a4f8f9bdc5d2df5689564ba Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 11:43:49 -0400
Subject: [PATCH 121/300] Update views to restrict all querysets
---
netbox/circuits/views.py | 6 +-
netbox/dcim/views.py | 87 ++++++++++++++---------
netbox/extras/views.py | 12 ++--
netbox/ipam/managers.py | 7 +-
netbox/ipam/views.py | 34 ++++-----
netbox/tenancy/views.py | 22 +++---
netbox/utilities/querysets.py | 11 ++-
netbox/virtualization/tests/test_views.py | 1 -
netbox/virtualization/views.py | 6 +-
9 files changed, 108 insertions(+), 78 deletions(-)
diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py
index bb4d787c8..5da912f0a 100644
--- a/netbox/circuits/views.py
+++ b/netbox/circuits/views.py
@@ -33,7 +33,7 @@ class ProviderView(ObjectView):
def get(self, request, slug):
provider = get_object_or_404(self.queryset, slug=slug)
- circuits = Circuit.objects.filter(
+ circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider
).prefetch_related(
'type', 'tenant', 'terminations__site'
@@ -138,12 +138,12 @@ class CircuitView(ObjectView):
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
- termination_a = CircuitTermination.objects.prefetch_related(
+ termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
- termination_z = CircuitTermination.objects.prefetch_related(
+ termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index d8ef5a5e9..0f4297fd6 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -19,8 +19,9 @@ from django.views.generic import View
from circuits.models import Circuit
from extras.models import Graph
from extras.views import ObjectConfigContextView
-from ipam.models import Prefix, VLAN
+from ipam.models import Prefix, Service, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
+from secrets.models import Secret
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.permissions import get_permission_for_model
@@ -197,14 +198,16 @@ class SiteView(ObjectView):
site = get_object_or_404(self.queryset, slug=slug)
stats = {
- 'rack_count': Rack.objects.filter(site=site).count(),
- 'device_count': Device.objects.filter(site=site).count(),
- 'prefix_count': Prefix.objects.filter(site=site).count(),
- 'vlan_count': VLAN.objects.filter(site=site).count(),
- 'circuit_count': Circuit.objects.filter(terminations__site=site).count(),
- 'vm_count': VirtualMachine.objects.filter(cluster__site=site).count(),
+ 'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'device_count': Device.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(site=site).count(),
+ 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
+ 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
}
- rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks'))
+ rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
+ rack_count=Count('racks')
+ )
show_graphs = Graph.objects.filter(type__model='site').exists()
return render(request, 'dcim/site.html', {
@@ -372,7 +375,7 @@ class RackView(ObjectView):
rack = get_object_or_404(self.queryset, pk=pk)
- nonracked_devices = Device.objects.filter(
+ nonracked_devices = Device.objects.restrict(request.user, 'view').filter(
rack=rack,
position__isnull=True,
parent_bay__isnull=True
@@ -384,8 +387,8 @@ class RackView(ObjectView):
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
- reservations = RackReservation.objects.filter(rack=rack)
- power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
+ reservations = RackReservation.objects.restrict(request.user, 'view').filter(rack=rack)
+ power_feeds = PowerFeed.objects.restrict(request.user, 'view').filter(rack=rack).prefetch_related('power_panel')
return render(request, 'dcim/rack.html', {
'rack': rack,
@@ -558,35 +561,35 @@ class DeviceTypeView(ObjectView):
# Component tables
consoleport_table = tables.ConsolePortTemplateTable(
- ConsolePortTemplate.objects.filter(device_type=devicetype),
+ ConsolePortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
consoleserverport_table = tables.ConsoleServerPortTemplateTable(
- ConsoleServerPortTemplate.objects.filter(device_type=devicetype),
+ ConsoleServerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
powerport_table = tables.PowerPortTemplateTable(
- PowerPortTemplate.objects.filter(device_type=devicetype),
+ PowerPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
poweroutlet_table = tables.PowerOutletTemplateTable(
- PowerOutletTemplate.objects.filter(device_type=devicetype),
+ PowerOutletTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
interface_table = tables.InterfaceTemplateTable(
- list(InterfaceTemplate.objects.filter(device_type=devicetype)),
+ list(InterfaceTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype)),
orderable=False
)
front_port_table = tables.FrontPortTemplateTable(
- FrontPortTemplate.objects.filter(device_type=devicetype),
+ FrontPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
rear_port_table = tables.RearPortTemplateTable(
- RearPortTemplate.objects.filter(device_type=devicetype),
+ RearPortTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
devicebay_table = tables.DeviceBayTemplateTable(
- DeviceBayTemplate.objects.filter(device_type=devicetype),
+ DeviceBayTemplate.objects.restrict(request.user, 'view').filter(device_type=devicetype),
orderable=False
)
if request.user.has_perm('dcim.change_devicetype'):
@@ -995,47 +998,61 @@ class DeviceView(ObjectView):
# VirtualChassis members
if device.virtual_chassis is not None:
- vc_members = Device.objects.filter(
+ vc_members = Device.objects.restrict(request.user, 'view').filter(
virtual_chassis=device.virtual_chassis
).order_by('vc_position')
else:
vc_members = []
# Console ports
- console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable')
+ console_ports = ConsolePort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'connected_endpoint__device', 'cable',
+ )
# Console server ports
- consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable')
+ consoleserverports = ConsoleServerPort.objects.restrict(request.user, 'view').filter(
+ device=device
+ ).prefetch_related(
+ 'connected_endpoint__device', 'cable',
+ )
# Power ports
- power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable')
+ power_ports = PowerPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ '_connected_poweroutlet__device', 'cable',
+ )
# Power outlets
- poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port')
+ poweroutlets = PowerOutlet.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'connected_endpoint__device', 'cable', 'power_port',
+ )
# Interfaces
- interfaces = device.vc_interfaces.prefetch_related(
+ interfaces = device.vc_interfaces.restrict(request.user, 'view').filter(device=device).prefetch_related(
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
)
# Front ports
- front_ports = device.frontports.prefetch_related('rear_port', 'cable')
+ front_ports = FrontPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'rear_port', 'cable',
+ )
# Rear ports
- rear_ports = device.rearports.prefetch_related('cable')
+ rear_ports = RearPort.objects.restrict(request.user, 'view').filter(device=device).prefetch_related('cable')
# Device bays
- device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer')
+ device_bays = DeviceBay.objects.restrict(request.user, 'view').filter(device=device).prefetch_related(
+ 'installed_device__device_type__manufacturer',
+ )
# Services
- services = device.services.all()
+ services = Service.objects.restrict(request.user, 'view').filter(device=device)
# Secrets
- secrets = device.secrets.all()
+ secrets = Secret.objects.restrict(request.user, 'view').filter(device=device)
# Find up to ten devices in the same site with the same functional role for quick reference.
- related_devices = Device.objects.filter(
+ related_devices = Device.objects.restrict(request.user, 'view').filter(
site=device.site, device_role=device.device_role
).exclude(
pk=device.pk
@@ -1068,7 +1085,7 @@ class DeviceInventoryView(ObjectView):
def get(self, request, pk):
device = get_object_or_404(self.queryset, pk=pk)
- inventory_items = InventoryItem.objects.filter(
+ inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
device=device, parent=None
).prefetch_related(
'manufacturer', 'child_items'
@@ -1102,7 +1119,9 @@ class DeviceLLDPNeighborsView(ObjectView):
def get(self, request, pk):
device = get_object_or_404(self.queryset, pk=pk)
- interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
+ interfaces = device.vc_interfaces.restrict(request.user, 'view').exclude(
+ type__in=NONCONNECTABLE_IFACE_TYPES
+ ).prefetch_related(
'_connected_interface__device'
)
@@ -1423,7 +1442,7 @@ class InterfaceView(ObjectView):
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
- data=interface.ip_addresses.prefetch_related('vrf', 'tenant'),
+ data=interface.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'),
orderable=False
)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index 9abf96f26..a607a4df8 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -163,7 +163,7 @@ class ObjectConfigContextView(ObjectView):
def get(self, request, pk):
obj = get_object_or_404(self.queryset, pk=pk)
- source_contexts = ConfigContext.objects.get_for_object(obj)
+ source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
model_name = self.queryset.model._meta.model_name
# Determine user's preferred output format
@@ -207,13 +207,17 @@ class ObjectChangeView(ObjectView):
objectchange = get_object_or_404(self.queryset, pk=pk)
- related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
+ related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
+ request_id=objectchange.request_id
+ ).exclude(
+ pk=objectchange.pk
+ )
related_changes_table = ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
- objectchanges = ObjectChange.objects.filter(
+ objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
changed_object_type=objectchange.changed_object_type,
changed_object_id=objectchange.changed_object_id,
)
@@ -255,7 +259,7 @@ class ObjectChangeLogView(View):
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
- objectchanges = ObjectChange.objects.prefetch_related(
+ objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
'user', 'changed_object_type'
).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py
index 245a3c891..1ef00e125 100644
--- a/netbox/ipam/managers.py
+++ b/netbox/ipam/managers.py
@@ -1,10 +1,10 @@
-from django.db import models
+from django.db.models import Manager
from ipam.lookups import Host, Inet
from utilities.querysets import RestrictedQuerySet
-class IPAddressManager(models.Manager):
+class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
def get_queryset(self):
"""
@@ -14,5 +14,4 @@ class IPAddressManager(models.Manager):
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128.
"""
- qs = RestrictedQuerySet(self.model, using=self._db)
- return qs.order_by(Inet(Host('address')))
+ return super().get_queryset().order_by(Inet(Host('address')))
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index d3b604be6..98fe1d73d 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -3,14 +3,13 @@ from django.conf import settings
from django.db.models import Count, Q
from django.db.models.expressions import RawSQL
from django.shortcuts import get_object_or_404, redirect, render
-from django.views.generic import View
from django_tables2 import RequestConfig
from dcim.models import Device, Interface
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView,
- ObjectListView, ObjectPermissionRequiredMixin,
+ ObjectListView,
)
from virtualization.models import VirtualMachine
from . import filters, forms, tables
@@ -125,7 +124,7 @@ class VRFView(ObjectView):
def get(self, request, pk):
vrf = get_object_or_404(self.queryset, pk=pk)
- prefix_count = Prefix.objects.filter(vrf=vrf).count()
+ prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=vrf).count()
return render(request, 'ipam/vrf.html', {
'vrf': vrf,
@@ -305,7 +304,7 @@ class AggregateView(ObjectView):
aggregate = get_object_or_404(self.queryset, pk=pk)
# Find all child prefixes contained by this aggregate
- child_prefixes = Prefix.objects.filter(
+ child_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
prefix__net_contained_or_equal=str(aggregate.prefix)
).prefetch_related(
'site', 'role'
@@ -429,12 +428,14 @@ class PrefixView(ObjectView):
prefix = get_object_or_404(self.queryset, pk=pk)
try:
- aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix))
+ aggregate = Aggregate.objects.restrict(request.user, 'view').get(
+ prefix__net_contains_or_equals=str(prefix.prefix)
+ )
except Aggregate.DoesNotExist:
aggregate = None
# Parent prefixes table
- parent_prefixes = Prefix.objects.filter(
+ parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
Q(vrf=prefix.vrf) | Q(vrf__isnull=True)
).filter(
prefix__net_contains=str(prefix.prefix)
@@ -445,7 +446,7 @@ class PrefixView(ObjectView):
parent_prefix_table.exclude = ('vrf',)
# Duplicate prefixes table
- duplicate_prefixes = Prefix.objects.filter(
+ duplicate_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=prefix.vrf, prefix=str(prefix.prefix)
).exclude(
pk=prefix.pk
@@ -471,7 +472,7 @@ class PrefixPrefixesView(ObjectView):
prefix = get_object_or_404(self.queryset, pk=pk)
# Child prefixes table
- child_prefixes = prefix.get_child_prefixes().prefetch_related(
+ child_prefixes = prefix.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
'site', 'vlan', 'role',
).annotate_depth(limit=0)
@@ -515,7 +516,7 @@ class PrefixIPAddressesView(ObjectView):
prefix = get_object_or_404(self.queryset, pk=pk)
# Find all IPAddresses belonging to this Prefix
- ipaddresses = prefix.get_child_ips().prefetch_related(
+ ipaddresses = prefix.get_child_ips().restrict(request.user, 'view').prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
)
@@ -607,7 +608,7 @@ class IPAddressView(ObjectView):
ipaddress = get_object_or_404(self.queryset, pk=pk)
# Parent prefixes table
- parent_prefixes = Prefix.objects.filter(
+ parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)
).prefetch_related(
'site', 'role'
@@ -616,7 +617,7 @@ class IPAddressView(ObjectView):
parent_prefixes_table.exclude = ('vrf',)
# Duplicate IPs table
- duplicate_ips = IPAddress.objects.filter(
+ duplicate_ips = IPAddress.objects.restrict(request.user, 'view').filter(
vrf=ipaddress.vrf, address=str(ipaddress.address)
).exclude(
pk=ipaddress.pk
@@ -629,14 +630,13 @@ class IPAddressView(ObjectView):
duplicate_ips_table = tables.IPAddressTable(list(duplicate_ips), orderable=False)
# Related IP table
- related_ips = IPAddress.objects.prefetch_related(
+ related_ips = IPAddress.objects.restrict(request.user, 'view').prefetch_related(
'interface__device'
).exclude(
address=str(ipaddress.address)
).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
)
-
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate = {
@@ -785,7 +785,7 @@ class VLANGroupVLANsView(ObjectView):
def get(self, request, pk):
vlan_group = get_object_or_404(self.queryset, pk=pk)
- vlans = VLAN.objects.filter(group_id=pk)
+ vlans = VLAN.objects.restrict(request.user, 'view').filter(group_id=pk)
vlans = add_available_vlans(vlan_group, vlans)
vlan_table = tables.VLANDetailTable(vlans)
@@ -832,7 +832,9 @@ class VLANView(ObjectView):
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
- prefixes = Prefix.objects.filter(vlan=vlan).prefetch_related('vrf', 'site', 'role')
+ prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=vlan).prefetch_related(
+ 'vrf', 'site', 'role'
+ )
prefix_table = tables.PrefixTable(list(prefixes), orderable=False)
prefix_table.exclude = ('vlan',)
@@ -848,7 +850,7 @@ class VLANMembersView(ObjectView):
def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk)
- members = vlan.get_members().prefetch_related('device', 'virtual_machine')
+ members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
members_table = tables.VLANMemberTable(members)
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py
index 823df9933..a82b231f5 100644
--- a/netbox/tenancy/views.py
+++ b/netbox/tenancy/views.py
@@ -64,17 +64,17 @@ class TenantView(ObjectView):
tenant = get_object_or_404(self.queryset, slug=slug)
stats = {
- 'site_count': Site.objects.filter(tenant=tenant).count(),
- 'rack_count': Rack.objects.filter(tenant=tenant).count(),
- 'rackreservation_count': RackReservation.objects.filter(tenant=tenant).count(),
- 'device_count': Device.objects.filter(tenant=tenant).count(),
- 'vrf_count': VRF.objects.filter(tenant=tenant).count(),
- 'prefix_count': Prefix.objects.filter(tenant=tenant).count(),
- 'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(),
- 'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
- 'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
- 'virtualmachine_count': VirtualMachine.objects.filter(tenant=tenant).count(),
- 'cluster_count': Cluster.objects.filter(tenant=tenant).count(),
+ 'site_count': Site.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'rack_count': Rack.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'rackreservation_count': RackReservation.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
+ 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=tenant).count(),
}
return render(request, 'tenancy/tenant.html', {
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 07199e143..6649e4d9c 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -28,15 +28,22 @@ class RestrictedQuerySet(QuerySet):
model_name = self.model._meta.model_name
permission_required = f'{app_label}.{action}_{model_name}'
+ # TODO: Handle anonymous users
+ if not user.is_authenticated:
+ return self
+
# Determine what constraints (if any) have been placed on this user for this action and model
# TODO: Find a better way to ensure permissions are cached
if not hasattr(user, '_object_perm_cache'):
user.get_all_permissions()
- obj_perm_attrs = user._object_perm_cache[permission_required]
+
+ # User has not been granted any permission
+ if permission_required not in user._object_perm_cache:
+ return self.none()
# Filter the queryset to include only objects with allowed attributes
attrs = Q()
- for perm_attrs in obj_perm_attrs:
+ for perm_attrs in user._object_perm_cache[permission_required]:
if perm_attrs:
attrs |= Q(**perm_attrs)
diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py
index 9fde12186..5cd19381f 100644
--- a/netbox/virtualization/tests/test_views.py
+++ b/netbox/virtualization/tests/test_views.py
@@ -187,7 +187,6 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# TODO: Update base class to DeviceComponentViewTestCase
class InterfaceTestCase(
- ViewTestCases.GetObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.BulkCreateObjectsViewTestCase,
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 79a807c21..aea4d0556 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -89,7 +89,7 @@ class ClusterView(ObjectView):
def get(self, request, pk):
cluster = get_object_or_404(self.queryset, pk=pk)
- devices = Device.objects.filter(cluster=cluster).prefetch_related(
+ devices = Device.objects.restrict(request.user, 'view').filter(cluster=cluster).prefetch_related(
'site', 'rack', 'tenant', 'device_type__manufacturer'
)
device_table = DeviceTable(list(devices), orderable=False)
@@ -235,8 +235,8 @@ class VirtualMachineView(ObjectView):
def get(self, request, pk):
virtualmachine = get_object_or_404(self.queryset, pk=pk)
- interfaces = Interface.objects.filter(virtual_machine=virtualmachine)
- services = Service.objects.filter(virtual_machine=virtualmachine)
+ interfaces = Interface.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
+ services = Service.objects.restrict(request.user, 'view').filter(virtual_machine=virtualmachine)
return render(request, 'virtualization/virtualmachine.html', {
'virtualmachine': virtualmachine,
From 9679557747b456efc2caa4bf790008b0b6231840 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 12:31:18 -0400
Subject: [PATCH 122/300] Add permission_is_exempt()
---
netbox/utilities/permissions.py | 24 +++++++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index 697e18828..de024cf99 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -1,5 +1,5 @@
+from django.conf import settings
from django.contrib.contenttypes.models import ContentType
-from django.db.models import Q
def get_permission_for_model(model, action):
@@ -34,3 +34,25 @@ def resolve_permission(name):
raise ValueError(f"Unknown app/model for {name}")
return content_type, action
+
+
+def permission_is_exempt(name):
+ """
+ Determine whether a specified permission is exempt from evaluation.
+
+ :param name: Permission name in the format ._
+ """
+ app_label, codename = name.split('.')
+ action, model_name = codename.split('_')
+
+ if action == 'view':
+ if (
+ # All models are exempt from view permission enforcement
+ '*' in settings.EXEMPT_VIEW_PERMISSIONS
+ ) or (
+ # This specific model is exempt from view permission enforcement
+ '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS
+ ):
+ return True
+
+ return False
From 3a9512f086fdb46a386639d8f98d5aa4fdcdb5f8 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 13:09:34 -0400
Subject: [PATCH 123/300] Refine queryset restriction logic
---
netbox/utilities/permissions.py | 18 ++++++++++++++++--
netbox/utilities/querysets.py | 16 ++++++----------
netbox/utilities/views.py | 16 ++++++++--------
3 files changed, 30 insertions(+), 20 deletions(-)
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index de024cf99..38064b689 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -19,12 +19,26 @@ def get_permission_for_model(model, action):
)
+def get_permission_action(name):
+ """
+ Return the action component (e.g. view or add) from a permission name.
+
+ :param name: Permission name in the format ._
+ """
+ try:
+ return name.split('.')[1].split('_')[0]
+ except ValueError:
+ raise ValueError(
+ f"Invalid permission name: {name}. Must be in the format ._"
+ )
+
+
def resolve_permission(name):
"""
Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns
(Site, "view").
- :param name: Permission name in the format ._
+ :param name: Permission name in the format ._
"""
app_label, codename = name.split('.')
action, model_name = codename.split('_')
@@ -40,7 +54,7 @@ def permission_is_exempt(name):
"""
Determine whether a specified permission is exempt from evaluation.
- :param name: Permission name in the format ._
+ :param name: Permission name in the format ._
"""
app_label, codename = name.split('.')
action, model_name = codename.split('_')
diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py
index 6649e4d9c..1ac79e90a 100644
--- a/netbox/utilities/querysets.py
+++ b/netbox/utilities/querysets.py
@@ -1,5 +1,7 @@
from django.db.models import Q, QuerySet
+from utilities.permissions import permission_is_exempt
+
class DummyQuerySet:
"""
@@ -19,7 +21,6 @@ class RestrictedQuerySet(QuerySet):
Filter the QuerySet to return only objects on which the specified user has been granted the specified
permission.
- :param queryset: Base QuerySet to be restricted
:param user: User instance
:param action: The action which must be permitted (e.g. "view" for "dcim.view_site")
"""
@@ -28,17 +29,12 @@ class RestrictedQuerySet(QuerySet):
model_name = self.model._meta.model_name
permission_required = f'{app_label}.{action}_{model_name}'
- # TODO: Handle anonymous users
- if not user.is_authenticated:
+ # Bypass restriction for superusers and exempt views
+ if user.is_superuser or permission_is_exempt(permission_required):
return self
- # Determine what constraints (if any) have been placed on this user for this action and model
- # TODO: Find a better way to ensure permissions are cached
- if not hasattr(user, '_object_perm_cache'):
- user.get_all_permissions()
-
- # User has not been granted any permission
- if permission_required not in user._object_perm_cache:
+ # User is anonymous or has not been granted the requisite permission
+ if not user.is_authenticated or permission_required not in user.get_all_permissions():
return self.none()
# Filter the queryset to include only objects with allowed attributes
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index f59492a0c..0304780f3 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
-from utilities.permissions import get_permission_for_model
+from utilities.permissions import get_permission_action, get_permission_for_model
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
@@ -60,16 +60,16 @@ class ObjectPermissionRequiredMixin(AccessMixin):
user = self.request.user
permission_required = self.get_required_permission()
- # First, check that the user is granted the required permission(s) at either the model or object level.
- if not user.has_perms((permission_required, *self.additional_permissions)):
- return False
+ # Check that the user has been granted the required permission(s).
+ if user.has_perms((permission_required, *self.additional_permissions)):
- # Update the view's QuerySet to filter only the permitted objects
- if user.is_authenticated and not user.is_superuser:
- action = permission_required.split('.')[1].split('_')[0]
+ # Update the view's QuerySet to filter only the permitted objects
+ action = get_permission_action(permission_required)
self.queryset = self.queryset.restrict(user, action)
- return True
+ return True
+
+ return False
def dispatch(self, request, *args, **kwargs):
From b6c38ceb732653cc9ad875385799713098d36d2d Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 13:17:59 -0400
Subject: [PATCH 124/300] Call permission_is_exempt() to check for exempt
permissions
---
netbox/utilities/auth_backends.py | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py
index bc263480f..1522e6268 100644
--- a/netbox/utilities/auth_backends.py
+++ b/netbox/utilities/auth_backends.py
@@ -6,14 +6,14 @@ from django.contrib.auth.models import Group
from django.db.models import Q
from users.models import ObjectPermission
-from utilities.permissions import resolve_permission
+from utilities.permissions import permission_is_exempt, resolve_permission
class ObjectPermissionBackend(ModelBackend):
def get_all_permissions(self, user_obj, obj=None):
if not user_obj.is_active or user_obj.is_anonymous:
- return set()
+ return dict()
if not hasattr(user_obj, '_object_perm_cache'):
user_obj._object_perm_cache = self.get_object_permissions(user_obj)
return user_obj._object_perm_cache
@@ -49,16 +49,9 @@ class ObjectPermissionBackend(ModelBackend):
if user_obj.is_active and user_obj.is_superuser:
return True
- # If this is a view permission, check whether the model has been exempted from enforcement
- if action == 'view':
- if (
- # All models are exempt from view permission enforcement
- '*' in settings.EXEMPT_VIEW_PERMISSIONS
- ) or (
- # This specific model is exempt from view permission enforcement
- '{}.{}'.format(app_label, model_name) in settings.EXEMPT_VIEW_PERMISSIONS
- ):
- return True
+ # Permission is exempt from enforcement (i.e. listed in EXEMPT_VIEW_PERMISSIONS)
+ if permission_is_exempt(perm):
+ return True
# Handle inactive/anonymous users
if not user_obj.is_active or user_obj.is_anonymous:
From a4af270ea8648892175a03f881fcd04bcef741fb Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 13:36:57 -0400
Subject: [PATCH 125/300] Restrict querysets for home, search views
---
netbox/netbox/views.py | 45 +++++++++++++++++++++---------------------
1 file changed, 22 insertions(+), 23 deletions(-)
diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py
index 37a516409..d6be844d4 100644
--- a/netbox/netbox/views.py
+++ b/netbox/netbox/views.py
@@ -194,52 +194,51 @@ class HomeView(View):
def get(self, request):
- connected_consoleports = ConsolePort.objects.filter(
+ connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').filter(
connected_endpoint__isnull=False
)
- connected_powerports = PowerPort.objects.filter(
+ connected_powerports = PowerPort.objects.restrict(request.user, 'view').filter(
_connected_poweroutlet__isnull=False
)
- connected_interfaces = Interface.objects.filter(
+ connected_interfaces = Interface.objects.restrict(request.user, 'view').filter(
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
)
- cables = Cable.objects.all()
stats = {
# Organization
- 'site_count': Site.objects.count(),
- 'tenant_count': Tenant.objects.count(),
+ 'site_count': Site.objects.restrict(request.user, 'view').count(),
+ 'tenant_count': Tenant.objects.restrict(request.user, 'view').count(),
# DCIM
- 'rack_count': Rack.objects.count(),
- 'devicetype_count': DeviceType.objects.count(),
- 'device_count': Device.objects.count(),
+ 'rack_count': Rack.objects.restrict(request.user, 'view').count(),
+ 'devicetype_count': DeviceType.objects.restrict(request.user, 'view').count(),
+ 'device_count': Device.objects.restrict(request.user, 'view').count(),
'interface_connections_count': connected_interfaces.count(),
- 'cable_count': cables.count(),
+ 'cable_count': Cable.objects.restrict(request.user, 'view').count(),
'console_connections_count': connected_consoleports.count(),
'power_connections_count': connected_powerports.count(),
- 'powerpanel_count': PowerPanel.objects.count(),
- 'powerfeed_count': PowerFeed.objects.count(),
+ 'powerpanel_count': PowerPanel.objects.restrict(request.user, 'view').count(),
+ 'powerfeed_count': PowerFeed.objects.restrict(request.user, 'view').count(),
# IPAM
- 'vrf_count': VRF.objects.count(),
- 'aggregate_count': Aggregate.objects.count(),
- 'prefix_count': Prefix.objects.count(),
- 'ipaddress_count': IPAddress.objects.count(),
- 'vlan_count': VLAN.objects.count(),
+ 'vrf_count': VRF.objects.restrict(request.user, 'view').count(),
+ 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').count(),
+ 'prefix_count': Prefix.objects.restrict(request.user, 'view').count(),
+ 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').count(),
+ 'vlan_count': VLAN.objects.restrict(request.user, 'view').count(),
# Circuits
- 'provider_count': Provider.objects.count(),
- 'circuit_count': Circuit.objects.count(),
+ 'provider_count': Provider.objects.restrict(request.user, 'view').count(),
+ 'circuit_count': Circuit.objects.restrict(request.user, 'view').count(),
# Secrets
- 'secret_count': Secret.objects.count(),
+ 'secret_count': Secret.objects.restrict(request.user, 'view').count(),
# Virtualization
- 'cluster_count': Cluster.objects.count(),
- 'virtualmachine_count': VirtualMachine.objects.count(),
+ 'cluster_count': Cluster.objects.restrict(request.user, 'view').count(),
+ 'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').count(),
}
@@ -293,7 +292,7 @@ class SearchView(View):
for obj_type in obj_types:
- queryset = SEARCH_TYPES[obj_type]['queryset']
+ queryset = SEARCH_TYPES[obj_type]['queryset'].restrict(request.user, 'view')
filterset = SEARCH_TYPES[obj_type]['filterset']
table = SEARCH_TYPES[obj_type]['table']
url = SEARCH_TYPES[obj_type]['url']
From 26d7c213140c82dca928e370167b6e2a70b901fe Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 13:47:34 -0400
Subject: [PATCH 126/300] Move authentication backends
---
docs/configuration/optional-settings.md | 2 +-
.../{utilities/auth_backends.py => netbox/authentication.py} | 0
netbox/netbox/configuration.example.py | 2 +-
netbox/netbox/settings.py | 4 ++--
4 files changed, 4 insertions(+), 4 deletions(-)
rename netbox/{utilities/auth_backends.py => netbox/authentication.py} (100%)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 3c4392915..7c4a7c9c2 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -384,7 +384,7 @@ NetBox can be configured to support remote user authentication by inferring user
## REMOTE_AUTH_BACKEND
-Default: `'utilities.auth_backends.RemoteUserBackend'`
+Default: `'netbox.authentication.RemoteUserBackend'`
Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
diff --git a/netbox/utilities/auth_backends.py b/netbox/netbox/authentication.py
similarity index 100%
rename from netbox/utilities/auth_backends.py
rename to netbox/netbox/authentication.py
diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py
index 941cbcd88..0803efb2a 100644
--- a/netbox/netbox/configuration.example.py
+++ b/netbox/netbox/configuration.example.py
@@ -205,7 +205,7 @@ PREFER_IPV4 = False
# Remote authentication support
REMOTE_AUTH_ENABLED = False
-REMOTE_AUTH_BACKEND = 'utilities.auth_backends.RemoteUserBackend'
+REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER'
REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = []
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 3b345638b..6199ede27 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -97,7 +97,7 @@ PLUGINS = getattr(configuration, 'PLUGINS', [])
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
-REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'utilities.auth_backends.RemoteUserBackend')
+REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', [])
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
@@ -339,7 +339,7 @@ TEMPLATES = [
# Set up authentication backends
AUTHENTICATION_BACKENDS = [
REMOTE_AUTH_BACKEND,
- 'utilities.auth_backends.ObjectPermissionBackend',
+ 'netbox.authentication.ObjectPermissionBackend',
]
# Internationalization
From 5d4cc5bf3d278cb124a5cb05d3b302d87a0c9c4b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 13:59:58 -0400
Subject: [PATCH 127/300] Fix ordering of group and user fields in
ObjectPermission admin
---
netbox/users/admin.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index c1b659a8e..4c3da5acd 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -90,7 +90,9 @@ class ObjectPermissionForm(forms.ModelForm):
model = ObjectPermission
exclude = []
help_texts = {
- 'actions': 'Actions granted in addition to those listed above'
+ 'actions': 'Actions granted in addition to those listed above',
+ 'attrs': 'JSON expression of a queryset filter that will return only permitted objects. Leave null to '
+ 'match all objects of this type.'
}
labels = {
'actions': 'Additional actions'
@@ -106,6 +108,10 @@ class ObjectPermissionForm(forms.ModelForm):
order_content_types(self.fields['content_types'])
self.fields['content_types'].choices.insert(0, ('', '---------'))
+ # Order group and user fields
+ self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
+ self.fields['users'].queryset = self.fields['users'].queryset.order_by('username')
+
# Check the appropriate checkboxes when editing an existing ObjectPermission
if self.instance.pk:
for action in ['view', 'add', 'change', 'delete']:
From e9831442cd770270008c060e0146692931319d10 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 15:28:36 -0400
Subject: [PATCH 128/300] Drafted documentation for object-based permissions
---
docs/administration/permissions.md | 76 ++++++++++++++++++++++++++++++
mkdocs.yml | 1 +
2 files changed, 77 insertions(+)
create mode 100644 docs/administration/permissions.md
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
new file mode 100644
index 000000000..582709726
--- /dev/null
+++ b/docs/administration/permissions.md
@@ -0,0 +1,76 @@
+# Permissions
+
+NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
+
+Assigning a permission in NetBox entails defining a relationship among several components:
+
+* Model(s) - One or more types of object in NetBox
+* User(s) - One or more users or groups of users
+* Actions - The actions that can be performed (view, add, change, and/or delete)
+* Attributes - An arbitrary filter used to limit the action to a specific subset of objects
+
+At a minimum, a permission assignment must specify one model, one user or group, and one action. The specification of constraining attributes is optional: A permission without any attributes specified will apply to all instances of the selected model(s).
+
+## Actions
+
+There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
+
+* View - Retrieve an object from the database
+* Add - Create a new object
+* Change - Modify an existing object
+* Delete - Delete an existing object
+
+Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
+
+## Attributes
+
+Constraining attributes are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
+
+All attributes defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following attributes.
+
+```json
+{
+ "status": "active",
+ "region__name": "Americas"
+}
+```
+
+The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of attributes, simply create another permission assignment for the same model and user/group.
+
+### Example Attribute Definitions
+
+| Query Filter | Permission Attributes |
+| ------------ | --------------------- |
+| `filter(status='active')` | `{"status": "active"}` |
+| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` |
+| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` |
+| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` |
+| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` |
+
+## Permissions Enforcement
+
+### Viewing Objects
+
+Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
+
+If the permission has been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
+
+```json
+[
+ {"site__name__in": ["NYC1", "NYC2"]},
+ {"status": "offline", "tenant__isnull": true}
+]
+```
+
+This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These attributes will result in the following ORM query:
+
+```no-highlight
+Site.objects.filter(
+ Q(site__name__in=['NYC1', 'NYC2']),
+ Q(status='active', tenant__isnull=True)
+)
+```
+
+### Creating and Modifying Objects
+
+The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the attributes granted by the permission. The transaction is then aborted, and the database is left in its original state.
diff --git a/mkdocs.yml b/mkdocs.yml
index b8633ea8f..2c58acbd8 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -58,6 +58,7 @@ nav:
- Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md'
- Administration:
+ - Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- API:
From 76f74f479ba74e86075aef2eda34dfa7d5f58dd0 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 16:23:45 -0400
Subject: [PATCH 129/300] Support permission attribute assignment via
REMOTE_AUTH_DEFAULT_PERMISSIONS
---
docs/configuration/optional-settings.md | 4 ++--
netbox/netbox/authentication.py | 6 +++---
netbox/netbox/configuration.example.py | 2 +-
netbox/netbox/settings.py | 13 ++++++++++++-
4 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 7c4a7c9c2..31ee39a5f 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -416,9 +416,9 @@ The list of groups to assign a new user account when created using remote authen
## REMOTE_AUTH_DEFAULT_PERMISSIONS
-Default: `[]` (Empty list)
+Default: `{}` (Empty dictionary)
-The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
+A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
---
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 1522e6268..4e9078a9a 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -112,18 +112,18 @@ class RemoteUserBackend(_RemoteUserBackend):
# Assign default object permissions to the user
permissions_list = []
- for permission_name in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS:
+ for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
content_type, action = resolve_permission(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
- obj_perm = ObjectPermission(actions=[action])
+ obj_perm = ObjectPermission(actions=[action], attrs=attrs)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.content_types.add(content_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
- "Invalid permission name: '{permission_name}'. Permissions must be in the form "
+ f"Invalid permission name: '{permission_name}'. Permissions must be in the form "
"._. (Example: dcim.add_site)"
)
if permissions_list:
diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py
index 0803efb2a..7b39fb19e 100644
--- a/netbox/netbox/configuration.example.py
+++ b/netbox/netbox/configuration.example.py
@@ -209,7 +209,7 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
REMOTE_AUTH_HEADER = 'HTTP_REMOTE_USER'
REMOTE_AUTH_AUTO_CREATE_USER = True
REMOTE_AUTH_DEFAULT_GROUPS = []
-REMOTE_AUTH_DEFAULT_PERMISSIONS = []
+REMOTE_AUTH_DEFAULT_PERMISSIONS = {}
# This determines how often the GitHub API is called to check the latest release of NetBox. Must be at least 1 hour.
RELEASE_CHECK_TIMEOUT = 24 * 3600
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 6199ede27..692382262 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -99,7 +99,7 @@ PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False)
REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend')
REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', [])
-REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', [])
+REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {})
REMOTE_AUTH_ENABLED = getattr(configuration, 'REMOTE_AUTH_ENABLED', False)
REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_USER')
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
@@ -127,6 +127,17 @@ if RELEASE_CHECK_URL:
if RELEASE_CHECK_TIMEOUT < 3600:
raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
+# TODO: Remove in v2.10
+# Backward compatibility for REMOTE_AUTH_DEFAULT_PERMISSIONS
+if type(REMOTE_AUTH_DEFAULT_PERMISSIONS) is not dict:
+ try:
+ REMOTE_AUTH_DEFAULT_PERMISSIONS = {perm: None for perm in REMOTE_AUTH_DEFAULT_PERMISSIONS}
+ warnings.warn(
+ "REMOTE_AUTH_DEFAULT_PERMISSIONS should be a dictionary. Backward compatibility will be removed in v2.10."
+ )
+ except TypeError:
+ raise ImproperlyConfigured("REMOTE_AUTH_DEFAULT_PERMISSIONS must be a dictionary.")
+
#
# Database
From 32620dd5563507877522c6072055920d9c0ae5b8 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 16:30:20 -0400
Subject: [PATCH 130/300] Changelog for #554
---
docs/release-notes/version-2.9.md | 13 +++++++++++++
1 file changed, 13 insertions(+)
create mode 100644 docs/release-notes/version-2.9.md
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
new file mode 100644
index 000000000..b6cc699d4
--- /dev/null
+++ b/docs/release-notes/version-2.9.md
@@ -0,0 +1,13 @@
+# NetBox v2.8
+
+## v2.9.0 (FUTURE)
+
+### New Features
+
+#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
+
+NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of attributes. The permission will apply only to objects which match the specified attributes. For example, assigning permission to modify devices with the attribute filter `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group.
+
+### Configuration Changes
+
+* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
From 7b01ba9776fc06d91f0465e57b0c46efbb605542 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Mon, 1 Jun 2020 16:46:14 -0400
Subject: [PATCH 131/300] Fix external auth permissions test
---
netbox/netbox/tests/test_authentication.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index bef8f004a..afeed2263 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -144,7 +144,7 @@ class ExternalAuthenticationTestCase(TestCase):
@override_settings(
REMOTE_AUTH_ENABLED=True,
REMOTE_AUTH_AUTO_CREATE_USER=True,
- REMOTE_AUTH_DEFAULT_PERMISSIONS=['dcim.add_site', 'dcim.change_site'],
+ REMOTE_AUTH_DEFAULT_PERMISSIONS={'dcim.add_site': None, 'dcim.change_site': None},
LOGIN_REQUIRED=True
)
def test_remote_auth_default_permissions(self):
@@ -158,7 +158,7 @@ class ExternalAuthenticationTestCase(TestCase):
self.assertTrue(settings.REMOTE_AUTH_ENABLED)
self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER)
self.assertEqual(settings.REMOTE_AUTH_HEADER, 'HTTP_REMOTE_USER')
- self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, ['dcim.add_site', 'dcim.change_site'])
+ self.assertEqual(settings.REMOTE_AUTH_DEFAULT_PERMISSIONS, {'dcim.add_site': None, 'dcim.change_site': None})
response = self.client.get(reverse('home'), follow=True, **headers)
self.assertEqual(response.status_code, 200)
From 85e932bfc1e6ad62c3e09e0b5a354c7c69597830 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 09:26:45 -0400
Subject: [PATCH 132/300] Clean up permissions utility functions
---
netbox/netbox/authentication.py | 7 +++----
netbox/utilities/permissions.py | 20 +++++++++++---------
netbox/utilities/testing/testcases.py | 4 ++--
netbox/utilities/views.py | 6 +++---
4 files changed, 19 insertions(+), 18 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 4e9078a9a..bf1f96edb 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -6,7 +6,7 @@ from django.contrib.auth.models import Group
from django.db.models import Q
from users.models import ObjectPermission
-from utilities.permissions import permission_is_exempt, resolve_permission
+from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
class ObjectPermissionBackend(ModelBackend):
@@ -42,8 +42,7 @@ class ObjectPermissionBackend(ModelBackend):
return perms
def has_perm(self, user_obj, perm, obj=None):
- app_label, codename = perm.split('.')
- action, model_name = codename.split('_')
+ app_label, action, model_name = resolve_permission(perm)
# Superusers implicitly have all permissions
if user_obj.is_active and user_obj.is_superuser:
@@ -114,7 +113,7 @@ class RemoteUserBackend(_RemoteUserBackend):
permissions_list = []
for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
- content_type, action = resolve_permission(permission_name)
+ content_type, action = resolve_permission_ct(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(actions=[action], attrs=attrs)
obj_perm.save()
diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py
index 38064b689..44c34942f 100644
--- a/netbox/utilities/permissions.py
+++ b/netbox/utilities/permissions.py
@@ -19,33 +19,36 @@ def get_permission_for_model(model, action):
)
-def get_permission_action(name):
+def resolve_permission(name):
"""
- Return the action component (e.g. view or add) from a permission name.
+ Given a permission name, return the app_label, action, and model_name components. For example, "dcim.view_site"
+ returns ("dcim", "view", "site").
:param name: Permission name in the format ._
"""
try:
- return name.split('.')[1].split('_')[0]
+ app_label, codename = name.split('.')
+ action, model_name = codename.rsplit('_', 1)
except ValueError:
raise ValueError(
f"Invalid permission name: {name}. Must be in the format ._"
)
+ return app_label, action, model_name
-def resolve_permission(name):
+
+def resolve_permission_ct(name):
"""
Given a permission name, return the relevant ContentType and action. For example, "dcim.view_site" returns
(Site, "view").
:param name: Permission name in the format ._
"""
- app_label, codename = name.split('.')
- action, model_name = codename.split('_')
+ app_label, action, model_name = resolve_permission(name)
try:
content_type = ContentType.objects.get(app_label=app_label, model=model_name)
except ContentType.DoesNotExist:
- raise ValueError(f"Unknown app/model for {name}")
+ raise ValueError(f"Unknown app_label/model_name for {name}")
return content_type, action
@@ -56,8 +59,7 @@ def permission_is_exempt(name):
:param name: Permission name in the format ._
"""
- app_label, codename = name.split('.')
- action, model_name = codename.split('_')
+ app_label, action, model_name = resolve_permission(name)
if action == 'view':
if (
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 3514f9060..2ef5a19fe 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -7,7 +7,7 @@ from django.urls import reverse, NoReverseMatch
from rest_framework.test import APIClient
from users.models import ObjectPermission, Token
-from utilities.permissions import resolve_permission
+from utilities.permissions import resolve_permission_ct
from .utils import disable_warnings, post_data
@@ -33,7 +33,7 @@ class TestCase(_TestCase):
Assign a set of permissions to the test user. Accepts permission names in the form ._.
"""
for name in names:
- ct, action = resolve_permission(name)
+ ct, action = resolve_permission_ct(name)
obj_perm = ObjectPermission(actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index 0304780f3..e4161077c 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -28,7 +28,7 @@ from extras.models import CustomField, CustomFieldValue, ExportTemplate
from extras.querysets import CustomFieldQueryset
from utilities.exceptions import AbortTransaction
from utilities.forms import BootstrapMixin, CSVDataField, TableConfigForm
-from utilities.permissions import get_permission_action, get_permission_for_model
+from utilities.permissions import get_permission_for_model, resolve_permission
from utilities.utils import csv_format, prepare_cloned_fields
from .error_handlers import handle_protectederror
from .forms import ConfirmationForm, ImportForm
@@ -64,7 +64,7 @@ class ObjectPermissionRequiredMixin(AccessMixin):
if user.has_perms((permission_required, *self.additional_permissions)):
# Update the view's QuerySet to filter only the permitted objects
- action = get_permission_action(permission_required)
+ action = resolve_permission(permission_required)[1]
self.queryset = self.queryset.restrict(user, action)
return True
@@ -233,7 +233,7 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
# Compile a dictionary indicating which permissions are available to the current user for this model
permissions = {}
for action in ('add', 'change', 'delete', 'view'):
- perm_name = '{}.{}_{}'.format(model._meta.app_label, action, model._meta.model_name)
+ perm_name = get_permission_for_model(model, action)
permissions[action] = request.user.has_perm(perm_name)
# Construct the table based on the user's permissions
From 110bad7041abff297b2202cad2bef9fcd16e52e4 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 09:36:45 -0400
Subject: [PATCH 133/300] Update custom napalm_read, napalm_write permissions
---
netbox/dcim/api/views.py | 2 +-
netbox/dcim/migrations/0041_napalm_integration.py | 2 +-
netbox/dcim/migrations/0089_deterministic_ordering.py | 2 +-
netbox/dcim/migrations/0095_primary_model_ordering.py | 2 +-
netbox/dcim/models/__init__.py | 4 ----
netbox/dcim/views.py | 6 +++---
netbox/templates/dcim/device.html | 2 +-
7 files changed, 8 insertions(+), 12 deletions(-)
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 9c8fe12de..3abfddbc2 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -395,7 +395,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
))
# Verify user permission
- if not request.user.has_perm('dcim.napalm_read'):
+ if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden()
# Connect to the device
diff --git a/netbox/dcim/migrations/0041_napalm_integration.py b/netbox/dcim/migrations/0041_napalm_integration.py
index 50c2fbd99..3acad9f0b 100644
--- a/netbox/dcim/migrations/0041_napalm_integration.py
+++ b/netbox/dcim/migrations/0041_napalm_integration.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ['name']},
),
migrations.AddField(
model_name='platform',
diff --git a/netbox/dcim/migrations/0089_deterministic_ordering.py b/netbox/dcim/migrations/0089_deterministic_ordering.py
index 6944cff00..77d18739e 100644
--- a/netbox/dcim/migrations/0089_deterministic_ordering.py
+++ b/netbox/dcim/migrations/0089_deterministic_ordering.py
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
diff --git a/netbox/dcim/migrations/0095_primary_model_ordering.py b/netbox/dcim/migrations/0095_primary_model_ordering.py
index 3bc780161..6225a9b73 100644
--- a/netbox/dcim/migrations/0095_primary_model_ordering.py
+++ b/netbox/dcim/migrations/0095_primary_model_ordering.py
@@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
- options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
+ options={'ordering': ('_name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 3dd3b8c89..4d18509a9 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -1477,10 +1477,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'),
)
- permissions = (
- ('napalm_read', 'Read-only access to devices via NAPALM'),
- ('napalm_write', 'Read/write access to devices via NAPALM'),
- )
def __str__(self):
return self.display_name or super().__str__()
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 0f4297fd6..2508590d9 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1099,7 +1099,7 @@ class DeviceInventoryView(ObjectView):
class DeviceStatusView(ObjectView):
- additional_permissions = ['dcim.napalm_read']
+ additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
def get(self, request, pk):
@@ -1113,7 +1113,7 @@ class DeviceStatusView(ObjectView):
class DeviceLLDPNeighborsView(ObjectView):
- additional_permissions = ['dcim.napalm_read']
+ additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
def get(self, request, pk):
@@ -1133,7 +1133,7 @@ class DeviceLLDPNeighborsView(ObjectView):
class DeviceConfigView(ObjectView):
- additional_permissions = ['dcim.napalm_read']
+ additional_permissions = ['dcim.napalm_read_device']
queryset = Device.objects.all()
def get(self, request, pk):
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html
index ef1a301e2..a42250a3d 100644
--- a/netbox/templates/dcim/device.html
+++ b/netbox/templates/dcim/device.html
@@ -101,7 +101,7 @@
Inventory {{ device.inventory_items.count }}
- {% if perms.dcim.napalm_read %}
+ {% if perms.dcim.napalm_read_device %}
{% if device.status != 'active' %}
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
{% elif not device.platform %}
From c6e85970d479ba570ddd1f730d4cf1e82bfddaa0 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 09:47:31 -0400
Subject: [PATCH 134/300] Remove activate_userkey permission
---
docs/release-notes/version-2.9.md | 4 ++++
netbox/secrets/admin.py | 2 +-
netbox/secrets/migrations/0001_initial.py | 1 -
netbox/secrets/models.py | 3 ---
netbox/users/migrations/0009_replicate_permissions.py | 5 ++++-
5 files changed, 9 insertions(+), 6 deletions(-)
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index b6cc699d4..fc16ed6fd 100644
--- a/docs/release-notes/version-2.9.md
+++ b/docs/release-notes/version-2.9.md
@@ -11,3 +11,7 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
### Configuration Changes
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
+
+### Other Changes
+
+* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py
index 94cd1c7fa..e11128674 100644
--- a/netbox/secrets/admin.py
+++ b/netbox/secrets/admin.py
@@ -23,7 +23,7 @@ class UserKeyAdmin(admin.ModelAdmin):
actions = super().get_actions(request)
if 'delete_selected' in actions:
del actions['delete_selected']
- if not request.user.has_perm('secrets.activate_userkey'):
+ if not request.user.has_perm('secrets.change_userkey'):
del actions['activate_selected']
return actions
diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py
index 1281a266a..3664bae63 100644
--- a/netbox/secrets/migrations/0001_initial.py
+++ b/netbox/secrets/migrations/0001_initial.py
@@ -56,7 +56,6 @@ class Migration(migrations.Migration):
],
options={
'ordering': ['user__username'],
- 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),),
},
),
migrations.AddField(
diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py
index 757ef88c7..bf5858ff8 100644
--- a/netbox/secrets/models.py
+++ b/netbox/secrets/models.py
@@ -64,9 +64,6 @@ class UserKey(models.Model):
class Meta:
ordering = ['user__username']
- permissions = (
- ('activate_userkey', "Can activate user keys for decryption"),
- )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py
index c5e4d364c..66084c3be 100644
--- a/netbox/users/migrations/0009_replicate_permissions.py
+++ b/netbox/users/migrations/0009_replicate_permissions.py
@@ -14,9 +14,12 @@ def replicate_permissions(apps, schema_editor):
# TODO: Optimize this iteration so that ObjectPermissions with identical sets of users and groups
# are combined into a single ObjectPermission instance.
for perm in Permission.objects.all():
- # Account for non-standard permission names; e.g. napalm_read
if perm.codename.split('_')[0] in ACTIONS:
+ # Account for non-standard legacy permission names; e.g. napalm_read
action = perm.codename.split('_')[0]
+ elif perm.codename == 'activate_userkey':
+ # Rename activate_userkey permission
+ action = 'change'
else:
action = perm.codename
From 7a7634de2d80726df9a5593ac8cb7306387ae7fa Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 10:50:58 -0400
Subject: [PATCH 135/300] Accomodate custom legacy permission in schema
migration
---
netbox/extras/migrations/0024_scripts.py | 1 -
netbox/extras/models/models.py | 3 ---
netbox/users/migrations/0009_replicate_permissions.py | 4 ++--
3 files changed, 2 insertions(+), 6 deletions(-)
diff --git a/netbox/extras/migrations/0024_scripts.py b/netbox/extras/migrations/0024_scripts.py
index 82d0afdc9..c8d81e5e2 100644
--- a/netbox/extras/migrations/0024_scripts.py
+++ b/netbox/extras/migrations/0024_scripts.py
@@ -16,7 +16,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
- 'permissions': (('run_script', 'Can run script'),),
'managed': False,
},
),
diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py
index a94fc3eea..9e000774f 100644
--- a/netbox/extras/models/models.py
+++ b/netbox/extras/models/models.py
@@ -564,9 +564,6 @@ class Script(models.Model):
"""
class Meta:
managed = False
- permissions = (
- ('run_script', 'Can run script'),
- )
#
diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py
index 66084c3be..b25698a36 100644
--- a/netbox/users/migrations/0009_replicate_permissions.py
+++ b/netbox/users/migrations/0009_replicate_permissions.py
@@ -15,11 +15,11 @@ def replicate_permissions(apps, schema_editor):
# are combined into a single ObjectPermission instance.
for perm in Permission.objects.all():
if perm.codename.split('_')[0] in ACTIONS:
- # Account for non-standard legacy permission names; e.g. napalm_read
action = perm.codename.split('_')[0]
elif perm.codename == 'activate_userkey':
- # Rename activate_userkey permission
action = 'change'
+ elif perm.codename == 'run_script':
+ action = 'run'
else:
action = perm.codename
From a62b98ac506aa45d72c7879ac7c01fff556cad30 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 13:21:00 -0400
Subject: [PATCH 136/300] Admin UI improvements
---
netbox/users/admin.py | 34 ++++++++++++++++---
.../users/migrations/0007_proxy_group_user.py | 2 ++
netbox/users/models.py | 7 +++-
3 files changed, 38 insertions(+), 5 deletions(-)
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 4c3da5acd..80b7affaf 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -35,20 +35,42 @@ class UserConfigInline(admin.TabularInline):
verbose_name = 'Preferences'
+class ObjectPermissionInline(admin.TabularInline):
+ model = AdminUser.object_permissions.through
+ fields = ['content_types', 'actions', 'attrs']
+ readonly_fields = fields
+ extra = 0
+ verbose_name = 'Permission'
+
+ def content_types(self, instance):
+ return ', '.join(instance.objectpermission.content_types.values_list('model', flat=True))
+
+ def actions(self, instance):
+ return ', '.join(instance.objectpermission.actions)
+
+ def attrs(self, instance):
+ return instance.objectpermission.attrs
+
+ def has_add_permission(self, request, obj):
+ # Don't allow the creation of new ObjectPermission assignments via this form
+ return False
+
+
@admin.register(AdminUser)
class UserAdmin(UserAdmin_):
list_display = [
'username', 'email', 'first_name', 'last_name', 'is_superuser', 'is_staff', 'is_active'
]
fieldsets = (
- (None, {'fields': ('username', 'password')}),
- ('Personal info', {'fields': ('first_name', 'last_name', 'email')}),
+ (None, {'fields': ('username', 'password', 'first_name', 'last_name', 'email')}),
+ ('Groups', {'fields': ('groups',)}),
('Permissions', {
'fields': ('is_active', 'is_staff', 'is_superuser'),
}),
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
- inlines = (UserConfigInline,)
+ inlines = [ObjectPermissionInline, UserConfigInline]
+ filter_horizontal = ('groups',)
#
@@ -154,7 +176,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
'fields': ('content_types',)
}),
('Assignment', {
- 'fields': (('groups', 'users'),)
+ 'fields': ('groups', 'users')
}),
('Actions', {
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
@@ -163,10 +185,14 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
'fields': ('attrs',)
}),
)
+ filter_horizontal = ('content_types', 'groups', 'users')
form = ObjectPermissionForm
list_display = [
'list_models', 'list_users', 'list_groups', 'actions', 'attrs',
]
+ list_filter = [
+ 'groups', 'users'
+ ]
def get_queryset(self, request):
return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups')
diff --git a/netbox/users/migrations/0007_proxy_group_user.py b/netbox/users/migrations/0007_proxy_group_user.py
index dfd0512bd..2aec9e425 100644
--- a/netbox/users/migrations/0007_proxy_group_user.py
+++ b/netbox/users/migrations/0007_proxy_group_user.py
@@ -21,6 +21,7 @@ class Migration(migrations.Migration):
'proxy': True,
'indexes': [],
'constraints': [],
+ 'verbose_name': 'Group',
},
bases=('auth.group',),
managers=[
@@ -35,6 +36,7 @@ class Migration(migrations.Migration):
'proxy': True,
'indexes': [],
'constraints': [],
+ 'verbose_name': 'User',
},
bases=('auth.user',),
managers=[
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 1c8775699..9dde9d009 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -29,6 +29,7 @@ class AdminGroup(Group):
Proxy contrib.auth.models.Group for the admin UI
"""
class Meta:
+ verbose_name = 'Group'
proxy = True
@@ -37,6 +38,7 @@ class AdminUser(User):
Proxy contrib.auth.models.User for the admin UI
"""
class Meta:
+ verbose_name = 'User'
proxy = True
@@ -264,4 +266,7 @@ class ObjectPermission(models.Model):
verbose_name = "Permission"
def __str__(self):
- return "Object permission"
+ return '{}: {}'.format(
+ ', '.join(self.content_types.values_list('model', flat=True)),
+ ', '.join(self.actions)
+ )
From 56f6698ba54b81b8ff985019e2bae58e63e7ee3b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 13:40:14 -0400
Subject: [PATCH 137/300] Fixes #4707: Fix prefix_count population on VLAN API
serializer
---
docs/release-notes/version-2.8.md | 1 +
netbox/ipam/api/views.py | 2 +-
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index fb177252f..5dcb5f01f 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -5,6 +5,7 @@
### Bug Fixes
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
+* [#4707](https://github.com/netbox-community/netbox/issues/4707) - Fix `prefix_count` population on VLAN API serializer
---
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index bf430f633..dd3652b1f 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -276,7 +276,7 @@ class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
- prefix_count=get_subquery(Prefix, 'role')
+ prefix_count=get_subquery(Prefix, 'vlan')
)
serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet
From cae412d280fe876d0513af2aec8334f3020565cf Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 14:19:08 -0400
Subject: [PATCH 138/300] Update ObjectImportView to support ObjectPermissions
---
netbox/dcim/views.py | 6 +++---
netbox/utilities/views.py | 34 +++++++++++++++++++++++++++++-----
2 files changed, 32 insertions(+), 8 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 2508590d9..b3b99d804 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -627,8 +627,8 @@ class DeviceTypeDeleteView(ObjectDeleteView):
default_return_url = 'dcim:devicetype_list'
-class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
- permission_required = [
+class DeviceTypeImportView(ObjectImportView):
+ additional_permissions = [
'dcim.add_devicetype',
'dcim.add_consoleporttemplate',
'dcim.add_consoleserverporttemplate',
@@ -639,7 +639,7 @@ class DeviceTypeImportView(PermissionRequiredMixin, ObjectImportView):
'dcim.add_rearporttemplate',
'dcim.add_devicebaytemplate',
]
- model = DeviceType
+ queryset = DeviceType.objects.all()
model_form = forms.DeviceTypeImportForm
related_object_forms = OrderedDict((
('console-ports', forms.ConsolePortTemplateImportForm),
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index e4161077c..e448f2934 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -571,21 +571,29 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
})
-class ObjectImportView(GetReturnURLMixin, View):
+class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Import a single object (YAML or JSON format).
+
+ queryset: Base queryset for the objects being created
+ model_form: The ModelForm used to create individual objects
+ related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects
+ template_name: The name of the template
"""
- model = None
+ queryset = None
model_form = None
related_object_forms = dict()
template_name = 'utilities/obj_import.html'
+ def get_required_permission(self):
+ return get_permission_for_model(self.queryset.model, 'add')
+
def get(self, request):
form = ImportForm()
return render(request, self.template_name, {
'form': form,
- 'obj_type': self.model._meta.verbose_name,
+ 'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request),
})
@@ -615,12 +623,17 @@ class ObjectImportView(GetReturnURLMixin, View):
# Save the primary object
obj = model_form.save()
+
+ # Enforce object-level permissions
+ self.queryset.get(pk=obj.pk)
+
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}")
+ related_obj_pks = []
for i, rel_obj_data in enumerate(data.get(field_name, list())):
f = related_object_form(obj, rel_obj_data)
@@ -630,7 +643,8 @@ class ObjectImportView(GetReturnURLMixin, View):
f.data[subfield_name] = field.initial
if f.is_valid():
- f.save()
+ related_obj = f.save()
+ related_obj_pks.append(related_obj.pk)
else:
# Replicate errors on the related object form to the primary form for display
for subfield_name, errors in f.errors.items():
@@ -639,9 +653,19 @@ class ObjectImportView(GetReturnURLMixin, View):
model_form.add_error(None, err_msg)
raise AbortTransaction()
+ # Enforce object-level permissions on related objects
+ model = related_object_form.Meta.model
+ if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks):
+ raise ObjectDoesNotExist
+
except AbortTransaction:
pass
+ except ObjectDoesNotExist:
+ msg = "Object creation failed due to object-level permissions violation"
+ logger.debug(msg)
+ form.add_error(None, msg)
+
if not model_form.errors:
logger.info(f"Import object {obj} (PK: {obj.pk})")
messages.success(request, mark_safe('Imported object: {}'.format(
@@ -673,7 +697,7 @@ class ObjectImportView(GetReturnURLMixin, View):
return render(request, self.template_name, {
'form': form,
- 'obj_type': self.model._meta.verbose_name,
+ 'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request),
})
From e463430d51567dd2111cf46e6dc7d03e5e24b73c Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 15:15:57 -0400
Subject: [PATCH 139/300] Change CableCreateView to use ObjectEditView
---
netbox/dcim/views.py | 73 +++++++++++++--------------------------
netbox/utilities/views.py | 2 +-
2 files changed, 25 insertions(+), 50 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index b3b99d804..12f7a5046 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1904,23 +1904,15 @@ class CableTraceView(ObjectView):
})
-class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.add_cable'
+class CableCreateView(ObjectEditView):
+ queryset = Cable.objects.all()
template_name = 'dcim/cable_connect.html'
+ default_return_url = 'dcim:cable_list'
def dispatch(self, request, *args, **kwargs):
- termination_a_type = kwargs.get('termination_a_type')
- termination_a_id = kwargs.get('termination_a_id')
-
- termination_b_type_name = kwargs.get('termination_b_type')
- self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
-
- self.obj = Cable(
- termination_a=termination_a_type.objects.get(pk=termination_a_id),
- termination_b_type=self.termination_b_type
- )
- self.form_class = {
+ # Set the model_form class based on the type of component being connected
+ self.model_form = {
'console-port': forms.ConnectCableToConsolePortForm,
'console-server-port': forms.ConnectCableToConsoleServerPortForm,
'power-port': forms.ConnectCableToPowerPortForm,
@@ -1930,59 +1922,42 @@ class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
'rear-port': forms.ConnectCableToRearPortForm,
'power-feed': forms.ConnectCableToPowerFeedForm,
'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
- }[termination_b_type_name]
+ }[kwargs.get('termination_b_type')]
return super().dispatch(request, *args, **kwargs)
+ def alter_obj(self, obj, request, url_args, url_kwargs):
+ termination_a_type = url_kwargs.get('termination_a_type')
+ termination_a_id = url_kwargs.get('termination_a_id')
+ termination_b_type_name = url_kwargs.get('termination_b_type')
+ self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
+
+ # Initialize Cable termination attributes
+ obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
+ obj.termination_b_type = self.termination_b_type
+
+ return obj
+
def get(self, request, *args, **kwargs):
+ obj = self.alter_obj(self.get_object(kwargs), 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}
# Set initial site and rack based on side A termination (if not already set)
if 'termination_b_site' not in initial_data:
- initial_data['termination_b_site'] = getattr(self.obj.termination_a.parent, 'site', None)
+ initial_data['termination_b_site'] = getattr(obj.termination_a.parent, 'site', None)
if 'termination_b_rack' not in initial_data:
- initial_data['termination_b_rack'] = getattr(self.obj.termination_a.parent, 'rack', None)
+ initial_data['termination_b_rack'] = getattr(obj.termination_a.parent, 'rack', None)
- form = self.form_class(instance=self.obj, initial=initial_data)
+ form = self.model_form(instance=obj, initial=initial_data)
return render(request, self.template_name, {
- 'obj': self.obj,
+ 'obj': obj,
'obj_type': Cable._meta.verbose_name,
'termination_b_type': self.termination_b_type.name,
'form': form,
- 'return_url': self.get_return_url(request, self.obj),
- })
-
- def post(self, request, *args, **kwargs):
-
- form = self.form_class(request.POST, request.FILES, instance=self.obj)
-
- if form.is_valid():
- obj = form.save()
-
- msg = 'Created cable {}'.format(
- obj.get_absolute_url(),
- escape(obj)
- )
- messages.success(request, mark_safe(msg))
-
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
-
- return_url = form.cleaned_data.get('return_url')
- if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
- return redirect(return_url)
- else:
- return redirect(self.get_return_url(request, obj))
-
- return render(request, self.template_name, {
- 'obj': self.obj,
- 'obj_type': Cable._meta.verbose_name,
- 'termination_b_type': self.termination_b_type.name,
- 'form': form,
- 'return_url': self.get_return_url(request, self.obj),
+ 'return_url': self.get_return_url(request, obj),
})
diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py
index e448f2934..9271e1c64 100644
--- a/netbox/utilities/views.py
+++ b/netbox/utilities/views.py
@@ -346,7 +346,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
form = self.model_form(
data=request.POST,
files=request.FILES,
- instance=self.alter_obj(self.get_object(kwargs), request, args, kwargs)
+ instance=obj
)
if form.is_valid():
From 205acd2c4d8ebaf83eeed38b998377bb59b70ac7 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 15:33:41 -0400
Subject: [PATCH 140/300] Update VirtualChassis views to support
ObjectPermissions
---
netbox/dcim/views.py | 38 ++++++++++++++++++++++++--------------
1 file changed, 24 insertions(+), 14 deletions(-)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 12f7a5046..de2bf80e5 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -3,7 +3,6 @@ import re
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
@@ -12,7 +11,6 @@ from django.forms import modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
-from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
from django.views.generic import View
@@ -2169,8 +2167,11 @@ class VirtualChassisView(ObjectView):
})
-class VirtualChassisCreateView(PermissionRequiredMixin, View):
- permission_required = 'dcim.add_virtualchassis'
+class VirtualChassisCreateView(ObjectPermissionRequiredMixin, View):
+ queryset = VirtualChassis.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.add_virtualchassis'
def post(self, request):
@@ -2224,8 +2225,11 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
})
-class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = VirtualChassis.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_virtualchassis'
def get(self, request, pk):
@@ -2294,12 +2298,15 @@ class VirtualChassisDeleteView(ObjectDeleteView):
default_return_url = 'dcim:device_list'
-class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = VirtualChassis.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_virtualchassis'
def get(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
initial_data = {k: request.GET[k] for k in request.GET}
member_select_form = forms.VCMemberSelectForm(initial=initial_data)
@@ -2314,7 +2321,7 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
def post(self, request, pk):
- virtual_chassis = get_object_or_404(VirtualChassis, pk=pk)
+ virtual_chassis = get_object_or_404(self.queryset, pk=pk)
member_select_form = forms.VCMemberSelectForm(request.POST)
@@ -2348,12 +2355,15 @@ class VirtualChassisAddMemberView(PermissionRequiredMixin, GetReturnURLMixin, Vi
})
-class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, View):
- permission_required = 'dcim.change_virtualchassis'
+class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURLMixin, View):
+ queryset = Device.objects.all()
+
+ def get_required_permission(self):
+ return 'dcim.change_device'
def get(self, request, pk):
- device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False)
+ device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(initial=request.GET)
return render(request, 'dcim/virtualchassis_remove_member.html', {
@@ -2364,7 +2374,7 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
def post(self, request, pk):
- device = get_object_or_404(Device, pk=pk, virtual_chassis__isnull=False)
+ device = get_object_or_404(self.queryset, pk=pk, virtual_chassis__isnull=False)
form = ConfirmationForm(request.POST)
# Protect master device from being removed
From 3502398d1d90546f4fb56c678f591599ce0893c3 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 15:36:31 -0400
Subject: [PATCH 141/300] Remove delete_token permission from TokenDeleteView
---
docs/release-notes/version-2.9.md | 1 +
netbox/users/views.py | 5 ++---
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index fc16ed6fd..4fda77838 100644
--- a/docs/release-notes/version-2.9.md
+++ b/docs/release-notes/version-2.9.md
@@ -15,3 +15,4 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
### Other Changes
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
+* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
diff --git a/netbox/users/views.py b/netbox/users/views.py
index c3e366542..f88ff040c 100644
--- a/netbox/users/views.py
+++ b/netbox/users/views.py
@@ -3,7 +3,7 @@ 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
-from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
+from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseForbidden, HttpResponseRedirect
@@ -320,8 +320,7 @@ class TokenEditView(LoginRequiredMixin, View):
})
-class TokenDeleteView(PermissionRequiredMixin, View):
- permission_required = 'users.delete_token'
+class TokenDeleteView(LoginRequiredMixin, View):
def get(self, request, pk):
From 19407ba3bc7104b1d88c4efa1f997b469f7c8616 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Tue, 2 Jun 2020 15:40:39 -0400
Subject: [PATCH 142/300] Uodate script and report views to use
ObjectPermissionRequiredMixin
---
netbox/extras/views.py | 33 +++++++++++++++++++++------------
1 file changed, 21 insertions(+), 12 deletions(-)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index a607a4df8..e80aa1d62 100644
--- a/netbox/extras/views.py
+++ b/netbox/extras/views.py
@@ -1,7 +1,6 @@
from django import template
from django.conf import settings
from django.contrib import messages
-from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden
@@ -13,7 +12,10 @@ from django_tables2 import RequestConfig
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
-from utilities.views import BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView
+from utilities.views import (
+ BulkDeleteView, BulkEditView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
+ ObjectPermissionRequiredMixin,
+)
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports
@@ -324,11 +326,12 @@ class ImageAttachmentDeleteView(ObjectDeleteView):
# Reports
#
-class ReportListView(PermissionRequiredMixin, View):
+class ReportListView(ObjectPermissionRequiredMixin, View):
"""
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
"""
- permission_required = 'extras.view_reportresult'
+ def get_required_permission(self):
+ return 'extras.view_reportresult'
def get(self, request):
@@ -348,11 +351,12 @@ class ReportListView(PermissionRequiredMixin, View):
})
-class ReportView(PermissionRequiredMixin, View):
+class ReportView(ObjectPermissionRequiredMixin, View):
"""
Display a single Report and its associated ReportResult (if any).
"""
- permission_required = 'extras.view_reportresult'
+ def get_required_permission(self):
+ return 'extras.view_reportresult'
def get(self, request, name):
@@ -371,11 +375,12 @@ class ReportView(PermissionRequiredMixin, View):
})
-class ReportRunView(PermissionRequiredMixin, View):
+class ReportRunView(ObjectPermissionRequiredMixin, View):
"""
Run a Report and record a new ReportResult.
"""
- permission_required = 'extras.add_reportresult'
+ def get_required_permission(self):
+ return 'extras.add_reportresult'
def post(self, request, name):
@@ -401,8 +406,10 @@ class ReportRunView(PermissionRequiredMixin, View):
# Scripts
#
-class ScriptListView(PermissionRequiredMixin, View):
- permission_required = 'extras.view_script'
+class ScriptListView(ObjectPermissionRequiredMixin, View):
+
+ def get_required_permission(self):
+ return 'extras.view_script'
def get(self, request):
@@ -411,8 +418,10 @@ class ScriptListView(PermissionRequiredMixin, View):
})
-class ScriptView(PermissionRequiredMixin, View):
- permission_required = 'extras.view_script'
+class ScriptView(ObjectPermissionRequiredMixin, View):
+
+ def get_required_permission(self):
+ return 'extras.view_script'
def _get_script(self, module, name):
scripts = get_scripts()
From ddcd172af130d0ebd529b55a297cd7824283b72f Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 09:27:20 -0400
Subject: [PATCH 143/300] Rename content_types to object_types
---
netbox/netbox/authentication.py | 10 +++---
netbox/netbox/tests/test_authentication.py | 26 +++++++-------
netbox/users/admin.py | 22 ++++++------
.../users/migrations/0008_objectpermission.py | 2 +-
.../migrations/0009_replicate_permissions.py | 2 +-
netbox/users/models.py | 4 +--
netbox/utilities/testing/testcases.py | 36 +++++++++----------
7 files changed, 51 insertions(+), 51 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index bf1f96edb..a219c1498 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -26,14 +26,14 @@ class ObjectPermissionBackend(ModelBackend):
object_permissions = ObjectPermission.objects.filter(
Q(users=user_obj) |
Q(groups__user=user_obj)
- ).prefetch_related('content_types')
+ ).prefetch_related('object_types')
# Create a dictionary mapping permissions to their attributes
perms = dict()
for obj_perm in object_permissions:
- for content_type in obj_perm.content_types.all():
+ for object_type in obj_perm.object_types.all():
for action in obj_perm.actions:
- perm_name = f"{content_type.app_label}.{action}_{content_type.model}"
+ perm_name = f"{object_type.app_label}.{action}_{object_type.model}"
if perm_name in perms:
perms[perm_name].append(obj_perm.attrs)
else:
@@ -113,12 +113,12 @@ class RemoteUserBackend(_RemoteUserBackend):
permissions_list = []
for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
- content_type, action = resolve_permission_ct(permission_name)
+ object_type, action = resolve_permission_ct(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
obj_perm = ObjectPermission(actions=[action], attrs=attrs)
obj_perm.save()
obj_perm.users.add(user)
- obj_perm.content_types.add(content_type)
+ obj_perm.object_types.add(object_type)
permissions_list.append(permission_name)
except ValueError:
logging.error(
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index afeed2263..db63faffd 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -207,7 +207,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
response = self.client.get(self.prefixes[0].get_absolute_url())
@@ -231,7 +231,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(reverse('ipam:prefix_list'))
@@ -265,7 +265,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create a non-permitted object
request = {
@@ -312,7 +312,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit a non-permitted object
request = {
@@ -355,7 +355,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Delete permitted object
request = {
@@ -403,7 +403,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create non-permitted objects
request = {
@@ -452,7 +452,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit non-permitted objects
request = {
@@ -496,7 +496,7 @@ class ObjectPermissionViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete non-permitted object
request = {
@@ -567,7 +567,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[0].pk})
@@ -594,7 +594,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Retrieve all objects. Only permitted objects should be returned.
response = self.client.get(url, **self.header)
@@ -621,7 +621,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to create a non-permitted object
response = self.client.post(url, data, format='json', **self.header)
@@ -650,7 +650,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to edit a non-permitted object
data = {'site': self.sites[0].pk}
@@ -685,7 +685,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(Prefix))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(Prefix))
# Attempt to delete a non-permitted object
url = reverse('ipam-api:prefix-detail', kwargs={'pk': self.prefixes[3].pk})
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 80b7affaf..80283340d 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -37,13 +37,13 @@ class UserConfigInline(admin.TabularInline):
class ObjectPermissionInline(admin.TabularInline):
model = AdminUser.object_permissions.through
- fields = ['content_types', 'actions', 'attrs']
+ fields = ['object_types', 'actions', 'attrs']
readonly_fields = fields
extra = 0
verbose_name = 'Permission'
- def content_types(self, instance):
- return ', '.join(instance.objectpermission.content_types.values_list('model', flat=True))
+ def object_types(self, instance):
+ return ', '.join(instance.objectpermission.object_types.values_list('model', flat=True))
def actions(self, instance):
return ', '.join(instance.objectpermission.actions)
@@ -127,8 +127,8 @@ class ObjectPermissionForm(forms.ModelForm):
self.fields['actions'].required = False
# Format ContentType choices
- order_content_types(self.fields['content_types'])
- self.fields['content_types'].choices.insert(0, ('', '---------'))
+ order_content_types(self.fields['object_types'])
+ self.fields['object_types'].choices.insert(0, ('', '---------'))
# Order group and user fields
self.fields['groups'].queryset = self.fields['groups'].queryset.order_by('name')
@@ -142,7 +142,7 @@ class ObjectPermissionForm(forms.ModelForm):
self.instance.actions.remove(action)
def clean(self):
- content_types = self.cleaned_data['content_types']
+ object_types = self.cleaned_data['object_types']
attrs = self.cleaned_data['attrs']
# Append any of the selected CRUD checkboxes to the actions list
@@ -159,7 +159,7 @@ class ObjectPermissionForm(forms.ModelForm):
# Validate the specified model attributes by attempting to execute a query. We don't care whether the query
# returns anything; we just want to make sure the specified attributes are valid.
if attrs:
- for ct in content_types:
+ for ct in object_types:
model = ct.model_class()
try:
model.objects.filter(**attrs).exists()
@@ -173,7 +173,7 @@ class ObjectPermissionForm(forms.ModelForm):
class ObjectPermissionAdmin(admin.ModelAdmin):
fieldsets = (
('Objects', {
- 'fields': ('content_types',)
+ 'fields': ('object_types',)
}),
('Assignment', {
'fields': ('groups', 'users')
@@ -185,7 +185,7 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
'fields': ('attrs',)
}),
)
- filter_horizontal = ('content_types', 'groups', 'users')
+ filter_horizontal = ('object_types', 'groups', 'users')
form = ObjectPermissionForm
list_display = [
'list_models', 'list_users', 'list_groups', 'actions', 'attrs',
@@ -195,10 +195,10 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
]
def get_queryset(self, request):
- return super().get_queryset(request).prefetch_related('content_types', 'users', 'groups')
+ return super().get_queryset(request).prefetch_related('object_types', 'users', 'groups')
def list_models(self, obj):
- return ', '.join([f"{ct}" for ct in obj.content_types.all()])
+ return ', '.join([f"{ct}" for ct in obj.object_types.all()])
list_models.short_description = 'Models'
def list_users(self, obj):
diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py
index f2ecb98b0..4f301264e 100644
--- a/netbox/users/migrations/0008_objectpermission.py
+++ b/netbox/users/migrations/0008_objectpermission.py
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
- ('content_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
+ ('object_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
('users', models.ManyToManyField(blank=True, related_name='object_permissions', to=settings.AUTH_USER_MODEL)),
],
diff --git a/netbox/users/migrations/0009_replicate_permissions.py b/netbox/users/migrations/0009_replicate_permissions.py
index b25698a36..a5d28beac 100644
--- a/netbox/users/migrations/0009_replicate_permissions.py
+++ b/netbox/users/migrations/0009_replicate_permissions.py
@@ -26,7 +26,7 @@ def replicate_permissions(apps, schema_editor):
if perm.group_set.exists() or perm.user_set.exists():
obj_perm = ObjectPermission(actions=[action])
obj_perm.save()
- obj_perm.content_types.add(perm.content_type)
+ obj_perm.object_types.add(perm.content_type)
if perm.group_set.exists():
obj_perm.groups.add(*list(perm.group_set.all()))
if perm.user_set.exists():
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 9dde9d009..255980dfc 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -243,7 +243,7 @@ class ObjectPermission(models.Model):
blank=True,
related_name='object_permissions'
)
- content_types = models.ManyToManyField(
+ object_types = models.ManyToManyField(
to=ContentType,
limit_choices_to={
'app_label__in': [
@@ -267,6 +267,6 @@ class ObjectPermission(models.Model):
def __str__(self):
return '{}: {}'.format(
- ', '.join(self.content_types.values_list('model', flat=True)),
+ ', '.join(self.object_types.values_list('model', flat=True)),
', '.join(self.actions)
)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 2ef5a19fe..3cf6a9df6 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -37,7 +37,7 @@ class TestCase(_TestCase):
obj_perm = ObjectPermission(actions=[action])
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ct)
+ obj_perm.object_types.add(ct)
#
# Convenience methods
@@ -169,7 +169,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(instance.get_absolute_url()), 200)
@@ -185,7 +185,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET to permitted object
self.assertHttpStatus(self.client.get(instance1.get_absolute_url()), 200)
@@ -225,7 +225,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -250,7 +250,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with object-level permission
self.assertHttpStatus(self.client.get(self._get_url('add')), 200)
@@ -309,7 +309,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('edit', instance)), 200)
@@ -333,7 +333,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with a permitted object
self.assertHttpStatus(self.client.get(self._get_url('edit', instance1)), 200)
@@ -386,7 +386,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('delete', instance)), 200)
@@ -411,7 +411,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with a permitted object
self.assertHttpStatus(self.client.get(self._get_url('delete', instance1)), 200)
@@ -463,7 +463,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
@@ -485,7 +485,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with object-level permission
self.assertHttpStatus(self.client.get(self._get_url('list')), 200)
@@ -515,7 +515,7 @@ class ViewTestCases:
obj_perm = ObjectPermission(actions=['add'])
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
response = self.client.post(**request)
self.assertHttpStatus(response, 302)
@@ -561,7 +561,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try GET with model-level permission
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
@@ -584,7 +584,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Test import with object-level permission
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 200)
@@ -631,7 +631,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
@@ -656,7 +656,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_edit'), data), 302)
@@ -701,7 +701,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with model-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
@@ -723,7 +723,7 @@ class ViewTestCases:
)
obj_perm.save()
obj_perm.users.add(self.user)
- obj_perm.content_types.add(ContentType.objects.get_for_model(self.model))
+ obj_perm.object_types.add(ContentType.objects.get_for_model(self.model))
# Try POST with object-level permission
self.assertHttpStatus(self.client.post(self._get_url('bulk_delete'), data), 302)
From d157818d7e7dd28e3b8d61456f1aecf66b8f1a31 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 09:43:46 -0400
Subject: [PATCH 144/300] Rename attrs to constraints
---
netbox/netbox/authentication.py | 28 +++++++++----------
netbox/netbox/tests/test_authentication.py | 26 ++++++++---------
netbox/users/admin.py | 26 ++++++++---------
.../users/migrations/0008_objectpermission.py | 4 +--
netbox/users/models.py | 4 +--
netbox/utilities/testing/testcases.py | 18 ++++++------
6 files changed, 52 insertions(+), 54 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index a219c1498..02b0be0f3 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -28,16 +28,16 @@ class ObjectPermissionBackend(ModelBackend):
Q(groups__user=user_obj)
).prefetch_related('object_types')
- # Create a dictionary mapping permissions to their attributes
+ # Create a dictionary mapping permissions to their constraints
perms = dict()
for obj_perm in object_permissions:
for object_type in obj_perm.object_types.all():
for action in obj_perm.actions:
perm_name = f"{object_type.app_label}.{action}_{object_type.model}"
if perm_name in perms:
- perms[perm_name].append(obj_perm.attrs)
+ perms[perm_name].append(obj_perm.constraints)
else:
- perms[perm_name] = [obj_perm.attrs]
+ perms[perm_name] = [obj_perm.constraints]
return perms
@@ -71,20 +71,20 @@ class ObjectPermissionBackend(ModelBackend):
raise ValueError(f"Invalid permission {perm} for model {model}")
# Compile a query filter that matches all instances of the specified model
- obj_perm_attrs = self.get_all_permissions(user_obj)[perm]
- attrs = Q()
- for perm_attrs in obj_perm_attrs:
- if perm_attrs:
- attrs |= Q(**perm_attrs)
+ obj_perm_constraints = self.get_all_permissions(user_obj)[perm]
+ constraints = Q()
+ for perm_constraints in obj_perm_constraints:
+ if perm_constraints:
+ constraints |= Q(**perm_constraints)
else:
- # Found ObjectPermission with null attrs; allow model-level access
- attrs = Q()
+ # Found ObjectPermission with null constraints; allow model-level access
+ constraints = Q()
break
# Permission to perform the requested action on the object depends on whether the specified object matches
- # the specified attributes. Note that this check is made against the *database* record representing the object,
+ # the specified constraints. Note that this check is made against the *database* record representing the object,
# not the instance itself.
- return model.objects.filter(attrs, pk=obj.pk).exists()
+ return model.objects.filter(constraints, pk=obj.pk).exists()
class RemoteUserBackend(_RemoteUserBackend):
@@ -111,11 +111,11 @@ class RemoteUserBackend(_RemoteUserBackend):
# Assign default object permissions to the user
permissions_list = []
- for permission_name, attrs in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
+ for permission_name, constraints in settings.REMOTE_AUTH_DEFAULT_PERMISSIONS.items():
try:
object_type, action = resolve_permission_ct(permission_name)
# TODO: Merge multiple actions into a single ObjectPermission per content type
- obj_perm = ObjectPermission(actions=[action], attrs=attrs)
+ obj_perm = ObjectPermission(actions=[action], constraints=constraints)
obj_perm.save()
obj_perm.users.add(user)
obj_perm.object_types.add(object_type)
diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py
index db63faffd..0e9bea90d 100644
--- a/netbox/netbox/tests/test_authentication.py
+++ b/netbox/netbox/tests/test_authentication.py
@@ -202,7 +202,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
@@ -226,7 +226,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
@@ -260,7 +260,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view', 'add']
)
obj_perm.save()
@@ -307,7 +307,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view', 'change']
)
obj_perm.save()
@@ -350,7 +350,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view', 'delete']
)
obj_perm.save()
@@ -398,7 +398,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['add']
)
obj_perm.save()
@@ -447,7 +447,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['change']
)
obj_perm.save()
@@ -491,7 +491,7 @@ class ObjectPermissionViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view', 'delete']
)
obj_perm.save()
@@ -562,7 +562,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
@@ -589,7 +589,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['view']
)
obj_perm.save()
@@ -616,7 +616,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['add']
)
obj_perm.save()
@@ -645,7 +645,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['change']
)
obj_perm.save()
@@ -680,7 +680,7 @@ class ObjectPermissionAPIViewTestCase(TestCase):
# Assign object permission
obj_perm = ObjectPermission(
- attrs={'site__name': 'Site 1'},
+ constraints={'site__name': 'Site 1'},
actions=['delete']
)
obj_perm.save()
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 80283340d..cc7a1b379 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -37,7 +37,7 @@ class UserConfigInline(admin.TabularInline):
class ObjectPermissionInline(admin.TabularInline):
model = AdminUser.object_permissions.through
- fields = ['object_types', 'actions', 'attrs']
+ fields = ['object_types', 'actions', 'constraints']
readonly_fields = fields
extra = 0
verbose_name = 'Permission'
@@ -48,8 +48,8 @@ class ObjectPermissionInline(admin.TabularInline):
def actions(self, instance):
return ', '.join(instance.objectpermission.actions)
- def attrs(self, instance):
- return instance.objectpermission.attrs
+ def constraints(self, instance):
+ return instance.objectpermission.constraints
def has_add_permission(self, request, obj):
# Don't allow the creation of new ObjectPermission assignments via this form
@@ -113,8 +113,8 @@ class ObjectPermissionForm(forms.ModelForm):
exclude = []
help_texts = {
'actions': 'Actions granted in addition to those listed above',
- 'attrs': 'JSON expression of a queryset filter that will return only permitted objects. Leave null to '
- 'match all objects of this type.'
+ 'constraints': 'JSON expression of a queryset filter that will return only permitted objects. Leave null '
+ 'to match all objects of this type.'
}
labels = {
'actions': 'Additional actions'
@@ -143,7 +143,7 @@ class ObjectPermissionForm(forms.ModelForm):
def clean(self):
object_types = self.cleaned_data['object_types']
- attrs = self.cleaned_data['attrs']
+ constraints = self.cleaned_data['constraints']
# Append any of the selected CRUD checkboxes to the actions list
if not self.cleaned_data.get('actions'):
@@ -156,16 +156,16 @@ class ObjectPermissionForm(forms.ModelForm):
if not self.cleaned_data['actions']:
raise ValidationError("At least one action must be selected.")
- # Validate the specified model attributes by attempting to execute a query. We don't care whether the query
- # returns anything; we just want to make sure the specified attributes are valid.
- if attrs:
+ # Validate the specified model constraints by attempting to execute a query. We don't care whether the query
+ # returns anything; we just want to make sure the specified constraints are valid.
+ if constraints:
for ct in object_types:
model = ct.model_class()
try:
- model.objects.filter(**attrs).exists()
+ model.objects.filter(**constraints).exists()
except FieldError as e:
raise ValidationError({
- 'attrs': f'Invalid attributes for {model}: {e}'
+ 'constraints': f'Invalid filter for {model}: {e}'
})
@@ -182,13 +182,13 @@ class ObjectPermissionAdmin(admin.ModelAdmin):
'fields': (('can_view', 'can_add', 'can_change', 'can_delete'), 'actions')
}),
('Constraints', {
- 'fields': ('attrs',)
+ 'fields': ('constraints',)
}),
)
filter_horizontal = ('object_types', 'groups', 'users')
form = ObjectPermissionForm
list_display = [
- 'list_models', 'list_users', 'list_groups', 'actions', 'attrs',
+ 'list_models', 'list_users', 'list_groups', 'actions', 'constraints',
]
list_filter = [
'groups', 'users'
diff --git a/netbox/users/migrations/0008_objectpermission.py b/netbox/users/migrations/0008_objectpermission.py
index 4f301264e..3f16e1ee8 100644
--- a/netbox/users/migrations/0008_objectpermission.py
+++ b/netbox/users/migrations/0008_objectpermission.py
@@ -1,5 +1,3 @@
-# Generated by Django 3.0.6 on 2020-05-29 14:59
-
from django.conf import settings
import django.contrib.postgres.fields
import django.contrib.postgres.fields.jsonb
@@ -20,7 +18,7 @@ class Migration(migrations.Migration):
name='ObjectPermission',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
- ('attrs', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
+ ('constraints', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('actions', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=30), size=None)),
('object_types', models.ManyToManyField(limit_choices_to={'app_label__in': ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'virtualization']}, related_name='object_permissions', to='contenttypes.ContentType')),
('groups', models.ManyToManyField(blank=True, related_name='object_permissions', to='auth.Group')),
diff --git a/netbox/users/models.py b/netbox/users/models.py
index 255980dfc..b340ce90f 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -252,10 +252,10 @@ class ObjectPermission(models.Model):
},
related_name='object_permissions'
)
- attrs = JSONField(
+ constraints = JSONField(
blank=True,
null=True,
- verbose_name='Attributes'
+ help_text="Queryset filter matching the applicable objects of the selected type(s)"
)
actions = ArrayField(
base_field=models.CharField(max_length=30),
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 3cf6a9df6..0db0ff936 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -180,7 +180,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
- attrs={'pk': instance1.pk},
+ constraints={'pk': instance1.pk},
actions=['view']
)
obj_perm.save()
@@ -245,7 +245,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk__gt': 0}, # Dummy permission to allow all
+ constraints={'pk__gt': 0}, # Dummy permission to allow all
actions=['add']
)
obj_perm.save()
@@ -265,7 +265,7 @@ class ViewTestCases:
self.assertInstanceEqual(self.model.objects.order_by('pk').last(), self.form_data)
# Nullify ObjectPermission to disallow new object creation
- obj_perm.attrs = {'pk': 0}
+ obj_perm.constraints = {'pk': 0}
obj_perm.save()
# Try to create a non-permitted object
@@ -328,7 +328,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk': instance1.pk},
+ constraints={'pk': instance1.pk},
actions=['change']
)
obj_perm.save()
@@ -406,7 +406,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk': instance1.pk},
+ constraints={'pk': instance1.pk},
actions=['delete']
)
obj_perm.save()
@@ -480,7 +480,7 @@ class ViewTestCases:
# Add object-level permission
obj_perm = ObjectPermission(
- attrs={'pk': instance1.pk},
+ constraints={'pk': instance1.pk},
actions=['view']
)
obj_perm.save()
@@ -579,7 +579,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk__gt': 0}, # Dummy permission to allow all
+ constraints={'pk__gt': 0}, # Dummy permission to allow all
actions=['add']
)
obj_perm.save()
@@ -651,7 +651,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk__in': list(pk_list)},
+ constraints={'pk__in': list(pk_list)},
actions=['change']
)
obj_perm.save()
@@ -718,7 +718,7 @@ class ViewTestCases:
# Assign object-level permission
obj_perm = ObjectPermission(
- attrs={'pk__in': list(pk_list)},
+ constraints={'pk__in': list(pk_list)},
actions=['delete']
)
obj_perm.save()
From 19b57aa1eaa93df5fba86e6b52e40075d7296227 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 10:00:58 -0400
Subject: [PATCH 145/300] Update permissions documentation
---
docs/administration/permissions.md | 22 +++++++++++-----------
docs/release-notes/version-2.9.md | 2 +-
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index 582709726..56ff049ad 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -4,12 +4,12 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
Assigning a permission in NetBox entails defining a relationship among several components:
-* Model(s) - One or more types of object in NetBox
+* Object type(s) - One or more types of object in NetBox
* User(s) - One or more users or groups of users
* Actions - The actions that can be performed (view, add, change, and/or delete)
-* Attributes - An arbitrary filter used to limit the action to a specific subset of objects
+* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
-At a minimum, a permission assignment must specify one model, one user or group, and one action. The specification of constraining attributes is optional: A permission without any attributes specified will apply to all instances of the selected model(s).
+At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
## Actions
@@ -22,11 +22,11 @@ There are four core actions that can be permitted for each type of object within
Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
-## Attributes
+## Constraints
-Constraining attributes are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
+Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
-All attributes defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following attributes.
+All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints.
```json
{
@@ -35,11 +35,11 @@ All attributes defined on a permission are applied with a logic AND. For example
}
```
-The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of attributes, simply create another permission assignment for the same model and user/group.
+The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.
-### Example Attribute Definitions
+### Example Constraint Definitions
-| Query Filter | Permission Attributes |
+| Query Filter | Permission Constraints |
| ------------ | --------------------- |
| `filter(status='active')` | `{"status": "active"}` |
| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` |
@@ -62,7 +62,7 @@ If the permission has been granted, NetBox will compile any specified constraint
]
```
-This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These attributes will result in the following ORM query:
+This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in the following ORM query:
```no-highlight
Site.objects.filter(
@@ -73,4 +73,4 @@ Site.objects.filter(
### Creating and Modifying Objects
-The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the attributes granted by the permission. The transaction is then aborted, and the database is left in its original state.
+The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then aborted, and the database is left in its original state.
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index 4fda77838..be6004feb 100644
--- a/docs/release-notes/version-2.9.md
+++ b/docs/release-notes/version-2.9.md
@@ -6,7 +6,7 @@
#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
-NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of attributes. The permission will apply only to objects which match the specified attributes. For example, assigning permission to modify devices with the attribute filter `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group.
+NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would grant the permission only for devices assigned to a tenant belonging to the "Customers" group.
### Configuration Changes
From 3084d58da1861acbb89f107cdce0953fcb2531eb Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 13:08:04 -0400
Subject: [PATCH 146/300] Add REST API endpoint for ObjectPermissions
---
netbox/netbox/urls.py | 1 +
netbox/netbox/views.py | 1 +
netbox/users/api/nested_serializers.py | 11 +-
netbox/users/api/serializers.py | 26 ++++-
netbox/users/api/urls.py | 21 ++++
netbox/users/api/views.py | 14 +++
netbox/users/models.py | 26 ++---
netbox/users/tests/test_api.py | 144 +++++++++++++++++++++++++
8 files changed, 228 insertions(+), 16 deletions(-)
create mode 100644 netbox/users/api/urls.py
create mode 100644 netbox/users/api/views.py
create mode 100644 netbox/users/tests/test_api.py
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index d8aa2f9d1..a928b79ea 100644
--- a/netbox/netbox/urls.py
+++ b/netbox/netbox/urls.py
@@ -65,6 +65,7 @@ _patterns = [
path('api/ipam/', include('ipam.api.urls')),
path('api/secrets/', include('secrets.api.urls')),
path('api/tenancy/', include('tenancy.api.urls')),
+ path('api/users/', include('users.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')),
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py
index d6be844d4..7ac5f550b 100644
--- a/netbox/netbox/views.py
+++ b/netbox/netbox/views.py
@@ -343,5 +343,6 @@ class APIRootView(APIView):
('plugins', reverse('plugins-api:api-root', request=request, format=format)),
('secrets', reverse('secrets-api:api-root', request=request, format=format)),
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
+ ('users', reverse('users-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
)))
diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py
index d1b649713..f7721cf94 100644
--- a/netbox/users/api/nested_serializers.py
+++ b/netbox/users/api/nested_serializers.py
@@ -1,4 +1,4 @@
-from django.contrib.auth.models import User
+from django.contrib.auth.models import Group, User
from utilities.api import WritableNestedSerializer
@@ -8,9 +8,16 @@ _all_ = [
#
-# Users
+# Groups and users
#
+class NestedGroupSerializer(WritableNestedSerializer):
+
+ class Meta:
+ model = Group
+ fields = ['id', 'name']
+
+
class NestedUserSerializer(WritableNestedSerializer):
class Meta:
diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py
index 86d350e69..dc5301846 100644
--- a/netbox/users/api/serializers.py
+++ b/netbox/users/api/serializers.py
@@ -1,4 +1,28 @@
+from django.contrib.contenttypes.models import ContentType
+
+from users.models import ObjectPermission
+from utilities.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer
from .nested_serializers import *
-# Placeholder for future serializers
+class ObjectPermissionSerializer(ValidatedModelSerializer):
+ object_types = ContentTypeField(
+ queryset=ContentType.objects.all(),
+ many=True
+ )
+ groups = SerializedPKRelatedField(
+ queryset=Group.objects.all(),
+ serializer=NestedGroupSerializer,
+ required=False,
+ many=True
+ )
+ users = SerializedPKRelatedField(
+ queryset=User.objects.all(),
+ serializer=NestedUserSerializer,
+ required=False,
+ many=True
+ )
+
+ class Meta:
+ model = ObjectPermission
+ fields = ('id', 'object_types', 'groups', 'users', 'actions', 'constraints')
diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py
new file mode 100644
index 000000000..fffea5968
--- /dev/null
+++ b/netbox/users/api/urls.py
@@ -0,0 +1,21 @@
+from rest_framework import routers
+
+from . import views
+
+
+class UsersRootView(routers.APIRootView):
+ """
+ Users API root view
+ """
+ def get_view_name(self):
+ return 'Users'
+
+
+router = routers.DefaultRouter()
+router.APIRootView = UsersRootView
+
+# Permissions
+router.register('permissions', views.ObjectPermissionViewSet)
+
+app_name = 'users-api'
+urlpatterns = router.urls
diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py
new file mode 100644
index 000000000..74b315b44
--- /dev/null
+++ b/netbox/users/api/views.py
@@ -0,0 +1,14 @@
+from utilities.api import ModelViewSet
+from . import serializers
+
+from users.models import ObjectPermission
+
+
+#
+# ObjectPermissions
+#
+
+class ObjectPermissionViewSet(ModelViewSet):
+ queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users')
+ serializer_class = serializers.ObjectPermissionSerializer
+ # filterset_class = filters.ObjectPermissionFilterSet
diff --git a/netbox/users/models.py b/netbox/users/models.py
index b340ce90f..fa3277456 100644
--- a/netbox/users/models.py
+++ b/netbox/users/models.py
@@ -233,16 +233,6 @@ class ObjectPermission(models.Model):
A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects
identified by ORM query parameters.
"""
- users = models.ManyToManyField(
- to=User,
- blank=True,
- related_name='object_permissions'
- )
- groups = models.ManyToManyField(
- to=Group,
- blank=True,
- related_name='object_permissions'
- )
object_types = models.ManyToManyField(
to=ContentType,
limit_choices_to={
@@ -252,15 +242,25 @@ class ObjectPermission(models.Model):
},
related_name='object_permissions'
)
- constraints = JSONField(
+ groups = models.ManyToManyField(
+ to=Group,
blank=True,
- null=True,
- help_text="Queryset filter matching the applicable objects of the selected type(s)"
+ related_name='object_permissions'
+ )
+ users = models.ManyToManyField(
+ to=User,
+ blank=True,
+ related_name='object_permissions'
)
actions = ArrayField(
base_field=models.CharField(max_length=30),
help_text="The list of actions granted by this permission"
)
+ constraints = JSONField(
+ blank=True,
+ null=True,
+ help_text="Queryset filter matching the applicable objects of the selected type(s)"
+ )
class Meta:
verbose_name = "Permission"
diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py
new file mode 100644
index 000000000..f507192ee
--- /dev/null
+++ b/netbox/users/tests/test_api.py
@@ -0,0 +1,144 @@
+from django.contrib.auth.models import Group, User
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from rest_framework import status
+
+from users.models import ObjectPermission
+from utilities.testing import APITestCase
+
+
+class AppTest(APITestCase):
+
+ def test_root(self):
+
+ url = reverse('users-api:api-root')
+ response = self.client.get('{}?format=api'.format(url), **self.header)
+
+ self.assertEqual(response.status_code, 200)
+
+
+class ObjectPermissionTest(APITestCase):
+
+ @classmethod
+ def setUpTestData(cls):
+
+ groups = (
+ Group(name='Group 1'),
+ Group(name='Group 2'),
+ Group(name='Group 3'),
+ )
+ Group.objects.bulk_create(groups)
+
+ users = (
+ User(username='User 1', is_active=True),
+ User(username='User 2', is_active=True),
+ User(username='User 3', is_active=True),
+ )
+ User.objects.bulk_create(users)
+
+ object_type = ContentType.objects.get(app_label='dcim', model='device')
+
+ for i in range(0, 3):
+ objectpermission = ObjectPermission(
+ actions=['view', 'add', 'change', 'delete'],
+ constraints={'name': f'TEST{i+1}'}
+ )
+ objectpermission.save()
+ objectpermission.object_types.add(object_type)
+ objectpermission.groups.add(groups[i])
+ objectpermission.users.add(users[i])
+
+ def test_get_objectpermission(self):
+ objectpermission = ObjectPermission.objects.first()
+ url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk})
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(response.data['id'], objectpermission.pk)
+
+ def test_list_objectpermissions(self):
+ url = reverse('users-api:objectpermission-list')
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(response.data['count'], ObjectPermission.objects.count())
+
+ def test_create_objectpermission(self):
+ data = {
+ 'object_types': ['dcim.site'],
+ 'groups': [Group.objects.first().pk],
+ 'users': [User.objects.first().pk],
+ 'actions': ['view', 'add', 'change', 'delete'],
+ 'constraints': {'name': 'TEST4'},
+ }
+
+ url = reverse('users-api:objectpermission-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(ObjectPermission.objects.count(), 4)
+ objectpermission = ObjectPermission.objects.get(pk=response.data['id'])
+ self.assertEqual(objectpermission.groups.first().pk, data['groups'][0])
+ self.assertEqual(objectpermission.users.first().pk, data['users'][0])
+ self.assertEqual(objectpermission.actions, data['actions'])
+ self.assertEqual(objectpermission.constraints, data['constraints'])
+
+ def test_create_objectpermission_bulk(self):
+ groups = Group.objects.all()[:3]
+ users = User.objects.all()[:3]
+ data = [
+ {
+ 'object_types': ['dcim.site'],
+ 'groups': [groups[0].pk],
+ 'users': [users[0].pk],
+ 'actions': ['view', 'add', 'change', 'delete'],
+ 'constraints': {'name': 'TEST4'},
+ },
+ {
+ 'object_types': ['dcim.site'],
+ 'groups': [groups[1].pk],
+ 'users': [users[1].pk],
+ 'actions': ['view', 'add', 'change', 'delete'],
+ 'constraints': {'name': 'TEST5'},
+ },
+ {
+ 'object_types': ['dcim.site'],
+ 'groups': [groups[2].pk],
+ 'users': [users[2].pk],
+ 'actions': ['view', 'add', 'change', 'delete'],
+ 'constraints': {'name': 'TEST6'},
+ },
+ ]
+
+ url = reverse('users-api:objectpermission-list')
+ response = self.client.post(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(ObjectPermission.objects.count(), 6)
+
+ def test_update_objectpermission(self):
+ objectpermission = ObjectPermission.objects.first()
+ data = {
+ 'object_types': ['dcim.site', 'dcim.device'],
+ 'groups': [g.pk for g in Group.objects.all()[:2]],
+ 'users': [u.pk for u in User.objects.all()[:2]],
+ 'actions': ['view'],
+ 'constraints': {'name': 'TEST'},
+ }
+
+ url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk})
+ response = self.client.put(url, data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ self.assertEqual(ObjectPermission.objects.count(), 3)
+ objectpermission = ObjectPermission.objects.get(pk=response.data['id'])
+ self.assertEqual(objectpermission.groups.first().pk, data['groups'][0])
+ self.assertEqual(objectpermission.users.first().pk, data['users'][0])
+ self.assertEqual(objectpermission.actions, data['actions'])
+ self.assertEqual(objectpermission.constraints, data['constraints'])
+
+ def test_delete_objectpermission(self):
+ objectpermission = ObjectPermission.objects.first()
+ url = reverse('users-api:objectpermission-detail', kwargs={'pk': objectpermission.pk})
+ response = self.client.delete(url, **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+ self.assertEqual(ObjectPermission.objects.count(), 2)
From dbf6c0a075a0a92b3d42922baa1fce6f60e71360 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 13:20:35 -0400
Subject: [PATCH 147/300] Split ObjectPermission model documentation
---
docs/administration/permissions.md | 35 +-------------------------
docs/models/users/objectpermission.md | 36 +++++++++++++++++++++++++++
2 files changed, 37 insertions(+), 34 deletions(-)
create mode 100644 docs/models/users/objectpermission.md
diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md
index 56ff049ad..7e47db0d9 100644
--- a/docs/administration/permissions.md
+++ b/docs/administration/permissions.md
@@ -2,40 +2,7 @@
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
-Assigning a permission in NetBox entails defining a relationship among several components:
-
-* Object type(s) - One or more types of object in NetBox
-* User(s) - One or more users or groups of users
-* Actions - The actions that can be performed (view, add, change, and/or delete)
-* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
-
-At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
-
-## Actions
-
-There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
-
-* View - Retrieve an object from the database
-* Add - Create a new object
-* Change - Modify an existing object
-* Delete - Delete an existing object
-
-Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
-
-## Constraints
-
-Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
-
-All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints.
-
-```json
-{
- "status": "active",
- "region__name": "Americas"
-}
-```
-
-The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.
+{!docs/models/users/objectpermission.md!}
### Example Constraint Definitions
diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md
new file mode 100644
index 000000000..80313fc0b
--- /dev/null
+++ b/docs/models/users/objectpermission.md
@@ -0,0 +1,36 @@
+# Object Permissions
+
+Assigning a permission in NetBox entails defining a relationship among several components:
+
+* Object type(s) - One or more types of object in NetBox
+* User(s) - One or more users or groups of users
+* Actions - The actions that can be performed (view, add, change, and/or delete)
+* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
+
+At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
+
+## Actions
+
+There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
+
+* View - Retrieve an object from the database
+* Add - Create a new object
+* Change - Modify an existing object
+* Delete - Delete an existing object
+
+Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
+
+## Constraints
+
+Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
+
+All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints.
+
+```json
+{
+ "status": "active",
+ "region__name": "Americas"
+}
+```
+
+The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.
From b31cc89478414608fa76b32d0ecdf0690516c4f2 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 14:13:18 -0400
Subject: [PATCH 148/300] Dropped backward compatibility for 'webhooks' Redis
queue
---
docs/release-notes/version-2.9.md | 1 +
netbox/netbox/settings.py | 17 ++++-------------
2 files changed, 5 insertions(+), 13 deletions(-)
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index be6004feb..31dc0be02 100644
--- a/docs/release-notes/version-2.9.md
+++ b/docs/release-notes/version-2.9.md
@@ -16,3 +16,4 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
+* Backward compatibility for the `webhooks` Redis queue configuration has been dropped. (Use `tasks` instead.)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 692382262..eb9fab57a 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -195,19 +195,11 @@ if STORAGE_CONFIG and STORAGE_BACKEND is None:
#
# Background task queuing
-if 'tasks' in REDIS:
- TASKS_REDIS = REDIS['tasks']
-elif 'webhooks' in REDIS:
- # TODO: Remove support for 'webhooks' name in v2.9
- warnings.warn(
- "The 'webhooks' REDIS configuration section has been renamed to 'tasks'. Please update your configuration as "
- "support for the old name will be removed in a future release."
- )
- TASKS_REDIS = REDIS['webhooks']
-else:
+if 'tasks' not in REDIS:
raise ImproperlyConfigured(
"REDIS section in configuration.py is missing the 'tasks' subsection."
)
+TASKS_REDIS = REDIS['tasks']
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
@@ -222,12 +214,11 @@ TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
# Caching
-if 'caching' in REDIS:
- CACHING_REDIS = REDIS['caching']
-else:
+if 'caching' not in REDIS:
raise ImproperlyConfigured(
"REDIS section in configuration.py is missing caching subsection."
)
+CACHING_REDIS = REDIS['caching']
CACHING_REDIS_HOST = CACHING_REDIS.get('HOST', 'localhost')
CACHING_REDIS_PORT = CACHING_REDIS.get('PORT', 6379)
CACHING_REDIS_SENTINELS = CACHING_REDIS.get('SENTINELS', [])
From bb1484a444f7a040dc5d3998aedc80521b4ec99b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 14:15:29 -0400
Subject: [PATCH 149/300] Dropped backward compatibility for the
/admin/webhook-backend-status URL
---
docs/release-notes/version-2.9.md | 3 ++-
netbox/netbox/urls.py | 16 +---------------
2 files changed, 3 insertions(+), 16 deletions(-)
diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md
index 31dc0be02..7c63af51c 100644
--- a/docs/release-notes/version-2.9.md
+++ b/docs/release-notes/version-2.9.md
@@ -16,4 +16,5 @@ NetBox v2.9 replaces Django's built-in permissions framework with one that suppo
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
-* Backward compatibility for the `webhooks` Redis queue configuration has been dropped. (Use `tasks` instead.)
+* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead).
+* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`).
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index a928b79ea..4878729b0 100644
--- a/netbox/netbox/urls.py
+++ b/netbox/netbox/urls.py
@@ -1,7 +1,6 @@
from django.conf import settings
from django.conf.urls import include
-from django.urls import path, re_path, reverse
-from django.views.generic.base import RedirectView
+from django.urls import path, re_path
from django.views.static import serve
from drf_yasg import openapi
from drf_yasg.views import get_schema_view
@@ -12,17 +11,6 @@ from users.views import LoginView, LogoutView
from .admin import admin_site
-# TODO: Remove in v2.9
-class RQRedirectView(RedirectView):
- """
- Temporary 301 redirect from the old URL to the new one.
- """
- permanent = True
-
- def get_redirect_url(self, *args, **kwargs):
- return reverse('rq_home')
-
-
openapi_info = openapi.Info(
title="NetBox API",
default_version='v2',
@@ -77,8 +65,6 @@ _patterns = [
# Admin
path('admin/', admin_site.urls),
path('admin/background-tasks/', include('django_rq.urls')),
- # TODO: Remove in v2.9
- path('admin/webhook-backend-status/', RQRedirectView.as_view()),
# Errors
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
From 040fadb0c300b9675bcec17832da4cdb4ed40f40 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 15:42:24 -0400
Subject: [PATCH 150/300] Move LDAP authentication support to LDAPBackend
---
netbox/netbox/authentication.py | 48 +++++++++++++++++++++++
netbox/netbox/settings.py | 69 ---------------------------------
2 files changed, 48 insertions(+), 69 deletions(-)
diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py
index 02b0be0f3..10d2d1b09 100644
--- a/netbox/netbox/authentication.py
+++ b/netbox/netbox/authentication.py
@@ -3,6 +3,7 @@ import logging
from django.conf import settings
from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import Group
+from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
from users.models import ObjectPermission
@@ -132,3 +133,50 @@ class RemoteUserBackend(_RemoteUserBackend):
def has_perm(self, user_obj, perm, obj=None):
return False
+
+
+class LDAPBackend:
+
+ def __new__(cls, *args, **kwargs):
+ try:
+ import ldap
+ from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
+ except ImportError:
+ raise ImproperlyConfigured(
+ "LDAP authentication has been configured, but django-auth-ldap is not installed."
+ )
+
+ try:
+ from netbox import ldap_config
+ except ImportError:
+ raise ImproperlyConfigured(
+ "ldap_config.py does not exist"
+ )
+
+ try:
+ getattr(ldap_config, 'AUTH_LDAP_SERVER_URI')
+ except AttributeError:
+ raise ImproperlyConfigured(
+ "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py."
+ )
+
+ # Create a new instance of django-auth-ldap's LDAPBackend
+ obj = LDAPBackend_()
+
+ # Read LDAP configuration parameters from ldap_config.py instead of settings.py
+ settings = LDAPSettings()
+ for param in dir(ldap_config):
+ if param.startswith(settings._prefix):
+ setattr(settings, param[10:], getattr(ldap_config, param))
+ obj.settings = settings
+
+ # Optionally disable strict certificate checking
+ if getattr(ldap_config, 'LDAP_IGNORE_CERT_ERRORS', False):
+ ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
+
+ # Enable logging for django_auth_ldap
+ ldap_logger = logging.getLogger('django_auth_ldap')
+ ldap_logger.addHandler(logging.StreamHandler())
+ ldap_logger.setLevel(logging.DEBUG)
+
+ return obj
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index eb9fab57a..bc1a8c2e7 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -378,75 +378,6 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
-
-#
-# LDAP authentication (optional)
-#
-
-try:
- from netbox import ldap_config as LDAP_CONFIG
-except ImportError:
- LDAP_CONFIG = None
-
-if LDAP_CONFIG is not None:
-
- # Check that django_auth_ldap is installed
- try:
- import ldap
- import django_auth_ldap
- except ImportError:
- raise ImproperlyConfigured(
- "LDAP authentication has been configured, but django-auth-ldap is not installed. Remove "
- "netbox/ldap_config.py to disable LDAP."
- )
-
- # Required configuration parameters
- try:
- AUTH_LDAP_SERVER_URI = getattr(LDAP_CONFIG, 'AUTH_LDAP_SERVER_URI')
- except AttributeError:
- raise ImproperlyConfigured(
- "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py."
- )
-
- # Optional configuration parameters
- AUTH_LDAP_ALWAYS_UPDATE_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_ALWAYS_UPDATE_USER', True)
- AUTH_LDAP_AUTHORIZE_ALL_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_AUTHORIZE_ALL_USERS', False)
- AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', False)
- AUTH_LDAP_BIND_DN = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_DN', '')
- AUTH_LDAP_BIND_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_PASSWORD', '')
- AUTH_LDAP_CACHE_TIMEOUT = getattr(LDAP_CONFIG, 'AUTH_LDAP_CACHE_TIMEOUT', 0)
- AUTH_LDAP_CONNECTION_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_CONNECTION_OPTIONS', {})
- AUTH_LDAP_DENY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_DENY_GROUP', None)
- AUTH_LDAP_FIND_GROUP_PERMS = getattr(LDAP_CONFIG, 'AUTH_LDAP_FIND_GROUP_PERMS', False)
- AUTH_LDAP_GLOBAL_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_GLOBAL_OPTIONS', {})
- AUTH_LDAP_GROUP_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_SEARCH', None)
- AUTH_LDAP_GROUP_TYPE = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_TYPE', None)
- AUTH_LDAP_MIRROR_GROUPS = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS', None)
- AUTH_LDAP_MIRROR_GROUPS_EXCEPT = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS_EXCEPT', None)
- AUTH_LDAP_PERMIT_EMPTY_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_PERMIT_EMPTY_PASSWORD', False)
- AUTH_LDAP_REQUIRE_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_REQUIRE_GROUP', None)
- AUTH_LDAP_NO_NEW_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_NO_NEW_USERS', False)
- AUTH_LDAP_START_TLS = getattr(LDAP_CONFIG, 'AUTH_LDAP_START_TLS', False)
- AUTH_LDAP_USER_QUERY_FIELD = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_QUERY_FIELD', None)
- AUTH_LDAP_USER_ATTRLIST = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTRLIST', None)
- AUTH_LDAP_USER_ATTR_MAP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTR_MAP', {})
- AUTH_LDAP_USER_DN_TEMPLATE = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_DN_TEMPLATE', None)
- AUTH_LDAP_USER_FLAGS_BY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {})
- AUTH_LDAP_USER_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_SEARCH', None)
-
- # Optionally disable strict certificate checking
- if getattr(LDAP_CONFIG, 'LDAP_IGNORE_CERT_ERRORS', False):
- ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
-
- # Prepend LDAPBackend to the authentication backends list
- AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend')
-
- # Enable logging for django_auth_ldap
- ldap_logger = logging.getLogger('django_auth_ldap')
- ldap_logger.addHandler(logging.StreamHandler())
- ldap_logger.setLevel(logging.DEBUG)
-
-
#
# Caching
#
From dc161d9f2f260e8bae77b3925ee9b023e7acd25b Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Wed, 3 Jun 2020 15:57:11 -0400
Subject: [PATCH 151/300] Update LDAP configuration documentation
---
docs/configuration/optional-settings.md | 5 ++++-
docs/installation/5-ldap.md | 11 +++++++++--
2 files changed, 13 insertions(+), 3 deletions(-)
diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md
index 31ee39a5f..9fddbe82a 100644
--- a/docs/configuration/optional-settings.md
+++ b/docs/configuration/optional-settings.md
@@ -386,7 +386,10 @@ NetBox can be configured to support remote user authentication by inferring user
Default: `'netbox.authentication.RemoteUserBackend'`
-Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
+Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though backends may also be provided via other packages.
+
+* `netbox.authentication.RemoteUserBackend`
+* `netbox.authentication.LDAPBackend`
---
diff --git a/docs/installation/5-ldap.md b/docs/installation/5-ldap.md
index 2fd88b841..bb1300c08 100644
--- a/docs/installation/5-ldap.md
+++ b/docs/installation/5-ldap.md
@@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i
## Configuration
-Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
+First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.)
+
+```python
+REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
+```
+
+Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
### General Server Configuration
@@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
- logfile, maxBytes=1024 * 500, backupCount=5)
+ logfile, maxBytes=1024 * 500, backupCount=5
+)
my_logger.addHandler(handler)
```
From 927c012fc9f8b625642aeecabd5989bb909f6c3c Mon Sep 17 00:00:00 2001
From: Daniel Sheppard
Date: Thu, 4 Jun 2020 09:34:22 -0500
Subject: [PATCH 152/300] #4674 - Fix available-ips and available-prefixes
swagger definitions
---
netbox/ipam/api/views.py | 17 +++++------------
1 file changed, 5 insertions(+), 12 deletions(-)
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index dd3652b1f..70065fd96 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -74,12 +74,8 @@ class PrefixViewSet(CustomFieldModelViewSet):
serializer_class = serializers.PrefixSerializer
filterset_class = filters.PrefixFilterSet
- @swagger_auto_schema(
- methods=['get', 'post'],
- responses={
- 200: serializers.AvailablePrefixSerializer(many=True),
- }
- )
+ @swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
+ @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=False)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
@@ -158,12 +154,9 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
- @swagger_auto_schema(
- methods=['get', 'post'],
- responses={
- 200: serializers.AvailableIPSerializer(many=True),
- }
- )
+ @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
+ @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=False)},
+ request_body=serializers.AvailableIPSerializer(many=False))
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
def available_ips(self, request, pk=None):
From 5330914431a0367ab6e65205b6edcf0daa0c6549 Mon Sep 17 00:00:00 2001
From: Daniel Sheppard
Date: Thu, 4 Jun 2020 09:42:00 -0500
Subject: [PATCH 153/300] #4674 - Correct many=False to many=True on the
response serializers
---
netbox/ipam/api/views.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py
index 70065fd96..076e1f86f 100644
--- a/netbox/ipam/api/views.py
+++ b/netbox/ipam/api/views.py
@@ -75,7 +75,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
filterset_class = filters.PrefixFilterSet
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
- @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=False)})
+ @swagger_auto_schema(method='post', responses={201: serializers.AvailablePrefixSerializer(many=True)})
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
def available_prefixes(self, request, pk=None):
@@ -155,7 +155,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
- @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=False)},
+ @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
request_body=serializers.AvailableIPSerializer(many=False))
@action(detail=True, url_path='available-ips', methods=['get', 'post'])
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
From 91ba44cc96b8c090e2e19cf68a2020a999a0a672 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 4 Jun 2020 11:44:16 -0400
Subject: [PATCH 154/300] Add local_requirements.txt to .gitignore
---
.gitignore | 1 +
1 file changed, 1 insertion(+)
diff --git a/.gitignore b/.gitignore
index 485b46d59..95e4ff702 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,6 +9,7 @@
/netbox/static
/venv/
/*.sh
+local_requirements.txt
!upgrade.sh
fabfile.py
gunicorn.py
From 8f9dcf5a97d007f6341d5f0a5c09d68386c68f07 Mon Sep 17 00:00:00 2001
From: Sander Steffann
Date: Thu, 4 Jun 2020 17:46:09 +0200
Subject: [PATCH 155/300] Avoid unnecessary queries in Cable.from_db
---
netbox/dcim/models/__init__.py | 8 ++++----
1 file changed, 4 insertions(+), 4 deletions(-)
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 1f6478119..98cd37c1c 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -2115,9 +2115,9 @@ class Cable(ChangeLoggedModel):
"""
instance = super().from_db(db, field_names, values)
- instance._orig_termination_a_type = instance.termination_a_type
+ instance._orig_termination_a_type_id = instance.termination_a_type_id
instance._orig_termination_a_id = instance.termination_a_id
- instance._orig_termination_b_type = instance.termination_b_type
+ instance._orig_termination_b_type_id = instance.termination_b_type_id
instance._orig_termination_b_id = instance.termination_b_id
return instance
@@ -2154,14 +2154,14 @@ class Cable(ChangeLoggedModel):
if self.pk:
err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.'
if (
- self.termination_a_type != self._orig_termination_a_type or
+ self.termination_a_type_id != self._orig_termination_a_type_id or
self.termination_a_id != self._orig_termination_a_id
):
raise ValidationError({
'termination_a': err_msg
})
if (
- self.termination_b_type != self._orig_termination_b_type or
+ self.termination_b_type_id != self._orig_termination_b_type_id or
self.termination_b_id != self._orig_termination_b_id
):
raise ValidationError({
From fae115b9959a5ff412a4951063ca427c3dbca6dc Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 4 Jun 2020 13:11:24 -0400
Subject: [PATCH 156/300] Closes #4698: Improve display of template code for
object in admin UI
---
docs/release-notes/version-2.8.md | 4 +++
netbox/extras/admin.py | 56 +++++++++++++++++++++++++------
2 files changed, 50 insertions(+), 10 deletions(-)
diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md
index 5dcb5f01f..6bb395524 100644
--- a/docs/release-notes/version-2.8.md
+++ b/docs/release-notes/version-2.8.md
@@ -2,6 +2,10 @@
## v2.8.6 (FUTURE)
+### Enhancements
+
+* [#4698](https://github.com/netbox-community/netbox/issues/4698) - Improve display of template code for object in admin UI
+
### Bug Fixes
* [#4702](https://github.com/netbox-community/netbox/issues/4702) - Catch IntegrityError exception when adding a non-unique secret
diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py
index 8f6a20db6..808d7ce32 100644
--- a/netbox/extras/admin.py
+++ b/netbox/extras/admin.py
@@ -46,24 +46,19 @@ class WebhookAdmin(admin.ModelAdmin):
form = WebhookForm
fieldsets = (
(None, {
- 'fields': (
- 'name', 'obj_type', 'enabled',
- )
+ 'fields': ('name', 'obj_type', 'enabled')
}),
('Events', {
- 'fields': (
- 'type_create', 'type_update', 'type_delete',
- )
+ 'fields': ('type_create', 'type_update', 'type_delete')
}),
('HTTP Request', {
'fields': (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
- )
+ ),
+ 'classes': ('monospace',)
}),
('SSL', {
- 'fields': (
- 'ssl_verification', 'ca_file_path',
- )
+ 'fields': ('ssl_verification', 'ca_file_path')
})
)
@@ -121,6 +116,8 @@ class CustomLinkForm(forms.ModelForm):
'url': forms.Textarea,
}
help_texts = {
+ 'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear '
+ 'first in a list.',
'text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. Links '
'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.',
@@ -136,6 +133,15 @@ class CustomLinkForm(forms.ModelForm):
@admin.register(CustomLink)
class CustomLinkAdmin(admin.ModelAdmin):
+ fieldsets = (
+ ('Custom Link', {
+ 'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window')
+ }),
+ ('Templates', {
+ 'fields': ('text', 'url'),
+ 'classes': ('monospace',)
+ })
+ )
list_display = [
'name', 'content_type', 'group_name', 'weight',
]
@@ -149,8 +155,29 @@ class CustomLinkAdmin(admin.ModelAdmin):
# Graphs
#
+class GraphForm(forms.ModelForm):
+
+ class Meta:
+ model = Graph
+ exclude = ()
+ widgets = {
+ 'source': forms.Textarea,
+ 'link': forms.Textarea,
+ }
+
+
@admin.register(Graph)
class GraphAdmin(admin.ModelAdmin):
+ fieldsets = (
+ ('Graph', {
+ 'fields': ('type', 'name', 'weight')
+ }),
+ ('Templates', {
+ 'fields': ('template_language', 'source', 'link'),
+ 'classes': ('monospace',)
+ })
+ )
+ form = GraphForm
list_display = [
'name', 'type', 'weight', 'template_language', 'source',
]
@@ -179,6 +206,15 @@ class ExportTemplateForm(forms.ModelForm):
@admin.register(ExportTemplate)
class ExportTemplateAdmin(admin.ModelAdmin):
+ fieldsets = (
+ ('Export Template', {
+ 'fields': ('content_type', 'name', 'description', 'mime_type', 'file_extension')
+ }),
+ ('Content', {
+ 'fields': ('template_language', 'template_code'),
+ 'classes': ('monospace',)
+ })
+ )
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension',
]
From cde1db443673036d407a6ff9ea68eb585d0924b7 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Thu, 4 Jun 2020 16:44:25 -0400
Subject: [PATCH 157/300] Add `label` to interface models
---
.../dcim/migrations/0107_interface_label.py | 23 +++++++++++++++++++
.../dcim/models/device_component_templates.py | 5 ++++
netbox/dcim/models/device_components.py | 5 ++++
3 files changed, 33 insertions(+)
create mode 100644 netbox/dcim/migrations/0107_interface_label.py
diff --git a/netbox/dcim/migrations/0107_interface_label.py b/netbox/dcim/migrations/0107_interface_label.py
new file mode 100644
index 000000000..48970d25d
--- /dev/null
+++ b/netbox/dcim/migrations/0107_interface_label.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.0.7 on 2020-06-04 20:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0106_role_default_color'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interface',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='interfacetemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ ]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 164d37d77..1a9e7bbf9 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -263,6 +263,11 @@ class InterfaceTemplate(ComponentTemplateModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this interface"
+ )
type = models.CharField(
max_length=50,
choices=InterfaceTypeChoices
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 4005d41a4..e5c2e5936 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -605,6 +605,11 @@ class Interface(CableTermination, ComponentModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this interface"
+ )
_connected_interface = models.OneToOneField(
to='self',
on_delete=models.SET_NULL,
From 4ae1879b872c2e9843604cba8d20c23a449c86cf Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 4 Jun 2020 16:45:03 -0400
Subject: [PATCH 158/300] Introduce APIViewTestCases for standardized API view
testing
---
netbox/utilities/testing/testcases.py | 191 +++++++++++++++++++++-----
1 file changed, 155 insertions(+), 36 deletions(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index d10bb025a..149ae8000 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist
from django.forms.models import model_to_dict
from django.test import Client, TestCase as _TestCase, override_settings
from django.urls import reverse, NoReverseMatch
+from rest_framework import status
from rest_framework.test import APIClient
from users.models import Token
@@ -57,6 +58,34 @@ class TestCase(_TestCase):
expected_status, response.status_code, getattr(response, 'data', 'No data')
))
+ def assertInstanceEqual(self, instance, data):
+ """
+ Compare a model instance to a dictionary, checking that its attribute values match those specified
+ in the dictionary.
+ """
+ model_dict = model_to_dict(instance, fields=data.keys())
+
+ for key in list(model_dict.keys()):
+
+ # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
+ if key == 'tags':
+ model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
+
+ # Convert ManyToManyField to list of instance PKs
+ elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
+ model_dict[key] = [obj.pk for obj in model_dict[key]]
+
+ # Omit any dictionary keys which are not instance attributes
+ relevant_data = {
+ k: v for k, v in data.items() if hasattr(instance, k)
+ }
+
+ self.assertDictEqual(model_dict, relevant_data)
+
+
+#
+# UI Tests
+#
class ModelViewTestCase(TestCase):
"""
@@ -104,42 +133,6 @@ class ModelViewTestCase(TestCase):
else:
raise Exception("Invalid action for URL resolution: {}".format(action))
- def assertInstanceEqual(self, instance, data):
- """
- Compare a model instance to a dictionary, checking that its attribute values match those specified
- in the dictionary.
- """
- model_dict = model_to_dict(instance, fields=data.keys())
-
- for key in list(model_dict.keys()):
-
- # TODO: Differentiate between tags assigned to the instance and a M2M field for tags (ex: ConfigContext)
- if key == 'tags':
- model_dict[key] = ','.join(sorted([tag.name for tag in model_dict['tags']]))
-
- # Convert ManyToManyField to list of instance PKs
- elif model_dict[key] and type(model_dict[key]) in (list, tuple) and hasattr(model_dict[key][0], 'pk'):
- model_dict[key] = [obj.pk for obj in model_dict[key]]
-
- # Omit any dictionary keys which are not instance attributes
- relevant_data = {
- k: v for k, v in data.items() if hasattr(instance, k)
- }
-
- self.assertDictEqual(model_dict, relevant_data)
-
-
-class APITestCase(TestCase):
- client_class = APIClient
-
- def setUp(self):
- """
- Create a superuser and token for API calls.
- """
- self.user = User.objects.create(username='testuser', is_superuser=True)
- self.token = Token.objects.create(user=self.user)
- self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
-
class ViewTestCases:
"""
@@ -488,3 +481,129 @@ class ViewTestCases:
TestCase suitable for testing device component models (ConsolePorts, Interfaces, etc.)
"""
maxDiff = None
+
+
+#
+# REST API Tests
+#
+
+class APITestCase(TestCase):
+ client_class = APIClient
+ model = None
+
+ def setUp(self):
+ """
+ Create a superuser and token for API calls.
+ """
+ self.user = User.objects.create(username='testuser', is_superuser=True)
+ self.token = Token.objects.create(user=self.user)
+ self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
+
+ def _get_detail_url(self, instance):
+ viewname = f'{instance._meta.app_label}-api:{instance._meta.model_name}-detail'
+ return reverse(viewname, kwargs={'pk': instance.pk})
+
+ def _get_list_url(self):
+ viewname = f'{self.model._meta.app_label}-api:{self.model._meta.model_name}-list'
+ return reverse(viewname)
+
+
+class APIViewTestCases:
+
+ class GetObjectViewTestCase(APITestCase):
+
+ def test_get_object(self):
+ """
+ GET a single object identified by its numeric ID.
+ """
+ instance = self.model.objects.first()
+ url = self._get_detail_url(instance)
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(response.data['id'], instance.pk)
+
+ class ListObjectsViewTestCase(APITestCase):
+ brief_fields = []
+
+ def test_list_objects(self):
+ """
+ GET a list of objects.
+ """
+ url = self._get_list_url()
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(len(response.data['results']), self.model.objects.count())
+
+ def test_list_objects_brief(self):
+ """
+ GET a list of objects using the "brief" parameter.
+ """
+ url = f'{self._get_list_url()}?brief=1'
+ response = self.client.get(url, **self.header)
+
+ self.assertEqual(len(response.data['results']), self.model.objects.count())
+ self.assertEqual(sorted(response.data['results'][0]), self.brief_fields)
+
+ class CreateObjectViewTestCase(APITestCase):
+ create_data = []
+
+ def test_create_object(self):
+ """
+ POST a single object.
+ """
+ initial_count = self.model.objects.count()
+ url = self._get_list_url()
+ response = self.client.post(url, self.create_data[0], format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(self.model.objects.count(), initial_count + 1)
+ self.assertInstanceEqual(self.model.objects.get(pk=response.data['id']), self.create_data[0])
+
+ def test_bulk_create_object(self):
+ """
+ POST a set of objects in a single request.
+ """
+ initial_count = self.model.objects.count()
+ url = self._get_list_url()
+ response = self.client.post(url, self.create_data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_201_CREATED)
+ self.assertEqual(self.model.objects.count(), initial_count + len(self.create_data))
+
+ class UpdateObjectViewTestCase(APITestCase):
+ update_data = {}
+
+ def test_update_object(self):
+ """
+ PATCH a single object identified by its numeric ID.
+ """
+ instance = self.model.objects.first()
+ url = self._get_detail_url(instance)
+ update_data = self.update_data
+ response = self.client.patch(url, update_data, format='json', **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_200_OK)
+ instance.refresh_from_db()
+ self.assertInstanceEqual(instance, self.update_data)
+
+ class DeleteObjectViewTestCase(APITestCase):
+
+ def test_delete_object(self):
+ """
+ DELETE a single object identified by its numeric ID.
+ """
+ instance = self.model.objects.first()
+ url = self._get_detail_url(instance)
+ response = self.client.delete(url, **self.header)
+
+ self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
+ self.assertFalse(self.model.objects.filter(pk=instance.pk).exists())
+
+ class APIViewTestCase(
+ GetObjectViewTestCase,
+ ListObjectsViewTestCase,
+ CreateObjectViewTestCase,
+ UpdateObjectViewTestCase,
+ DeleteObjectViewTestCase
+ ):
+ pass
From 4219691e62724a5617ffa2d85eb635790139f8fd Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Thu, 4 Jun 2020 16:47:15 -0400
Subject: [PATCH 159/300] Update circuits API tests to use APIViewTestCases
---
netbox/circuits/tests/test_api.py | 490 ++++++++----------------------
1 file changed, 122 insertions(+), 368 deletions(-)
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index b5f8758e7..751d58339 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -1,43 +1,55 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
-from rest_framework import status
from circuits.choices import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site
from extras.models import Graph
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
def test_root(self):
-
url = reverse('circuits-api:api-root')
response = self.client.get('{}?format=api'.format(url), **self.header)
self.assertEqual(response.status_code, 200)
-class ProviderTest(APITestCase):
+class ProviderTest(APIViewTestCases.APIViewTestCase):
+ model = Provider
+ brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Provider 4',
+ 'slug': 'provider-4',
+ },
+ {
+ 'name': 'Provider 5',
+ 'slug': 'provider-5',
+ },
+ {
+ 'name': 'Provider 6',
+ 'slug': 'provider-6',
+ },
+ ]
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
- super().setUp()
-
- self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
- self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
- self.provider3 = Provider.objects.create(name='Test Provider 3', slug='test-provider-3')
-
- def test_get_provider(self):
-
- url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.provider1.name)
+ providers = (
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ Provider(name='Provider 3', slug='provider-3'),
+ )
+ Provider.objects.bulk_create(providers)
def test_get_provider_graphs(self):
-
+ """
+ Test retrieval of Graphs assigned to Providers.
+ """
+ provider = self.model.objects.first()
provider_ct = ContentType.objects.get(app_label='circuits', model='provider')
self.graph1 = Graph.objects.create(
type=provider_ct,
@@ -55,389 +67,131 @@ class ProviderTest(APITestCase):
source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
)
- url = reverse('circuits-api:provider-graphs', kwargs={'pk': self.provider1.pk})
+ url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
- self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=test-provider-1&foo=1')
+ self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?provider=provider-1&foo=1')
- def test_list_providers(self):
- url = reverse('circuits-api:provider-list')
- response = self.client.get(url, **self.header)
+class CircuitTypeTest(APIViewTestCases.APIViewTestCase):
+ model = CircuitType
+ brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url']
+ create_data = (
+ {
+ 'name': 'Circuit Type 4',
+ 'slug': 'circuit-type-4',
+ },
+ {
+ 'name': 'Circuit Type 5',
+ 'slug': 'circuit-type-5',
+ },
+ {
+ 'name': 'Circuit Type 6',
+ 'slug': 'circuit-type-6',
+ },
+ )
- self.assertEqual(response.data['count'], 3)
+ @classmethod
+ def setUpTestData(cls):
- def test_list_providers_brief(self):
-
- url = reverse('circuits-api:provider-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['circuit_count', 'id', 'name', 'slug', 'url']
+ circuit_types = (
+ CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
+ CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
+ CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)
+ CircuitType.objects.bulk_create(circuit_types)
- def test_create_provider(self):
- data = {
- 'name': 'Test Provider 4',
- 'slug': 'test-provider-4',
- }
+class CircuitTest(APIViewTestCases.APIViewTestCase):
+ model = Circuit
+ brief_fields = ['cid', 'id', 'url']
- url = reverse('circuits-api:provider-list')
- response = self.client.post(url, data, format='json', **self.header)
+ @classmethod
+ def setUpTestData(cls):
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Provider.objects.count(), 4)
- provider4 = Provider.objects.get(pk=response.data['id'])
- self.assertEqual(provider4.name, data['name'])
- self.assertEqual(provider4.slug, data['slug'])
+ providers = (
+ Provider(name='Provider 1', slug='provider-1'),
+ Provider(name='Provider 2', slug='provider-2'),
+ )
+ Provider.objects.bulk_create(providers)
- def test_create_provider_bulk(self):
+ circuit_types = (
+ CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
+ CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
+ )
+ CircuitType.objects.bulk_create(circuit_types)
- data = [
+ circuits = (
+ Circuit(cid='Circuit 1', provider=providers[0], type=circuit_types[0]),
+ Circuit(cid='Circuit 2', provider=providers[0], type=circuit_types[0]),
+ Circuit(cid='Circuit 3', provider=providers[0], type=circuit_types[0]),
+ )
+ Circuit.objects.bulk_create(circuits)
+
+ cls.create_data = [
{
- 'name': 'Test Provider 4',
- 'slug': 'test-provider-4',
+ 'cid': 'Circuit 4',
+ 'provider': providers[1].pk,
+ 'type': circuit_types[1].pk,
},
{
- 'name': 'Test Provider 5',
- 'slug': 'test-provider-5',
+ 'cid': 'Circuit 5',
+ 'provider': providers[1].pk,
+ 'type': circuit_types[1].pk,
},
{
- 'name': 'Test Provider 6',
- 'slug': 'test-provider-6',
+ 'cid': 'Circuit 6',
+ 'provider': providers[1].pk,
+ 'type': circuit_types[1].pk,
},
]
- url = reverse('circuits-api:provider-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Provider.objects.count(), 6)
- 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'])
+class CircuitTerminationTest(APIViewTestCases.APIViewTestCase):
+ model = CircuitTermination
+ brief_fields = ['circuit', 'id', 'term_side', 'url']
- def test_update_provider(self):
+ @classmethod
+ def setUpTestData(cls):
+ SIDE_A = CircuitTerminationSideChoices.SIDE_A
+ SIDE_Z = CircuitTerminationSideChoices.SIDE_Z
- data = {
- 'name': 'Test Provider X',
- 'slug': 'test-provider-x',
- }
-
- url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Provider.objects.count(), 3)
- provider1 = Provider.objects.get(pk=response.data['id'])
- self.assertEqual(provider1.name, data['name'])
- self.assertEqual(provider1.slug, data['slug'])
-
- def test_delete_provider(self):
-
- url = reverse('circuits-api:provider-detail', kwargs={'pk': self.provider1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Provider.objects.count(), 2)
-
-
-class CircuitTypeTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
- self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
- self.circuittype3 = CircuitType.objects.create(name='Test Circuit Type 3', slug='test-circuit-type-3')
-
- def test_get_circuittype(self):
-
- url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.circuittype1.name)
-
- def test_list_circuittypes(self):
-
- url = reverse('circuits-api:circuittype-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_circuittypes_brief(self):
-
- url = reverse('circuits-api:circuittype-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['circuit_count', 'id', 'name', 'slug', 'url']
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
)
+ Site.objects.bulk_create(sites)
- def test_create_circuittype(self):
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+ circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
- data = {
- 'name': 'Test Circuit Type 4',
- 'slug': 'test-circuit-type-4',
- }
-
- url = reverse('circuits-api:circuittype-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(CircuitType.objects.count(), 4)
- circuittype4 = CircuitType.objects.get(pk=response.data['id'])
- self.assertEqual(circuittype4.name, data['name'])
- self.assertEqual(circuittype4.slug, data['slug'])
-
- def test_update_circuittype(self):
-
- data = {
- 'name': 'Test Circuit Type X',
- 'slug': 'test-circuit-type-x',
- }
-
- url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(CircuitType.objects.count(), 3)
- circuittype1 = CircuitType.objects.get(pk=response.data['id'])
- self.assertEqual(circuittype1.name, data['name'])
- self.assertEqual(circuittype1.slug, data['slug'])
-
- def test_delete_circuittype(self):
-
- url = reverse('circuits-api:circuittype-detail', kwargs={'pk': self.circuittype1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(CircuitType.objects.count(), 2)
-
-
-class CircuitTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.provider1 = Provider.objects.create(name='Test Provider 1', slug='test-provider-1')
- self.provider2 = Provider.objects.create(name='Test Provider 2', slug='test-provider-2')
- self.circuittype1 = CircuitType.objects.create(name='Test Circuit Type 1', slug='test-circuit-type-1')
- self.circuittype2 = CircuitType.objects.create(name='Test Circuit Type 2', slug='test-circuit-type-2')
- self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=self.provider1, type=self.circuittype1)
- self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=self.provider1, type=self.circuittype1)
- self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=self.provider1, type=self.circuittype1)
-
- def test_get_circuit(self):
-
- url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['cid'], self.circuit1.cid)
-
- def test_list_circuits(self):
-
- url = reverse('circuits-api:circuit-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_circuits_brief(self):
-
- url = reverse('circuits-api:circuit-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cid', 'id', 'url']
+ circuits = (
+ Circuit(cid='Circuit 1', provider=provider, type=circuit_type),
+ Circuit(cid='Circuit 2', provider=provider, type=circuit_type),
+ Circuit(cid='Circuit 3', provider=provider, type=circuit_type),
)
+ Circuit.objects.bulk_create(circuits)
- def test_create_circuit(self):
+ circuit_terminations = (
+ CircuitTermination(circuit=circuits[0], site=sites[0], port_speed=100000, term_side=SIDE_A),
+ CircuitTermination(circuit=circuits[0], site=sites[1], port_speed=100000, term_side=SIDE_Z),
+ CircuitTermination(circuit=circuits[1], site=sites[0], port_speed=100000, term_side=SIDE_A),
+ CircuitTermination(circuit=circuits[1], site=sites[1], port_speed=100000, term_side=SIDE_Z),
+ )
+ CircuitTermination.objects.bulk_create(circuit_terminations)
- data = {
- 'cid': 'TEST0004',
- 'provider': self.provider1.pk,
- 'type': self.circuittype1.pk,
- 'status': CircuitStatusChoices.STATUS_ACTIVE,
- }
-
- url = reverse('circuits-api:circuit-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Circuit.objects.count(), 4)
- circuit4 = Circuit.objects.get(pk=response.data['id'])
- self.assertEqual(circuit4.cid, data['cid'])
- self.assertEqual(circuit4.provider_id, data['provider'])
- self.assertEqual(circuit4.type_id, data['type'])
-
- def test_create_circuit_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'cid': 'TEST0004',
- 'provider': self.provider1.pk,
- 'type': self.circuittype1.pk,
- 'status': CircuitStatusChoices.STATUS_ACTIVE,
+ 'circuit': circuits[2].pk,
+ 'term_side': SIDE_A,
+ 'site': sites[1].pk,
+ 'port_speed': 200000,
},
{
- 'cid': 'TEST0005',
- 'provider': self.provider1.pk,
- 'type': self.circuittype1.pk,
- 'status': CircuitStatusChoices.STATUS_ACTIVE,
- },
- {
- 'cid': 'TEST0006',
- 'provider': self.provider1.pk,
- 'type': self.circuittype1.pk,
- 'status': CircuitStatusChoices.STATUS_ACTIVE,
+ 'circuit': circuits[2].pk,
+ 'term_side': SIDE_Z,
+ 'site': sites[1].pk,
+ 'port_speed': 200000,
},
]
-
- url = reverse('circuits-api:circuit-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Circuit.objects.count(), 6)
- self.assertEqual(response.data[0]['cid'], data[0]['cid'])
- self.assertEqual(response.data[1]['cid'], data[1]['cid'])
- self.assertEqual(response.data[2]['cid'], data[2]['cid'])
-
- def test_update_circuit(self):
-
- data = {
- 'cid': 'TEST000X',
- 'provider': self.provider2.pk,
- 'type': self.circuittype2.pk,
- }
-
- url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Circuit.objects.count(), 3)
- circuit1 = Circuit.objects.get(pk=response.data['id'])
- self.assertEqual(circuit1.cid, data['cid'])
- self.assertEqual(circuit1.provider_id, data['provider'])
- self.assertEqual(circuit1.type_id, data['type'])
-
- def test_delete_circuit(self):
-
- url = reverse('circuits-api:circuit-detail', kwargs={'pk': self.circuit1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Circuit.objects.count(), 2)
-
-
-class CircuitTerminationTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
- provider = Provider.objects.create(name='Test Provider', slug='test-provider')
- circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
- self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
- self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
- self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
- self.circuittermination1 = CircuitTermination.objects.create(
- circuit=self.circuit1,
- term_side=CircuitTerminationSideChoices.SIDE_A,
- site=self.site1,
- port_speed=1000000
- )
- self.circuittermination2 = CircuitTermination.objects.create(
- circuit=self.circuit1,
- term_side=CircuitTerminationSideChoices.SIDE_Z,
- site=self.site2,
- port_speed=1000000
- )
- self.circuittermination3 = CircuitTermination.objects.create(
- circuit=self.circuit2,
- term_side=CircuitTerminationSideChoices.SIDE_A,
- site=self.site1,
- port_speed=1000000
- )
- self.circuittermination4 = CircuitTermination.objects.create(
- circuit=self.circuit2,
- term_side=CircuitTerminationSideChoices.SIDE_Z,
- site=self.site2,
- port_speed=1000000
- )
-
- def test_get_circuittermination(self):
-
- url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['id'], self.circuittermination1.pk)
-
- def test_list_circuitterminations(self):
-
- url = reverse('circuits-api:circuittermination-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 4)
-
- def test_create_circuittermination(self):
-
- data = {
- 'circuit': self.circuit3.pk,
- 'term_side': CircuitTerminationSideChoices.SIDE_A,
- 'site': self.site1.pk,
- 'port_speed': 1000000,
- }
-
- url = reverse('circuits-api:circuittermination-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(CircuitTermination.objects.count(), 5)
- circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
- self.assertEqual(circuittermination4.circuit_id, data['circuit'])
- self.assertEqual(circuittermination4.term_side, data['term_side'])
- self.assertEqual(circuittermination4.site_id, data['site'])
- self.assertEqual(circuittermination4.port_speed, data['port_speed'])
-
- def test_update_circuittermination(self):
-
- circuittermination5 = CircuitTermination.objects.create(
- circuit=self.circuit3,
- term_side=CircuitTerminationSideChoices.SIDE_A,
- site=self.site1,
- port_speed=1000000
- )
-
- data = {
- 'circuit': self.circuit3.pk,
- 'term_side': CircuitTerminationSideChoices.SIDE_Z,
- 'site': self.site2.pk,
- 'port_speed': 1000000,
- }
-
- url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(CircuitTermination.objects.count(), 5)
- circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
- self.assertEqual(circuittermination1.term_side, data['term_side'])
- self.assertEqual(circuittermination1.site_id, data['site'])
- self.assertEqual(circuittermination1.port_speed, data['port_speed'])
-
- def test_delete_circuittermination(self):
-
- url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(CircuitTermination.objects.count(), 3)
From e9f8640ee680e6eacf49a1ad0523bde1055dd86c Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Thu, 4 Jun 2020 16:50:51 -0400
Subject: [PATCH 160/300] Add `label` to Interface serializers
---
netbox/dcim/api/serializers.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 9ac58dc3a..e8bc57d1d 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -304,7 +304,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = InterfaceTemplate
- fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
+ fields = ['id', 'device_type', 'name', 'label', 'type', 'mgmt_only']
class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -536,7 +536,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = Interface
fields = [
- 'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
+ 'id', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses',
]
From f8851121abd26eaa2a761c04375d95f76b666acb Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Thu, 4 Jun 2020 17:11:27 -0400
Subject: [PATCH 161/300] Add the `label` to the string representation
---
netbox/dcim/models/device_component_templates.py | 2 ++
netbox/dcim/models/device_components.py | 2 ++
2 files changed, 4 insertions(+)
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index 1a9e7bbf9..f61c3f057 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -282,6 +282,8 @@ class InterfaceTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device):
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index e5c2e5936..9d9e79196 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -691,6 +691,8 @@ class Interface(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def get_absolute_url(self):
From 1d5f2fbd117add86ad03d843092ffdd140c12cd1 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 5 Jun 2020 09:19:31 -0400
Subject: [PATCH 162/300] Correct test method name
---
netbox/utilities/testing/testcases.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index 149ae8000..ea393152d 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -158,7 +158,7 @@ class ViewTestCases:
self.assertHttpStatus(response, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
- def test_list_objects_anonymous(self):
+ def test_get_object_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
response = self.client.get(self.model.objects.first().get_absolute_url())
From 86e5a09b01cd1a826b6673bff9787dfedb106f13 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 5 Jun 2020 09:36:38 -0400
Subject: [PATCH 163/300] Optimize test_get_provider_graphs()
---
netbox/circuits/tests/test_api.py | 21 ++++++---------------
1 file changed, 6 insertions(+), 15 deletions(-)
diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py
index 751d58339..b2a1398a0 100644
--- a/netbox/circuits/tests/test_api.py
+++ b/netbox/circuits/tests/test_api.py
@@ -50,22 +50,13 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
Test retrieval of Graphs assigned to Providers.
"""
provider = self.model.objects.first()
- provider_ct = ContentType.objects.get(app_label='circuits', model='provider')
- self.graph1 = Graph.objects.create(
- type=provider_ct,
- name='Test Graph 1',
- source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=1'
- )
- self.graph2 = Graph.objects.create(
- type=provider_ct,
- name='Test Graph 2',
- source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=2'
- )
- self.graph3 = Graph.objects.create(
- type=provider_ct,
- name='Test Graph 3',
- source='http://example.com/graphs.py?provider={{ obj.slug }}&foo=3'
+ ct = ContentType.objects.get(app_label='circuits', model='provider')
+ graphs = (
+ Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'),
+ Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'),
+ Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'),
)
+ Graph.objects.bulk_create(graphs)
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)
From 8cc1dc9f1c546c86122f7286029721c04fe5e345 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 5 Jun 2020 10:05:54 -0400
Subject: [PATCH 164/300] Fix update data
---
netbox/utilities/testing/testcases.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/netbox/utilities/testing/testcases.py b/netbox/utilities/testing/testcases.py
index ea393152d..7c2cde018 100644
--- a/netbox/utilities/testing/testcases.py
+++ b/netbox/utilities/testing/testcases.py
@@ -579,7 +579,7 @@ class APIViewTestCases:
"""
instance = self.model.objects.first()
url = self._get_detail_url(instance)
- update_data = self.update_data
+ update_data = self.update_data or getattr(self, 'create_data')[0]
response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
From a06d74472da1c4ffbcb8e3fc23b9b8f62af079f0 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Fri, 5 Jun 2020 10:32:59 -0400
Subject: [PATCH 165/300] Add `label` to *ports models
---
netbox/dcim/migrations/0108_port_label.py | 53 +++++++++++++++++++
.../dcim/models/device_component_templates.py | 20 +++++++
netbox/dcim/models/device_components.py | 20 +++++++
3 files changed, 93 insertions(+)
create mode 100644 netbox/dcim/migrations/0108_port_label.py
diff --git a/netbox/dcim/migrations/0108_port_label.py b/netbox/dcim/migrations/0108_port_label.py
new file mode 100644
index 000000000..af0aa1962
--- /dev/null
+++ b/netbox/dcim/migrations/0108_port_label.py
@@ -0,0 +1,53 @@
+# Generated by Django 3.0.7 on 2020-06-05 14:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0107_interface_label'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='consoleport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleserverport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='consoleserverporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='poweroutlet',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='poweroutlettemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='powerport',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ migrations.AddField(
+ model_name='powerporttemplate',
+ name='label',
+ field=models.CharField(blank=True, max_length=64),
+ ),
+ ]
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index f61c3f057..b7f94a450 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -69,6 +69,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this console port"
+ )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -107,6 +112,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this console server port"
+ )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -145,6 +155,11 @@ class PowerPortTemplate(ComponentTemplateModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this power supply port"
+ )
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -197,6 +212,11 @@ class PowerOutletTemplate(ComponentTemplateModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this power outlet"
+ )
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 9d9e79196..860e1bd65 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -231,6 +231,11 @@ class ConsolePort(CableTermination, ComponentModel):
name = models.CharField(
max_length=50
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this console port"
+ )
_name = NaturalOrderingField(
target_field='name',
max_length=100,
@@ -298,6 +303,11 @@ class ConsoleServerPort(CableTermination, ComponentModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this console server port"
+ )
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -353,6 +363,11 @@ class PowerPort(CableTermination, ComponentModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this power supply port"
+ )
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -516,6 +531,11 @@ class PowerOutlet(CableTermination, ComponentModel):
max_length=100,
blank=True
)
+ label = models.CharField(
+ max_length=64,
+ blank=True,
+ help_text="The physical label for this power outlet"
+ )
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
From 1fae9aff0c90d5217309263388ed02925435d4f2 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Fri, 5 Jun 2020 10:42:13 -0400
Subject: [PATCH 166/300] Add `label` to *port serializers
---
netbox/dcim/api/serializers.py | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index e8bc57d1d..a2f576aca 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -248,7 +248,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
- fields = ['id', 'device_type', 'name', 'type']
+ fields = ['id', 'device_type', 'name', 'label', 'type']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
@@ -261,7 +261,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
- fields = ['id', 'device_type', 'name', 'type']
+ fields = ['id', 'device_type', 'name', 'label', 'type']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
@@ -274,7 +274,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
- fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
+ fields = ['id', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
@@ -295,7 +295,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
- fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
+ fields = ['id', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
@@ -446,7 +446,7 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
class Meta:
model = ConsoleServerPort
fields = [
- 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
+ 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
@@ -464,7 +464,7 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = ConsolePort
fields = [
- 'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
+ 'id', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
]
@@ -494,7 +494,7 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerOutlet
fields = [
- 'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
+ 'id', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
@@ -512,7 +512,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerPort
fields = [
- 'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
+ 'id', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
From e21cbf2a0667e2d014740a1bfef06a056c238a40 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Fri, 5 Jun 2020 11:01:39 -0400
Subject: [PATCH 167/300] Add the `label` to the string representation
---
netbox/dcim/models/device_component_templates.py | 8 ++++++++
netbox/dcim/models/device_components.py | 8 ++++++++
2 files changed, 16 insertions(+)
diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py
index b7f94a450..acb5f0d46 100644
--- a/netbox/dcim/models/device_component_templates.py
+++ b/netbox/dcim/models/device_component_templates.py
@@ -85,6 +85,8 @@ class ConsolePortTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device):
@@ -128,6 +130,8 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device):
@@ -183,6 +187,8 @@ class PowerPortTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device):
@@ -241,6 +247,8 @@ class PowerOutletTemplate(ComponentTemplateModel):
unique_together = ('device_type', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def clean(self):
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 860e1bd65..b8331365d 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -267,6 +267,8 @@ class ConsolePort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def get_absolute_url(self):
@@ -327,6 +329,8 @@ class ConsoleServerPort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def get_absolute_url(self):
@@ -413,6 +417,8 @@ class PowerPort(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def get_absolute_url(self):
@@ -568,6 +574,8 @@ class PowerOutlet(CableTermination, ComponentModel):
unique_together = ('device', 'name')
def __str__(self):
+ if self.label:
+ return f"{self.name} ({self.label})"
return self.name
def get_absolute_url(self):
From d65cead212dc781a6dbbe35ea15eea7b75023da9 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Fri, 5 Jun 2020 12:34:09 -0400
Subject: [PATCH 168/300] Return an empty list if value is None
---
netbox/utilities/forms.py | 2 ++
1 file changed, 2 insertions(+)
diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py
index 979b6ac32..3d8dbe33f 100644
--- a/netbox/utilities/forms.py
+++ b/netbox/utilities/forms.py
@@ -530,6 +530,8 @@ class ExpandableNameField(forms.CharField):
"""
def to_python(self, value):
+ if value is None:
+ return list()
if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value):
return list(expand_alphanumeric_pattern(value))
return [value]
From 279ae7ea10f94b4d8af7430863e7a226f456cf7e Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 5 Jun 2020 10:31:29 -0400
Subject: [PATCH 169/300] Standardize DCIM API tests
---
netbox/dcim/tests/test_api.py | 4452 ++++++++-------------------------
1 file changed, 1075 insertions(+), 3377 deletions(-)
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index d45d972f8..1733deecc 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1,6 +1,6 @@
+from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
-from netaddr import IPNetwork
from rest_framework import status
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
@@ -12,9 +12,9 @@ from dcim.models import (
InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
-from ipam.models import IPAddress, VLAN
+from ipam.models import VLAN
from extras.models import Graph
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, APIViewTestCases
from virtualization.models import Cluster, ClusterType
@@ -28,2007 +28,795 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
-class RegionTest(APITestCase):
+class RegionTest(APIViewTestCases.APIViewTestCase):
+ model = Region
+ brief_fields = ['id', 'name', 'site_count', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Region 4',
+ 'slug': 'region-4',
+ },
+ {
+ 'name': 'Region 5',
+ 'slug': 'region-5',
+ },
+ {
+ 'name': 'Region 6',
+ 'slug': 'region-6',
+ },
+ ]
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
- super().setUp()
+ Region.objects.create(name='Region 1', slug='region-1')
+ Region.objects.create(name='Region 2', slug='region-2')
+ Region.objects.create(name='Region 3', slug='region-3')
- self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
- self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
- self.region3 = Region.objects.create(name='Test Region 3', slug='test-region-3')
- def test_get_region(self):
+class SiteTest(APIViewTestCases.APIViewTestCase):
+ model = Site
+ brief_fields = ['id', 'name', 'slug', 'url']
- url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk})
- response = self.client.get(url, **self.header)
+ @classmethod
+ def setUpTestData(cls):
- self.assertEqual(response.data['name'], self.region1.name)
-
- def test_list_regions(self):
-
- url = reverse('dcim-api:region-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_regions_brief(self):
-
- url = reverse('dcim-api:region-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'site_count', 'slug', 'url']
+ regions = (
+ Region.objects.create(name='Test Region 1', slug='test-region-1'),
+ Region.objects.create(name='Test Region 2', slug='test-region-2'),
)
- def test_create_region(self):
+ sites = (
+ Site(region=regions[0], name='Site 1', slug='site-1'),
+ Site(region=regions[0], name='Site 2', slug='site-2'),
+ Site(region=regions[0], name='Site 3', slug='site-3'),
+ )
+ Site.objects.bulk_create(sites)
- data = {
- 'name': 'Test Region 4',
- 'slug': 'test-region-4',
- }
-
- url = reverse('dcim-api:region-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Region.objects.count(), 4)
- region4 = Region.objects.get(pk=response.data['id'])
- self.assertEqual(region4.name, data['name'])
- self.assertEqual(region4.slug, data['slug'])
-
- def test_create_region_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'name': 'Test Region 4',
- 'slug': 'test-region-4',
+ 'name': 'Site 4',
+ 'slug': 'site-4',
+ 'region': regions[1].pk,
+ 'status': SiteStatusChoices.STATUS_ACTIVE,
},
{
- 'name': 'Test Region 5',
- 'slug': 'test-region-5',
+ 'name': 'Site 5',
+ 'slug': 'site-5',
+ 'region': regions[1].pk,
+ 'status': SiteStatusChoices.STATUS_ACTIVE,
},
{
- 'name': 'Test Region 6',
- 'slug': 'test-region-6',
+ 'name': 'Site 6',
+ 'slug': 'site-6',
+ 'region': regions[1].pk,
+ 'status': SiteStatusChoices.STATUS_ACTIVE,
},
]
- url = reverse('dcim-api:region-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Region.objects.count(), 6)
- 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'])
-
- def test_update_region(self):
-
- data = {
- 'name': 'Test Region X',
- 'slug': 'test-region-x',
- }
-
- url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Region.objects.count(), 3)
- region1 = Region.objects.get(pk=response.data['id'])
- self.assertEqual(region1.name, data['name'])
- self.assertEqual(region1.slug, data['slug'])
-
- def test_delete_region(self):
-
- url = reverse('dcim-api:region-detail', kwargs={'pk': self.region1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Region.objects.count(), 2)
-
-
-class SiteTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
- self.region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
- self.site1 = Site.objects.create(region=self.region1, name='Test Site 1', slug='test-site-1')
- self.site2 = Site.objects.create(region=self.region1, name='Test Site 2', slug='test-site-2')
- self.site3 = Site.objects.create(region=self.region1, name='Test Site 3', slug='test-site-3')
-
- def test_get_site(self):
-
- url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.site1.name)
-
def test_get_site_graphs(self):
+ """
+ Test retrieval of Graphs assigned to Sites.
+ """
+ ct = ContentType.objects.get_for_model(Site)
+ graphs = (
+ Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'),
+ Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'),
+ Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'),
+ )
+ Graph.objects.bulk_create(graphs)
- site_ct = ContentType.objects.get_for_model(Site)
- self.graph1 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 1',
- source='http://example.com/graphs.py?site={{ obj.slug }}&foo=1'
- )
- self.graph2 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 2',
- source='http://example.com/graphs.py?site={{ obj.slug }}&foo=2'
- )
- self.graph3 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 3',
- source='http://example.com/graphs.py?site={{ obj.slug }}&foo=3'
- )
-
- url = reverse('dcim-api:site-graphs', kwargs={'pk': self.site1.pk})
+ url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
- self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=test-site-1&foo=1')
+ self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?site=site-1&foo=1')
- def test_list_sites(self):
- url = reverse('dcim-api:site-list')
- response = self.client.get(url, **self.header)
+class RackGroupTest(APIViewTestCases.APIViewTestCase):
+ model = RackGroup
+ brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
- self.assertEqual(response.data['count'], 3)
+ @classmethod
+ def setUpTestData(cls):
- def test_list_sites_brief(self):
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
- url = reverse('dcim-api:site-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'slug', 'url']
+ parent_rack_groups = (
+ RackGroup.objects.create(site=sites[0], name='Parent Rack Group 1', slug='parent-rack-group-1'),
+ RackGroup.objects.create(site=sites[1], name='Parent Rack Group 2', slug='parent-rack-group-2'),
)
- def test_create_site(self):
+ RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1', parent=parent_rack_groups[0])
+ RackGroup.objects.create(site=sites[0], name='Rack Group 2', slug='rack-group-2', parent=parent_rack_groups[0])
+ RackGroup.objects.create(site=sites[0], name='Rack Group 3', slug='rack-group-3', parent=parent_rack_groups[0])
- data = {
- 'name': 'Test Site 4',
- 'slug': 'test-site-4',
- 'region': self.region1.pk,
- 'status': SiteStatusChoices.STATUS_ACTIVE,
- }
-
- url = reverse('dcim-api:site-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Site.objects.count(), 4)
- site4 = Site.objects.get(pk=response.data['id'])
- self.assertEqual(site4.name, data['name'])
- self.assertEqual(site4.slug, data['slug'])
- self.assertEqual(site4.region_id, data['region'])
-
- def test_create_site_bulk(self):
-
- data = [
- {
- 'name': 'Test Site 4',
- 'slug': 'test-site-4',
- 'region': self.region1.pk,
- 'status': SiteStatusChoices.STATUS_ACTIVE,
- },
- {
- 'name': 'Test Site 5',
- 'slug': 'test-site-5',
- 'region': self.region1.pk,
- 'status': SiteStatusChoices.STATUS_ACTIVE,
- },
- {
- 'name': 'Test Site 6',
- 'slug': 'test-site-6',
- 'region': self.region1.pk,
- 'status': SiteStatusChoices.STATUS_ACTIVE,
- },
- ]
-
- url = reverse('dcim-api:site-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Site.objects.count(), 6)
- 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'])
-
- def test_update_site(self):
-
- data = {
- 'name': 'Test Site X',
- 'slug': 'test-site-x',
- 'region': self.region2.pk,
- }
-
- url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Site.objects.count(), 3)
- site1 = Site.objects.get(pk=response.data['id'])
- self.assertEqual(site1.name, data['name'])
- self.assertEqual(site1.slug, data['slug'])
- self.assertEqual(site1.region_id, data['region'])
-
- def test_delete_site(self):
-
- url = reverse('dcim-api:site-detail', kwargs={'pk': self.site1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Site.objects.count(), 2)
-
-
-class RackGroupTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
- self.parent_rackgroup1 = RackGroup.objects.create(site=self.site1, name='Parent Rack Group 1', slug='parent-rack-group-1')
- self.parent_rackgroup2 = RackGroup.objects.create(site=self.site2, name='Parent Rack Group 2', slug='parent-rack-group-2')
- self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Rack Group 1', slug='rack-group-1', parent=self.parent_rackgroup1)
- self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Rack Group 2', slug='rack-group-2', parent=self.parent_rackgroup1)
- self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Rack Group 3', slug='rack-group-3', parent=self.parent_rackgroup1)
-
- def test_get_rackgroup(self):
-
- url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.rackgroup1.name)
-
- def test_list_rackgroups(self):
-
- url = reverse('dcim-api:rackgroup-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 5)
-
- def test_list_rackgroups_brief(self):
-
- url = reverse('dcim-api:rackgroup-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'rack_count', 'slug', 'url']
- )
-
- def test_create_rackgroup(self):
-
- data = {
- 'name': 'Rack Group 4',
- 'slug': 'rack-group-4',
- 'site': self.site1.pk,
- 'parent': self.parent_rackgroup1.pk,
- }
-
- url = reverse('dcim-api:rackgroup-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackGroup.objects.count(), 6)
- rackgroup4 = RackGroup.objects.get(pk=response.data['id'])
- self.assertEqual(rackgroup4.name, data['name'])
- self.assertEqual(rackgroup4.slug, data['slug'])
- self.assertEqual(rackgroup4.site_id, data['site'])
- self.assertEqual(rackgroup4.parent_id, data['parent'])
-
- def test_create_rackgroup_bulk(self):
-
- data = [
+ cls.create_data = [
{
'name': 'Test Rack Group 4',
'slug': 'test-rack-group-4',
- 'site': self.site1.pk,
- 'parent': self.parent_rackgroup1.pk,
+ 'site': sites[1].pk,
+ 'parent': parent_rack_groups[1].pk,
},
{
'name': 'Test Rack Group 5',
'slug': 'test-rack-group-5',
- 'site': self.site1.pk,
- 'parent': self.parent_rackgroup1.pk,
+ 'site': sites[1].pk,
+ 'parent': parent_rack_groups[1].pk,
},
{
'name': 'Test Rack Group 6',
'slug': 'test-rack-group-6',
- 'site': self.site1.pk,
- 'parent': self.parent_rackgroup1.pk,
+ 'site': sites[1].pk,
+ 'parent': parent_rack_groups[1].pk,
},
]
- url = reverse('dcim-api:rackgroup-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackGroup.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'])
+class RackRoleTest(APIViewTestCases.APIViewTestCase):
+ model = RackRole
+ brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Rack Role 4',
+ 'slug': 'rack-role-4',
+ 'color': 'ffff00',
+ },
+ {
+ 'name': 'Rack Role 5',
+ 'slug': 'rack-role-5',
+ 'color': 'ffff00',
+ },
+ {
+ 'name': 'Rack Role 6',
+ 'slug': 'rack-role-6',
+ 'color': 'ffff00',
+ },
+ ]
- def test_update_rackgroup(self):
+ @classmethod
+ def setUpTestData(cls):
- data = {
- 'name': 'Test Rack Group X',
- 'slug': 'test-rack-group-x',
- 'site': self.site2.pk,
- 'parent': self.parent_rackgroup2.pk,
- }
-
- url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(RackGroup.objects.count(), 5)
- rackgroup1 = RackGroup.objects.get(pk=response.data['id'])
- self.assertEqual(rackgroup1.name, data['name'])
- self.assertEqual(rackgroup1.slug, data['slug'])
- self.assertEqual(rackgroup1.site_id, data['site'])
- self.assertEqual(rackgroup1.parent_id, data['parent'])
-
- def test_delete_rackgroup(self):
-
- url = reverse('dcim-api:rackgroup-detail', kwargs={'pk': self.rackgroup1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(RackGroup.objects.count(), 4)
+ rack_roles = (
+ RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
+ RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
+ RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
+ )
+ RackRole.objects.bulk_create(rack_roles)
-class RackRoleTest(APITestCase):
+class RackTest(APIViewTestCases.APIViewTestCase):
+ model = Rack
+ brief_fields = ['device_count', 'display_name', 'id', 'name', 'url']
- def setUp(self):
+ @classmethod
+ def setUpTestData(cls):
- super().setUp()
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
- self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
- self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00')
- self.rackrole3 = RackRole.objects.create(name='Test Rack Role 3', slug='test-rack-role-3', color='0000ff')
-
- def test_get_rackrole(self):
-
- url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.rackrole1.name)
-
- def test_list_rackroles(self):
-
- url = reverse('dcim-api:rackrole-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_rackroles_brief(self):
-
- url = reverse('dcim-api:rackrole-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'rack_count', 'slug', 'url']
+ rack_groups = (
+ RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1'),
+ RackGroup.objects.create(site=sites[1], name='Rack Group 2', slug='rack-group-2'),
)
- def test_create_rackrole(self):
+ rack_roles = (
+ RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'),
+ RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'),
+ )
+ RackRole.objects.bulk_create(rack_roles)
- data = {
- 'name': 'Test Rack Role 4',
- 'slug': 'test-rack-role-4',
- 'color': 'ffff00',
- }
+ racks = (
+ Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'),
+ Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'),
+ Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'),
+ )
+ Rack.objects.bulk_create(racks)
- url = reverse('dcim-api:rackrole-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackRole.objects.count(), 4)
- rackrole1 = RackRole.objects.get(pk=response.data['id'])
- self.assertEqual(rackrole1.name, data['name'])
- self.assertEqual(rackrole1.slug, data['slug'])
- self.assertEqual(rackrole1.color, data['color'])
-
- def test_create_rackrole_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'name': 'Test Rack Role 4',
- 'slug': 'test-rack-role-4',
- 'color': 'ffff00',
+ 'name': 'Test Rack 4',
+ 'site': sites[1].pk,
+ 'group': rack_groups[1].pk,
+ 'role': rack_roles[1].pk,
},
{
- 'name': 'Test Rack Role 5',
- 'slug': 'test-rack-role-5',
- 'color': 'ffff00',
+ 'name': 'Test Rack 5',
+ 'site': sites[1].pk,
+ 'group': rack_groups[1].pk,
+ 'role': rack_roles[1].pk,
},
{
- 'name': 'Test Rack Role 6',
- 'slug': 'test-rack-role-6',
- 'color': 'ffff00',
+ 'name': 'Test Rack 6',
+ 'site': sites[1].pk,
+ 'group': rack_groups[1].pk,
+ 'role': rack_roles[1].pk,
},
]
- url = reverse('dcim-api:rackrole-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackRole.objects.count(), 6)
- 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'])
-
- def test_update_rackrole(self):
-
- data = {
- 'name': 'Test Rack Role X',
- 'slug': 'test-rack-role-x',
- 'color': 'ffff00',
- }
-
- url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(RackRole.objects.count(), 3)
- rackrole1 = RackRole.objects.get(pk=response.data['id'])
- self.assertEqual(rackrole1.name, data['name'])
- self.assertEqual(rackrole1.slug, data['slug'])
- self.assertEqual(rackrole1.color, data['color'])
-
- def test_delete_rackrole(self):
-
- url = reverse('dcim-api:rackrole-detail', kwargs={'pk': self.rackrole1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(RackRole.objects.count(), 2)
-
-
-class RackTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
- self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
- self.rackgroup2 = RackGroup.objects.create(site=self.site2, name='Test Rack Group 2', slug='test-rack-group-2')
- self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
- self.rackrole2 = RackRole.objects.create(name='Test Rack Role 2', slug='test-rack-role-2', color='00ff00')
- self.rack1 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
- )
- self.rack2 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
- )
- self.rack3 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42,
- )
-
- def test_get_rack(self):
-
- url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.rack1.name)
-
+ # TODO: Document this test
def test_get_elevation_rack_units(self):
+ rack = Rack.objects.first()
- url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
+ url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 13)
- url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
+ url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 11)
- url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
+ url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
- url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
+ url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
def test_get_rack_elevation(self):
-
- url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
+ """
+ GET a single rack elevation.
+ """
+ rack = Rack.objects.first()
+ url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
def test_get_rack_elevation_svg(self):
-
- url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
+ """
+ GET a single rack elevation in SVG format.
+ """
+ rack = Rack.objects.first()
+ url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
- def test_list_racks(self):
- url = reverse('dcim-api:rack-list')
- response = self.client.get(url, **self.header)
+class RackReservationTest(APIViewTestCases.APIViewTestCase):
+ model = RackReservation
- self.assertEqual(response.data['count'], 3)
+ @classmethod
+ def setUpTestData(cls):
- def test_list_racks_brief(self):
+ user = User.objects.create(username='user1', is_active=True)
- url = reverse('dcim-api:rack-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
+ site = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['device_count', 'display_name', 'id', 'name', 'url']
+ cls.racks = (
+ Rack(site=site, name='Rack 1'),
+ Rack(site=site, name='Rack 2'),
)
+ Rack.objects.bulk_create(cls.racks)
- def test_create_rack(self):
-
- data = {
- 'name': 'Test Rack 4',
- 'facility_id': '1234',
- 'site': self.site1.pk,
- 'group': self.rackgroup1.pk,
- 'role': self.rackrole1.pk,
- }
-
- url = reverse('dcim-api:rack-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Rack.objects.count(), 4)
- rack4 = Rack.objects.get(pk=response.data['id'])
- self.assertEqual(rack4.name, data['name'])
- self.assertEqual(rack4.site_id, data['site'])
- self.assertEqual(rack4.group_id, data['group'])
- self.assertEqual(rack4.role_id, data['role'])
-
- def test_create_rack_bulk(self):
-
- data = [
- {
- 'name': 'Test Rack 4',
- 'site': self.site1.pk,
- 'group': self.rackgroup1.pk,
- 'role': self.rackrole1.pk,
- },
- {
- 'name': 'Test Rack 5',
- 'site': self.site1.pk,
- 'group': self.rackgroup1.pk,
- 'role': self.rackrole1.pk,
- },
- {
- 'name': 'Test Rack 6',
- 'site': self.site1.pk,
- 'group': self.rackgroup1.pk,
- 'role': self.rackrole1.pk,
- },
- ]
-
- url = reverse('dcim-api:rack-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Rack.objects.count(), 6)
- 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'])
-
- def test_update_rack(self):
-
- data = {
- 'name': 'Test Rack X',
- 'site': self.site2.pk,
- 'group': self.rackgroup2.pk,
- 'role': self.rackrole2.pk,
- }
-
- url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Rack.objects.count(), 3)
- rack1 = Rack.objects.get(pk=response.data['id'])
- self.assertEqual(rack1.name, data['name'])
- self.assertEqual(rack1.site_id, data['site'])
- self.assertEqual(rack1.group_id, data['group'])
- self.assertEqual(rack1.role_id, data['role'])
-
- def test_delete_rack(self):
-
- url = reverse('dcim-api:rack-detail', kwargs={'pk': self.rack1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Rack.objects.count(), 2)
-
-
-class RackReservationTest(APITestCase):
+ rack_reservations = (
+ RackReservation(rack=cls.racks[0], units=[1, 2, 3], user=user, description='Reservation #1'),
+ RackReservation(rack=cls.racks[0], units=[4, 5, 6], user=user, description='Reservation #2'),
+ RackReservation(rack=cls.racks[0], units=[7, 8, 9], user=user, description='Reservation #3'),
+ )
+ RackReservation.objects.bulk_create(rack_reservations)
def setUp(self):
-
super().setUp()
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.rack1 = Rack.objects.create(site=self.site1, name='Test Rack 1')
- self.rackreservation1 = RackReservation.objects.create(
- rack=self.rack1, units=[1, 2, 3], user=self.user, description='Reservation #1',
- )
- self.rackreservation2 = RackReservation.objects.create(
- rack=self.rack1, units=[4, 5, 6], user=self.user, description='Reservation #2',
- )
- self.rackreservation3 = RackReservation.objects.create(
- rack=self.rack1, units=[7, 8, 9], user=self.user, description='Reservation #3',
- )
-
- def test_get_rackreservation(self):
-
- url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['id'], self.rackreservation1.pk)
-
- def test_list_rackreservations(self):
-
- url = reverse('dcim-api:rackreservation-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_rackreservation(self):
-
- data = {
- 'rack': self.rack1.pk,
- 'units': [10, 11, 12],
- 'user': self.user.pk,
- 'description': 'Fourth reservation',
- }
-
- url = reverse('dcim-api:rackreservation-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackReservation.objects.count(), 4)
- rackreservation4 = RackReservation.objects.get(pk=response.data['id'])
- self.assertEqual(rackreservation4.rack_id, data['rack'])
- self.assertEqual(rackreservation4.units, data['units'])
- self.assertEqual(rackreservation4.user_id, data['user'])
- self.assertEqual(rackreservation4.description, data['description'])
-
- def test_create_rackreservation_bulk(self):
-
- data = [
+ # We have to set creation data under setUp() because we need access to the test user.
+ self.create_data = [
{
- 'rack': self.rack1.pk,
+ 'rack': self.racks[1].pk,
'units': [10, 11, 12],
'user': self.user.pk,
'description': 'Reservation #4',
},
{
- 'rack': self.rack1.pk,
+ 'rack': self.racks[1].pk,
'units': [13, 14, 15],
'user': self.user.pk,
'description': 'Reservation #5',
},
{
- 'rack': self.rack1.pk,
+ 'rack': self.racks[1].pk,
'units': [16, 17, 18],
'user': self.user.pk,
'description': 'Reservation #6',
},
]
- url = reverse('dcim-api:rackreservation-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RackReservation.objects.count(), 6)
- self.assertEqual(response.data[0]['description'], data[0]['description'])
- self.assertEqual(response.data[1]['description'], data[1]['description'])
- self.assertEqual(response.data[2]['description'], data[2]['description'])
+class ManufacturerTest(APIViewTestCases.APIViewTestCase):
+ model = Manufacturer
+ brief_fields = ['devicetype_count', 'id', 'name', 'slug', 'url']
+ create_data = [
+ {
+ 'name': 'Manufacturer 4',
+ 'slug': 'manufacturer-4',
+ },
+ {
+ 'name': 'Manufacturer 5',
+ 'slug': 'manufacturer-5',
+ },
+ {
+ 'name': 'Manufacturer 6',
+ 'slug': 'manufacturer-6',
+ },
+ ]
- def test_update_rackreservation(self):
+ @classmethod
+ def setUpTestData(cls):
- data = {
- 'rack': self.rack1.pk,
- 'units': [10, 11, 12],
- 'user': self.user.pk,
- 'description': 'Modified reservation',
- }
-
- url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(RackReservation.objects.count(), 3)
- rackreservation1 = RackReservation.objects.get(pk=response.data['id'])
- self.assertEqual(rackreservation1.units, data['units'])
- self.assertEqual(rackreservation1.description, data['description'])
-
- def test_delete_rackreservation(self):
-
- url = reverse('dcim-api:rackreservation-detail', kwargs={'pk': self.rackreservation1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(RackReservation.objects.count(), 2)
-
-
-class ManufacturerTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2')
- self.manufacturer3 = Manufacturer.objects.create(name='Test Manufacturer 3', slug='test-manufacturer-3')
-
- def test_get_manufacturer(self):
-
- url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.manufacturer1.name)
-
- def test_list_manufacturers(self):
-
- url = reverse('dcim-api:manufacturer-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_manufacturers_brief(self):
-
- url = reverse('dcim-api:manufacturer-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['devicetype_count', 'id', 'name', 'slug', 'url']
+ manufacturers = (
+ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+ Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+ Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
)
+ Manufacturer.objects.bulk_create(manufacturers)
- def test_create_manufacturer(self):
- data = {
- 'name': 'Test Manufacturer 4',
- 'slug': 'test-manufacturer-4',
- }
+class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
+ model = DeviceType
+ brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
- url = reverse('dcim-api:manufacturer-list')
- response = self.client.post(url, data, format='json', **self.header)
+ @classmethod
+ def setUpTestData(cls):
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Manufacturer.objects.count(), 4)
- manufacturer4 = Manufacturer.objects.get(pk=response.data['id'])
- self.assertEqual(manufacturer4.name, data['name'])
- self.assertEqual(manufacturer4.slug, data['slug'])
+ manufacturers = (
+ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+ Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+ )
+ Manufacturer.objects.bulk_create(manufacturers)
- def test_create_manufacturer_bulk(self):
+ device_types = (
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
+ DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'),
+ )
+ DeviceType.objects.bulk_create(device_types)
- data = [
+ cls.create_data = [
{
- 'name': 'Test Manufacturer 4',
- 'slug': 'test-manufacturer-4',
+ 'manufacturer': manufacturers[1].pk,
+ 'model': 'Device Type 4',
+ 'slug': 'device-type-4',
},
{
- 'name': 'Test Manufacturer 5',
- 'slug': 'test-manufacturer-5',
+ 'manufacturer': manufacturers[1].pk,
+ 'model': 'Device Type 5',
+ 'slug': 'device-type-5',
},
{
- 'name': 'Test Manufacturer 6',
- 'slug': 'test-manufacturer-6',
+ 'manufacturer': manufacturers[1].pk,
+ 'model': 'Device Type 6',
+ 'slug': 'device-type-6',
},
]
- url = reverse('dcim-api:manufacturer-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Manufacturer.objects.count(), 6)
- 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'])
+class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = ConsolePortTemplate
+ brief_fields = ['id', 'name', 'url']
- def test_update_manufacturer(self):
-
- data = {
- 'name': 'Test Manufacturer X',
- 'slug': 'test-manufacturer-x',
- }
-
- url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Manufacturer.objects.count(), 3)
- manufacturer1 = Manufacturer.objects.get(pk=response.data['id'])
- self.assertEqual(manufacturer1.name, data['name'])
- self.assertEqual(manufacturer1.slug, data['slug'])
-
- def test_delete_manufacturer(self):
-
- url = reverse('dcim-api:manufacturer-detail', kwargs={'pk': self.manufacturer1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Manufacturer.objects.count(), 2)
-
-
-class DeviceTypeTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer1 = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.manufacturer2 = Manufacturer.objects.create(name='Test Manufacturer 2', slug='test-manufacturer-2')
- self.devicetype1 = DeviceType.objects.create(
- manufacturer=self.manufacturer1, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.devicetype2 = DeviceType.objects.create(
- manufacturer=self.manufacturer1, model='Test Device Type 2', slug='test-device-type-2'
- )
- self.devicetype3 = DeviceType.objects.create(
- manufacturer=self.manufacturer1, model='Test Device Type 3', slug='test-device-type-3'
- )
-
- def test_get_devicetype(self):
-
- url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['model'], self.devicetype1.model)
-
- def test_list_devicetypes(self):
-
- url = reverse('dcim-api:devicetype-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_devicetypes_brief(self):
-
- url = reverse('dcim-api:devicetype-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
- )
-
- def test_create_devicetype(self):
-
- data = {
- 'manufacturer': self.manufacturer1.pk,
- 'model': 'Test Device Type 4',
- 'slug': 'test-device-type-4',
- }
-
- url = reverse('dcim-api:devicetype-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceType.objects.count(), 4)
- devicetype4 = DeviceType.objects.get(pk=response.data['id'])
- self.assertEqual(devicetype4.manufacturer_id, data['manufacturer'])
- self.assertEqual(devicetype4.model, data['model'])
- self.assertEqual(devicetype4.slug, data['slug'])
-
- def test_create_devicetype_bulk(self):
-
- data = [
- {
- 'manufacturer': self.manufacturer1.pk,
- 'model': 'Test Device Type 4',
- 'slug': 'test-device-type-4',
- },
- {
- 'manufacturer': self.manufacturer1.pk,
- 'model': 'Test Device Type 5',
- 'slug': 'test-device-type-5',
- },
- {
- 'manufacturer': self.manufacturer1.pk,
- 'model': 'Test Device Type 6',
- 'slug': 'test-device-type-6',
- },
- ]
-
- url = reverse('dcim-api:devicetype-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceType.objects.count(), 6)
- self.assertEqual(response.data[0]['model'], data[0]['model'])
- self.assertEqual(response.data[1]['model'], data[1]['model'])
- self.assertEqual(response.data[2]['model'], data[2]['model'])
-
- def test_update_devicetype(self):
-
- data = {
- 'manufacturer': self.manufacturer2.pk,
- 'model': 'Test Device Type X',
- 'slug': 'test-device-type-x',
- }
-
- url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(DeviceType.objects.count(), 3)
- devicetype1 = DeviceType.objects.get(pk=response.data['id'])
- self.assertEqual(devicetype1.manufacturer_id, data['manufacturer'])
- self.assertEqual(devicetype1.model, data['model'])
- self.assertEqual(devicetype1.slug, data['slug'])
-
- def test_delete_devicetype(self):
-
- url = reverse('dcim-api:devicetype-detail', kwargs={'pk': self.devicetype1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(DeviceType.objects.count(), 2)
-
-
-class ConsolePortTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.consoleporttemplate1 = ConsolePortTemplate.objects.create(
- device_type=self.devicetype, name='Test CP Template 1'
- )
- self.consoleporttemplate2 = ConsolePortTemplate.objects.create(
- device_type=self.devicetype, name='Test CP Template 2'
- )
- self.consoleporttemplate3 = ConsolePortTemplate.objects.create(
- device_type=self.devicetype, name='Test CP Template 3'
- )
-
- def test_get_consoleporttemplate(self):
-
- url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.consoleporttemplate1.name)
-
- def test_list_consoleporttemplates(self):
-
- url = reverse('dcim-api:consoleporttemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_consoleporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CP Template 4',
- }
-
- url = reverse('dcim-api:consoleporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsolePortTemplate.objects.count(), 4)
- consoleporttemplate4 = ConsolePortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(consoleporttemplate4.device_type_id, data['device_type'])
- self.assertEqual(consoleporttemplate4.name, data['name'])
-
- def test_create_consoleporttemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CP Template 4',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CP Template 5',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CP Template 6',
- },
- ]
-
- url = reverse('dcim-api:consoleporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsolePortTemplate.objects.count(), 6)
- 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'])
-
- def test_update_consoleporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CP Template X',
- }
-
- url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ConsolePortTemplate.objects.count(), 3)
- consoleporttemplate1 = ConsolePortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(consoleporttemplate1.name, data['name'])
-
- def test_delete_consoleporttemplate(self):
-
- url = reverse('dcim-api:consoleporttemplate-detail', kwargs={'pk': self.consoleporttemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ConsolePortTemplate.objects.count(), 2)
-
-
-class ConsoleServerPortTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test CSP Template 1'
- )
- self.consoleserverporttemplate2 = ConsoleServerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test CSP Template 2'
- )
- self.consoleserverporttemplate3 = ConsoleServerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test CSP Template 3'
- )
-
- def test_get_consoleserverporttemplate(self):
-
- url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.consoleserverporttemplate1.name)
-
- def test_list_consoleserverporttemplates(self):
-
- url = reverse('dcim-api:consoleserverporttemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_consoleserverporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CSP Template 4',
- }
-
- url = reverse('dcim-api:consoleserverporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsoleServerPortTemplate.objects.count(), 4)
- consoleserverporttemplate4 = ConsoleServerPortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(consoleserverporttemplate4.device_type_id, data['device_type'])
- self.assertEqual(consoleserverporttemplate4.name, data['name'])
-
- def test_create_consoleserverporttemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CSP Template 4',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CSP Template 5',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CSP Template 6',
- },
- ]
-
- url = reverse('dcim-api:consoleserverporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsoleServerPortTemplate.objects.count(), 6)
- 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'])
-
- def test_update_consoleserverporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test CSP Template X',
- }
-
- url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ConsoleServerPortTemplate.objects.count(), 3)
- consoleserverporttemplate1 = ConsoleServerPortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(consoleserverporttemplate1.name, data['name'])
-
- def test_delete_consoleserverporttemplate(self):
-
- url = reverse('dcim-api:consoleserverporttemplate-detail', kwargs={'pk': self.consoleserverporttemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ConsoleServerPortTemplate.objects.count(), 2)
-
-
-class PowerPortTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.powerporttemplate1 = PowerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test PP Template 1'
- )
- self.powerporttemplate2 = PowerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test PP Template 2'
- )
- self.powerporttemplate3 = PowerPortTemplate.objects.create(
- device_type=self.devicetype, name='Test PP Template 3'
- )
-
- def test_get_powerporttemplate(self):
-
- url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.powerporttemplate1.name)
-
- def test_list_powerporttemplates(self):
-
- url = reverse('dcim-api:powerporttemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_powerporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PP Template 4',
- }
-
- url = reverse('dcim-api:powerporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPortTemplate.objects.count(), 4)
- powerporttemplate4 = PowerPortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(powerporttemplate4.device_type_id, data['device_type'])
- self.assertEqual(powerporttemplate4.name, data['name'])
-
- def test_create_powerporttemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PP Template 4',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PP Template 5',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PP Template 6',
- },
- ]
-
- url = reverse('dcim-api:powerporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPortTemplate.objects.count(), 6)
- 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'])
-
- def test_update_powerporttemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PP Template X',
- }
-
- url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerPortTemplate.objects.count(), 3)
- powerporttemplate1 = PowerPortTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(powerporttemplate1.name, data['name'])
-
- def test_delete_powerporttemplate(self):
-
- url = reverse('dcim-api:powerporttemplate-detail', kwargs={'pk': self.powerporttemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerPortTemplate.objects.count(), 2)
-
-
-class PowerOutletTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.poweroutlettemplate1 = PowerOutletTemplate.objects.create(
- device_type=self.devicetype, name='Test PO Template 1'
- )
- self.poweroutlettemplate2 = PowerOutletTemplate.objects.create(
- device_type=self.devicetype, name='Test PO Template 2'
- )
- self.poweroutlettemplate3 = PowerOutletTemplate.objects.create(
- device_type=self.devicetype, name='Test PO Template 3'
- )
-
- def test_get_poweroutlettemplate(self):
-
- url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.poweroutlettemplate1.name)
-
- def test_list_poweroutlettemplates(self):
-
- url = reverse('dcim-api:poweroutlettemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_poweroutlettemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PO Template 4',
- }
-
- url = reverse('dcim-api:poweroutlettemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerOutletTemplate.objects.count(), 4)
- poweroutlettemplate4 = PowerOutletTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(poweroutlettemplate4.device_type_id, data['device_type'])
- self.assertEqual(poweroutlettemplate4.name, data['name'])
-
- def test_create_poweroutlettemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PO Template 4',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PO Template 5',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PO Template 6',
- },
- ]
-
- url = reverse('dcim-api:poweroutlettemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerOutletTemplate.objects.count(), 6)
- 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'])
-
- def test_update_poweroutlettemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test PO Template X',
- }
-
- url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerOutletTemplate.objects.count(), 3)
- poweroutlettemplate1 = PowerOutletTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(poweroutlettemplate1.name, data['name'])
-
- def test_delete_poweroutlettemplate(self):
-
- url = reverse('dcim-api:poweroutlettemplate-detail', kwargs={'pk': self.poweroutlettemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerOutletTemplate.objects.count(), 2)
-
-
-class InterfaceTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.interfacetemplate1 = InterfaceTemplate.objects.create(
- device_type=self.devicetype, name='Test Interface Template 1', type='1000base-t'
- )
- self.interfacetemplate2 = InterfaceTemplate.objects.create(
- device_type=self.devicetype, name='Test Interface Template 2', type='1000base-t'
- )
- self.interfacetemplate3 = InterfaceTemplate.objects.create(
- device_type=self.devicetype, name='Test Interface Template 3', type='1000base-t'
- )
-
- def test_get_interfacetemplate(self):
-
- url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.interfacetemplate1.name)
-
- def test_list_interfacetemplates(self):
-
- url = reverse('dcim-api:interfacetemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_interfacetemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Interface Template 4',
- 'type': '1000base-t',
- }
-
- url = reverse('dcim-api:interfacetemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(InterfaceTemplate.objects.count(), 4)
- interfacetemplate4 = InterfaceTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(interfacetemplate4.device_type_id, data['device_type'])
- self.assertEqual(interfacetemplate4.name, data['name'])
-
- def test_create_interfacetemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Interface Template 4',
- 'type': '1000base-t',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Interface Template 5',
- 'type': '1000base-t',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Interface Template 6',
- 'type': '1000base-t',
- },
- ]
-
- url = reverse('dcim-api:interfacetemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(InterfaceTemplate.objects.count(), 6)
- 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'])
-
- def test_update_interfacetemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Interface Template X',
- 'type': '1000base-x-gbic',
- }
-
- url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(InterfaceTemplate.objects.count(), 3)
- interfacetemplate1 = InterfaceTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(interfacetemplate1.name, data['name'])
-
- def test_delete_interfacetemplate(self):
-
- url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(InterfaceTemplate.objects.count(), 2)
-
-
-class DeviceBayTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.devicebaytemplate1 = DeviceBayTemplate.objects.create(
- device_type=self.devicetype, name='Test Device Bay Template 1'
- )
- self.devicebaytemplate2 = DeviceBayTemplate.objects.create(
- device_type=self.devicetype, name='Test Device Bay Template 2'
- )
- self.devicebaytemplate3 = DeviceBayTemplate.objects.create(
- device_type=self.devicetype, name='Test Device Bay Template 3'
- )
-
- def test_get_devicebaytemplate(self):
-
- url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.devicebaytemplate1.name)
-
- def test_list_devicebaytemplates(self):
-
- url = reverse('dcim-api:devicebaytemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_devicebaytemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Device Bay Template 4',
- }
-
- url = reverse('dcim-api:devicebaytemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceBayTemplate.objects.count(), 4)
- devicebaytemplate4 = DeviceBayTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(devicebaytemplate4.device_type_id, data['device_type'])
- self.assertEqual(devicebaytemplate4.name, data['name'])
-
- def test_create_devicebaytemplate_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Device Bay Template 4',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Device Bay Template 5',
- },
- {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Device Bay Template 6',
- },
- ]
-
- url = reverse('dcim-api:devicebaytemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceBayTemplate.objects.count(), 6)
- 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'])
-
- def test_update_devicebaytemplate(self):
-
- data = {
- 'device_type': self.devicetype.pk,
- 'name': 'Test Device Bay Template X',
- }
-
- url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(DeviceBayTemplate.objects.count(), 3)
- devicebaytemplate1 = DeviceBayTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(devicebaytemplate1.name, data['name'])
-
- def test_delete_devicebaytemplate(self):
-
- url = reverse('dcim-api:devicebaytemplate-detail', kwargs={'pk': self.devicebaytemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(DeviceBayTemplate.objects.count(), 2)
-
-
-class DeviceRoleTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.devicerole1 = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.devicerole2 = DeviceRole.objects.create(
- name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
- )
- self.devicerole3 = DeviceRole.objects.create(
- name='Test Device Role 3', slug='test-device-role-3', color='0000ff'
- )
-
- def test_get_devicerole(self):
-
- url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.devicerole1.name)
-
- def test_list_deviceroles(self):
-
- url = reverse('dcim-api:devicerole-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_deviceroles_brief(self):
-
- url = reverse('dcim-api:devicerole-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
- )
-
- def test_create_devicerole(self):
-
- data = {
- 'name': 'Test Device Role 4',
- 'slug': 'test-device-role-4',
- 'color': 'ffff00',
- }
-
- url = reverse('dcim-api:devicerole-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceRole.objects.count(), 4)
- devicerole4 = DeviceRole.objects.get(pk=response.data['id'])
- self.assertEqual(devicerole4.name, data['name'])
- self.assertEqual(devicerole4.slug, data['slug'])
- self.assertEqual(devicerole4.color, data['color'])
-
- def test_create_devicerole_bulk(self):
-
- data = [
- {
- 'name': 'Test Device Role 4',
- 'slug': 'test-device-role-4',
- 'color': 'ffff00',
- },
- {
- 'name': 'Test Device Role 5',
- 'slug': 'test-device-role-5',
- 'color': 'ffff00',
- },
- {
- 'name': 'Test Device Role 6',
- 'slug': 'test-device-role-6',
- 'color': 'ffff00',
- },
- ]
-
- url = reverse('dcim-api:devicerole-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceRole.objects.count(), 6)
- 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'])
-
- def test_update_devicerole(self):
-
- data = {
- 'name': 'Test Device Role X',
- 'slug': 'test-device-role-x',
- 'color': '00ffff',
- }
-
- url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(DeviceRole.objects.count(), 3)
- devicerole1 = DeviceRole.objects.get(pk=response.data['id'])
- self.assertEqual(devicerole1.name, data['name'])
- self.assertEqual(devicerole1.slug, data['slug'])
- self.assertEqual(devicerole1.color, data['color'])
-
- def test_delete_devicerole(self):
-
- url = reverse('dcim-api:devicerole-detail', kwargs={'pk': self.devicerole1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(DeviceRole.objects.count(), 2)
-
-
-class PlatformTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
- self.platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
- self.platform3 = Platform.objects.create(name='Test Platform 3', slug='test-platform-3')
-
- def test_get_platform(self):
-
- url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.platform1.name)
-
- def test_list_platforms(self):
-
- url = reverse('dcim-api:platform-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_platforms_brief(self):
-
- url = reverse('dcim-api:platform-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
- )
-
- def test_create_platform(self):
-
- data = {
- 'name': 'Test Platform 4',
- 'slug': 'test-platform-4',
- }
-
- url = reverse('dcim-api:platform-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Platform.objects.count(), 4)
- platform4 = Platform.objects.get(pk=response.data['id'])
- self.assertEqual(platform4.name, data['name'])
- self.assertEqual(platform4.slug, data['slug'])
-
- def test_create_platform_bulk(self):
-
- data = [
- {
- 'name': 'Test Platform 4',
- 'slug': 'test-platform-4',
- },
- {
- 'name': 'Test Platform 5',
- 'slug': 'test-platform-5',
- },
- {
- 'name': 'Test Platform 6',
- 'slug': 'test-platform-6',
- },
- ]
-
- url = reverse('dcim-api:platform-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Platform.objects.count(), 6)
- 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'])
-
- def test_update_platform(self):
-
- data = {
- 'name': 'Test Platform X',
- 'slug': 'test-platform-x',
- }
-
- url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Platform.objects.count(), 3)
- platform1 = Platform.objects.get(pk=response.data['id'])
- self.assertEqual(platform1.name, data['name'])
- self.assertEqual(platform1.slug, data['slug'])
-
- def test_delete_platform(self):
-
- url = reverse('dcim-api:platform-detail', kwargs={'pk': self.platform1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Platform.objects.count(), 2)
-
-
-class DeviceTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
- self.rack1 = Rack.objects.create(name='Test Rack 1', site=self.site1, u_height=48)
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype1 = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
- )
- self.devicetype2 = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 2', slug='test-device-type-2'
- )
- self.devicerole1 = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.devicerole2 = DeviceRole.objects.create(
- name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
- )
- cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
- self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
- self.device1 = Device.objects.create(
- device_type=self.devicetype1,
- device_role=self.devicerole1,
- name='Test Device 1',
- site=self.site1,
- cluster=self.cluster1
- )
- self.device2 = Device.objects.create(
- device_type=self.devicetype1,
- device_role=self.devicerole1,
- name='Test Device 2',
- site=self.site1,
- cluster=self.cluster1
- )
- self.device3 = Device.objects.create(
- device_type=self.devicetype1,
- device_role=self.devicerole1,
- name='Test Device 3',
- site=self.site1,
- cluster=self.cluster1
- )
- self.device_with_context_data = Device.objects.create(
- device_type=self.devicetype1,
- device_role=self.devicerole1,
- name='Device with context data',
- site=self.site1,
- local_context_data={
- 'A': 1,
- 'B': 2
- }
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
- def test_get_device(self):
+ console_port_templates = (
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 1'),
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 2'),
+ ConsolePortTemplate(device_type=devicetype, name='Console Port Template 3'),
+ )
+ ConsolePortTemplate.objects.bulk_create(console_port_templates)
- url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk})
- response = self.client.get(url, **self.header)
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Port Template 4',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Port Template 5',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Port Template 6',
+ },
+ ]
- self.assertEqual(response.data['name'], self.device1.name)
- self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
- self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
+
+class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = ConsoleServerPortTemplate
+ brief_fields = ['id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+ )
+
+ console_server_port_templates = (
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 1'),
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 2'),
+ ConsoleServerPortTemplate(device_type=devicetype, name='Console Server Port Template 3'),
+ )
+ ConsoleServerPortTemplate.objects.bulk_create(console_server_port_templates)
+
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Server Port Template 4',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Server Port Template 5',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Console Server Port Template 6',
+ },
+ ]
+
+
+class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = PowerPortTemplate
+ brief_fields = ['id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+ )
+
+ power_port_templates = (
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
+ PowerPortTemplate(device_type=devicetype, name='Power Port Template 3'),
+ )
+ PowerPortTemplate.objects.bulk_create(power_port_templates)
+
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Port Template 4',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Port Template 5',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Port Template 6',
+ },
+ ]
+
+
+class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = PowerOutletTemplate
+ brief_fields = ['id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+ )
+
+ power_outlet_templates = (
+ PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
+ PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
+ PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 3'),
+ )
+ PowerOutletTemplate.objects.bulk_create(power_outlet_templates)
+
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Outlet Template 4',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Outlet Template 5',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Power Outlet Template 6',
+ },
+ ]
+
+
+class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = InterfaceTemplate
+ brief_fields = ['id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+ )
+
+ interface_templates = (
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
+ InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'),
+ )
+ InterfaceTemplate.objects.bulk_create(interface_templates)
+
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Interface Template 4',
+ 'type': '1000base-t',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Interface Template 5',
+ 'type': '1000base-t',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Interface Template 6',
+ 'type': '1000base-t',
+ },
+ ]
+
+
+class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = DeviceBayTemplate
+ brief_fields = ['id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(
+ manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+ )
+
+ device_bay_templates = (
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 1'),
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 2'),
+ DeviceBayTemplate(device_type=devicetype, name='Device Bay Template 3'),
+ )
+ DeviceBayTemplate.objects.bulk_create(device_bay_templates)
+
+ cls.create_data = [
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Device Bay Template 4',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Device Bay Template 5',
+ },
+ {
+ 'device_type': devicetype.pk,
+ 'name': 'Device Bay Template 6',
+ },
+ ]
+
+
+class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
+ model = DeviceRole
+ brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+ create_data = [
+ {
+ 'name': 'Device Role 4',
+ 'slug': 'device-role-4',
+ 'color': 'ffff00',
+ },
+ {
+ 'name': 'Device Role 5',
+ 'slug': 'device-role-5',
+ 'color': 'ffff00',
+ },
+ {
+ 'name': 'Device Role 6',
+ 'slug': 'device-role-6',
+ 'color': 'ffff00',
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
+ DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
+ DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
+
+
+class PlatformTest(APIViewTestCases.APIViewTestCase):
+ model = Platform
+ brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
+ create_data = [
+ {
+ 'name': 'Platform 4',
+ 'slug': 'platform-4',
+ },
+ {
+ 'name': 'Platform 5',
+ 'slug': 'platform-5',
+ },
+ {
+ 'name': 'Platform 6',
+ 'slug': 'platform-6',
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+
+ platforms = (
+ Platform(name='Platform 1', slug='platform-1'),
+ Platform(name='Platform 2', slug='platform-2'),
+ Platform(name='Platform 3', slug='platform-3'),
+ )
+ Platform.objects.bulk_create(platforms)
+
+
+class DeviceTest(APIViewTestCases.APIViewTestCase):
+ model = Device
+ brief_fields = ['display_name', 'id', 'name', 'url']
+
+ @classmethod
+ def setUpTestData(cls):
+
+ sites = (
+ Site(name='Site 1', slug='site-1'),
+ Site(name='Site 2', slug='site-2'),
+ )
+ Site.objects.bulk_create(sites)
+
+ racks = (
+ Rack(name='Rack 1', site=sites[0]),
+ Rack(name='Rack 2', site=sites[1]),
+ )
+ Rack.objects.bulk_create(racks)
+
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+
+ device_types = (
+ DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+ DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+ )
+ DeviceType.objects.bulk_create(device_types)
+
+ device_roles = (
+ DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000'),
+ DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00'),
+ )
+ DeviceRole.objects.bulk_create(device_roles)
+
+ cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
+
+ clusters = (
+ Cluster(name='Cluster 1', type=cluster_type),
+ Cluster(name='Cluster 2', type=cluster_type),
+ )
+ Cluster.objects.bulk_create(clusters)
+
+ devices = (
+ Device(
+ device_type=device_types[0],
+ device_role=device_roles[0],
+ name='Device 1',
+ site=sites[0],
+ rack=racks[0],
+ cluster=clusters[0],
+ local_context_data={'A': 1}
+ ),
+ Device(
+ device_type=device_types[0],
+ device_role=device_roles[0],
+ name='Device 2',
+ site=sites[0],
+ rack=racks[0],
+ cluster=clusters[0],
+ local_context_data={'B': 2}
+ ),
+ Device(
+ device_type=device_types[0],
+ device_role=device_roles[0],
+ name='Device 3',
+ site=sites[0],
+ rack=racks[0],
+ cluster=clusters[0],
+ local_context_data={'C': 3}
+ ),
+ )
+ Device.objects.bulk_create(devices)
+
+ cls.create_data = [
+ {
+ 'device_type': device_types[1].pk,
+ 'device_role': device_roles[1].pk,
+ 'name': 'Test Device 4',
+ 'site': sites[1].pk,
+ 'rack': racks[1].pk,
+ 'cluster': clusters[1].pk,
+ },
+ {
+ 'device_type': device_types[1].pk,
+ 'device_role': device_roles[1].pk,
+ 'name': 'Test Device 5',
+ 'site': sites[1].pk,
+ 'rack': racks[1].pk,
+ 'cluster': clusters[1].pk,
+ },
+ {
+ 'device_type': device_types[1].pk,
+ 'device_role': device_roles[1].pk,
+ 'name': 'Test Device 6',
+ 'site': sites[1].pk,
+ 'rack': racks[1].pk,
+ 'cluster': clusters[1].pk,
+ },
+ ]
def test_get_device_graphs(self):
+ """
+ Test retrieval of Graphs assigned to Devices.
+ """
+ ct = ContentType.objects.get_for_model(Device)
+ graphs = (
+ Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'),
+ Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'),
+ Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'),
+ )
+ Graph.objects.bulk_create(graphs)
- device_ct = ContentType.objects.get_for_model(Device)
- self.graph1 = Graph.objects.create(
- type=device_ct,
- name='Test Graph 1',
- source='http://example.com/graphs.py?device={{ obj.name }}&foo=1'
- )
- self.graph2 = Graph.objects.create(
- type=device_ct,
- name='Test Graph 2',
- source='http://example.com/graphs.py?device={{ obj.name }}&foo=2'
- )
- self.graph3 = Graph.objects.create(
- type=device_ct,
- name='Test Graph 3',
- source='http://example.com/graphs.py?device={{ obj.name }}&foo=3'
- )
-
- url = reverse('dcim-api:device-graphs', kwargs={'pk': self.device1.pk})
+ url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
- self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Test Device 1&foo=1')
-
- def test_list_devices(self):
-
- url = reverse('dcim-api:device-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 4)
-
- def test_list_devices_brief(self):
-
- url = reverse('dcim-api:device-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['display_name', 'id', 'name', 'url']
- )
-
- def test_create_device(self):
-
- data = {
- 'device_type': self.devicetype1.pk,
- 'device_role': self.devicerole1.pk,
- 'name': 'Test Device 4',
- 'site': self.site1.pk,
- 'rack': self.rack1.pk,
- 'face': DeviceFaceChoices.FACE_FRONT,
- 'position': 1,
- 'cluster': self.cluster1.pk,
- }
-
- url = reverse('dcim-api:device-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Device.objects.count(), 5)
- device4 = Device.objects.get(pk=response.data['id'])
- self.assertEqual(device4.device_type_id, data['device_type'])
- self.assertEqual(device4.device_role_id, data['device_role'])
- self.assertEqual(device4.name, data['name'])
- self.assertEqual(device4.site.pk, data['site'])
- self.assertEqual(device4.cluster.pk, data['cluster'])
-
- def test_create_device_bulk(self):
-
- data = [
- {
- 'device_type': self.devicetype1.pk,
- 'device_role': self.devicerole1.pk,
- 'name': 'Test Device 4',
- 'site': self.site1.pk,
- },
- {
- 'device_type': self.devicetype1.pk,
- 'device_role': self.devicerole1.pk,
- 'name': 'Test Device 5',
- 'site': self.site1.pk,
- },
- {
- 'device_type': self.devicetype1.pk,
- 'device_role': self.devicerole1.pk,
- 'name': 'Test Device 6',
- 'site': self.site1.pk,
- },
- ]
-
- url = reverse('dcim-api:device-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Device.objects.count(), 7)
- 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'])
-
- def test_update_device(self):
-
- interface = Interface.objects.create(name='Test Interface 1', device=self.device1)
- ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
- ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
-
- data = {
- 'device_type': self.devicetype2.pk,
- 'device_role': self.devicerole2.pk,
- 'name': 'Test Device X',
- 'site': self.site2.pk,
- 'primary_ip4': ip4_address.pk,
- 'primary_ip6': ip6_address.pk,
- }
-
- url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Device.objects.count(), 4)
- device1 = Device.objects.get(pk=response.data['id'])
- self.assertEqual(device1.device_type_id, data['device_type'])
- self.assertEqual(device1.device_role_id, data['device_role'])
- self.assertEqual(device1.name, data['name'])
- self.assertEqual(device1.site.pk, data['site'])
- self.assertEqual(device1.primary_ip4.pk, data['primary_ip4'])
- self.assertEqual(device1.primary_ip6.pk, data['primary_ip6'])
-
- def test_delete_device(self):
-
- url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Device.objects.count(), 3)
+ self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?device=Device 1&foo=1')
def test_config_context_included_by_default_in_list_view(self):
-
+ """
+ Check that config context data is included by default in the devices list.
+ """
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
response = self.client.get(url, **self.header)
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
def test_config_context_excluded(self):
-
+ """
+ Check that config context data can be excluded by passing ?exclude=config_context.
+ """
url = reverse('dcim-api:device-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
self.assertFalse('config_context' in response.data['results'][0])
def test_unique_name_per_site_constraint(self):
-
+ """
+ Check that creating a device with a duplicate name within a site fails.
+ """
+ device = Device.objects.first()
data = {
- 'device_type': self.devicetype1.pk,
- 'device_role': self.devicerole1.pk,
- 'name': 'Test Device 1',
- 'site': self.site1.pk,
+ 'device_type': device.device_type.pk,
+ 'device_role': device.device_role.pk,
+ 'site': device.site.pk,
+ 'name': device.name,
}
url = reverse('dcim-api:device-list')
@@ -2037,1401 +825,578 @@ class DeviceTest(APITestCase):
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
-class ConsolePortTest(APITestCase):
+class ConsolePortTest(APIViewTestCases.APIViewTestCase):
+ model = ConsolePort
+ brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ console_ports = (
+ ConsolePort(device=device, name='Console Port 1'),
+ ConsolePort(device=device, name='Console Port 2'),
+ ConsolePort(device=device, name='Console Port 3'),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.consoleport1 = ConsolePort.objects.create(device=self.device, name='Test Console Port 1')
- self.consoleport2 = ConsolePort.objects.create(device=self.device, name='Test Console Port 2')
- self.consoleport3 = ConsolePort.objects.create(device=self.device, name='Test Console Port 3')
+ ConsolePort.objects.bulk_create(console_ports)
- def test_get_consoleport(self):
-
- url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.consoleport1.name)
-
- def test_list_consoleports(self):
-
- url = reverse('dcim-api:consoleport-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_consoleports_brief(self):
-
- url = reverse('dcim-api:consoleport-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- )
-
- def test_create_consoleport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Console Port 4',
- }
-
- url = reverse('dcim-api:consoleport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsolePort.objects.count(), 4)
- consoleport4 = ConsolePort.objects.get(pk=response.data['id'])
- self.assertEqual(consoleport4.device_id, data['device'])
- self.assertEqual(consoleport4.name, data['name'])
-
- def test_create_consoleport_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
- 'name': 'Test Console Port 4',
+ 'device': device.pk,
+ 'name': 'Console Port 4',
},
{
- 'device': self.device.pk,
- 'name': 'Test Console Port 5',
+ 'device': device.pk,
+ 'name': 'Console Port 5',
},
{
- 'device': self.device.pk,
- 'name': 'Test Console Port 6',
+ 'device': device.pk,
+ 'name': 'Console Port 6',
},
]
- url = reverse('dcim-api:consoleport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsolePort.objects.count(), 6)
- 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'])
-
- def test_update_consoleport(self):
-
- consoleserverport = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1')
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Console Port X',
- }
-
- url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ConsolePort.objects.count(), 3)
- consoleport1 = ConsolePort.objects.get(pk=response.data['id'])
- self.assertEqual(consoleport1.name, data['name'])
-
- def test_delete_consoleport(self):
-
- url = reverse('dcim-api:consoleport-detail', kwargs={'pk': self.consoleport1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ConsolePort.objects.count(), 2)
-
def test_trace_consoleport(self):
-
+ """
+ Test tracing a ConsolePort cable.
+ """
+ consoleport = ConsolePort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
- console_server_port = ConsoleServerPort.objects.create(
+ consoleserverport = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
- cable = Cable(termination_a=self.consoleport1, termination_b=console_server_port, label='Cable 1')
+ cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1')
cable.save()
- url = reverse('dcim-api:consoleport-trace', kwargs={'pk': self.consoleport1.pk})
+ url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], self.consoleport1.name)
+ self.assertEqual(segment1[0]['name'], consoleport.name)
self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], console_server_port.name)
+ self.assertEqual(segment1[2]['name'], consoleserverport.name)
-class ConsoleServerPortTest(APITestCase):
+class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
+ model = ConsoleServerPort
+ brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ console_server_ports = (
+ ConsoleServerPort(device=device, name='Console Server Port 1'),
+ ConsoleServerPort(device=device, name='Console Server Port 2'),
+ ConsoleServerPort(device=device, name='Console Server Port 3'),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 1')
- self.consoleserverport2 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 2')
- self.consoleserverport3 = ConsoleServerPort.objects.create(device=self.device, name='Test CS Port 3')
+ ConsoleServerPort.objects.bulk_create(console_server_ports)
- def test_get_consoleserverport(self):
-
- url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.consoleserverport1.name)
-
- def test_list_consoleserverports(self):
-
- url = reverse('dcim-api:consoleserverport-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_consoleserverports_brief(self):
-
- url = reverse('dcim-api:consoleserverport-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- )
-
- def test_create_consoleserverport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test CS Port 4',
- }
-
- url = reverse('dcim-api:consoleserverport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsoleServerPort.objects.count(), 4)
- consoleserverport4 = ConsoleServerPort.objects.get(pk=response.data['id'])
- self.assertEqual(consoleserverport4.device_id, data['device'])
- self.assertEqual(consoleserverport4.name, data['name'])
-
- def test_create_consoleserverport_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
- 'name': 'Test CS Port 4',
+ 'device': device.pk,
+ 'name': 'Console Server Port 4',
},
{
- 'device': self.device.pk,
- 'name': 'Test CS Port 5',
+ 'device': device.pk,
+ 'name': 'Console Server Port 5',
},
{
- 'device': self.device.pk,
- 'name': 'Test CS Port 6',
+ 'device': device.pk,
+ 'name': 'Console Server Port 6',
},
]
- url = reverse('dcim-api:consoleserverport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConsoleServerPort.objects.count(), 6)
- 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'])
-
- def test_update_consoleserverport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test CS Port X',
- }
-
- url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ConsoleServerPort.objects.count(), 3)
- consoleserverport1 = ConsoleServerPort.objects.get(pk=response.data['id'])
- self.assertEqual(consoleserverport1.name, data['name'])
-
- def test_delete_consoleserverport(self):
-
- url = reverse('dcim-api:consoleserverport-detail', kwargs={'pk': self.consoleserverport1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ConsoleServerPort.objects.count(), 2)
-
def test_trace_consoleserverport(self):
-
+ """
+ Test tracing a ConsoleServerPort cable.
+ """
+ consoleserverport = ConsoleServerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
- console_port = ConsolePort.objects.create(
+ consoleport = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
- cable = Cable(termination_a=self.consoleserverport1, termination_b=console_port, label='Cable 1')
+ cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1')
cable.save()
- url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': self.consoleserverport1.pk})
+ url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], self.consoleserverport1.name)
+ self.assertEqual(segment1[0]['name'], consoleserverport.name)
self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], console_port.name)
+ self.assertEqual(segment1[2]['name'], consoleport.name)
-class PowerPortTest(APITestCase):
+class PowerPortTest(APIViewTestCases.APIViewTestCase):
+ model = PowerPort
+ brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ power_ports = (
+ PowerPort(device=device, name='Power Port 1'),
+ PowerPort(device=device, name='Power Port 2'),
+ PowerPort(device=device, name='Power Port 3'),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.powerport1 = PowerPort.objects.create(device=self.device, name='Test Power Port 1')
- self.powerport2 = PowerPort.objects.create(device=self.device, name='Test Power Port 2')
- self.powerport3 = PowerPort.objects.create(device=self.device, name='Test Power Port 3')
+ PowerPort.objects.bulk_create(power_ports)
- def test_get_powerport(self):
-
- url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.powerport1.name)
-
- def test_list_powerports(self):
-
- url = reverse('dcim-api:powerport-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_powerports_brief(self):
-
- url = reverse('dcim-api:powerport-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- )
-
- def test_create_powerport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Power Port 4',
- }
-
- url = reverse('dcim-api:powerport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPort.objects.count(), 4)
- powerport4 = PowerPort.objects.get(pk=response.data['id'])
- self.assertEqual(powerport4.device_id, data['device'])
- self.assertEqual(powerport4.name, data['name'])
-
- def test_create_powerport_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
- 'name': 'Test Power Port 4',
+ 'device': device.pk,
+ 'name': 'Power Port 4',
},
{
- 'device': self.device.pk,
- 'name': 'Test Power Port 5',
+ 'device': device.pk,
+ 'name': 'Power Port 5',
},
{
- 'device': self.device.pk,
- 'name': 'Test Power Port 6',
+ 'device': device.pk,
+ 'name': 'Power Port 6',
},
]
- url = reverse('dcim-api:powerport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPort.objects.count(), 6)
- 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'])
-
- def test_update_powerport(self):
-
- poweroutlet = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1')
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Power Port X',
- }
-
- url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerPort.objects.count(), 3)
- powerport1 = PowerPort.objects.get(pk=response.data['id'])
- self.assertEqual(powerport1.name, data['name'])
-
- def test_delete_powerport(self):
-
- url = reverse('dcim-api:powerport-detail', kwargs={'pk': self.powerport1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerPort.objects.count(), 2)
-
def test_trace_powerport(self):
-
+ """
+ Test tracing a PowerPort cable.
+ """
+ powerport = PowerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
- power_outlet = PowerOutlet.objects.create(
+ poweroutlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
- cable = Cable(termination_a=self.powerport1, termination_b=power_outlet, label='Cable 1')
+ cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1')
cable.save()
- url = reverse('dcim-api:powerport-trace', kwargs={'pk': self.powerport1.pk})
+ url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], self.powerport1.name)
+ self.assertEqual(segment1[0]['name'], powerport.name)
self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], power_outlet.name)
+ self.assertEqual(segment1[2]['name'], poweroutlet.name)
-class PowerOutletTest(APITestCase):
+class PowerOutletTest(APIViewTestCases.APIViewTestCase):
+ model = PowerOutlet
+ brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ power_outlets = (
+ PowerOutlet(device=device, name='Power Outlet 1'),
+ PowerOutlet(device=device, name='Power Outlet 2'),
+ PowerOutlet(device=device, name='Power Outlet 3'),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 1')
- self.poweroutlet2 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 2')
- self.poweroutlet3 = PowerOutlet.objects.create(device=self.device, name='Test Power Outlet 3')
+ PowerOutlet.objects.bulk_create(power_outlets)
- def test_get_poweroutlet(self):
-
- url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.poweroutlet1.name)
-
- def test_list_poweroutlets(self):
-
- url = reverse('dcim-api:poweroutlet-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_poweroutlets_brief(self):
-
- url = reverse('dcim-api:poweroutlet-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- )
-
- def test_create_poweroutlet(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Power Outlet 4',
- }
-
- url = reverse('dcim-api:poweroutlet-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerOutlet.objects.count(), 4)
- poweroutlet4 = PowerOutlet.objects.get(pk=response.data['id'])
- self.assertEqual(poweroutlet4.device_id, data['device'])
- self.assertEqual(poweroutlet4.name, data['name'])
-
- def test_create_poweroutlet_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
- 'name': 'Test Power Outlet 4',
+ 'device': device.pk,
+ 'name': 'Power Outlet 4',
},
{
- 'device': self.device.pk,
- 'name': 'Test Power Outlet 5',
+ 'device': device.pk,
+ 'name': 'Power Outlet 5',
},
{
- 'device': self.device.pk,
- 'name': 'Test Power Outlet 6',
+ 'device': device.pk,
+ 'name': 'Power Outlet 6',
},
]
- url = reverse('dcim-api:poweroutlet-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerOutlet.objects.count(), 6)
- 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'])
-
- def test_update_poweroutlet(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Power Outlet X',
- }
-
- url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerOutlet.objects.count(), 3)
- poweroutlet1 = PowerOutlet.objects.get(pk=response.data['id'])
- self.assertEqual(poweroutlet1.name, data['name'])
-
- def test_delete_poweroutlet(self):
-
- url = reverse('dcim-api:poweroutlet-detail', kwargs={'pk': self.poweroutlet1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerOutlet.objects.count(), 2)
-
def test_trace_poweroutlet(self):
-
+ """
+ Test tracing a PowerOutlet cable.
+ """
+ poweroutlet = PowerOutlet.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
- power_port = PowerPort.objects.create(
+ powerport = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
- cable = Cable(termination_a=self.poweroutlet1, termination_b=power_port, label='Cable 1')
+ cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1')
cable.save()
- url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': self.poweroutlet1.pk})
+ url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
- self.assertEqual(segment1[0]['name'], self.poweroutlet1.name)
+ self.assertEqual(segment1[0]['name'], poweroutlet.name)
self.assertEqual(segment1[1]['label'], cable.label)
- self.assertEqual(segment1[2]['name'], power_port.name)
+ self.assertEqual(segment1[2]['name'], powerport.name)
-class InterfaceTest(APITestCase):
+class InterfaceTest(APIViewTestCases.APIViewTestCase):
+ model = Interface
+ brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ interfaces = (
+ Interface(device=device, name='Interface 1', type='1000base-t'),
+ Interface(device=device, name='Interface 2', type='1000base-t'),
+ Interface(device=device, name='Interface 3', type='1000base-t'),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+ Interface.objects.bulk_create(interfaces)
+
+ vlans = (
+ VLAN(name='VLAN 1', vid=1),
+ VLAN(name='VLAN 2', vid=2),
+ VLAN(name='VLAN 3', vid=3),
)
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1', type='1000base-t')
- self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2', type='1000base-t')
- self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3', type='1000base-t')
+ VLAN.objects.bulk_create(vlans)
- self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
- self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
- self.vlan3 = VLAN.objects.create(name="Test VLAN 3", vid=3)
-
- def test_get_interface(self):
-
- url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(response.data['name'], self.interface1.name)
+ cls.create_data = [
+ {
+ 'device': device.pk,
+ 'name': 'Interface 4',
+ 'type': '1000base-t',
+ 'mode': InterfaceModeChoices.MODE_TAGGED,
+ 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
+ 'untagged_vlan': vlans[2].pk,
+ },
+ {
+ 'device': device.pk,
+ 'name': 'Interface 5',
+ 'type': '1000base-t',
+ 'mode': InterfaceModeChoices.MODE_TAGGED,
+ 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
+ 'untagged_vlan': vlans[2].pk,
+ },
+ {
+ 'device': device.pk,
+ 'name': 'Interface 6',
+ 'type': '1000base-t',
+ 'mode': InterfaceModeChoices.MODE_TAGGED,
+ 'tagged_vlans': [vlans[0].pk, vlans[1].pk],
+ 'untagged_vlan': vlans[2].pk,
+ },
+ ]
def test_get_interface_graphs(self):
+ """
+ Test retrieval of Graphs assigned to Devices.
+ """
+ ct = ContentType.objects.get_for_model(Interface)
+ graphs = (
+ Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'),
+ Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'),
+ Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'),
+ )
+ Graph.objects.bulk_create(graphs)
- interface_ct = ContentType.objects.get_for_model(Interface)
- self.graph1 = Graph.objects.create(
- type=interface_ct,
- name='Test Graph 1',
- source='http://example.com/graphs.py?interface={{ obj.name }}&foo=1'
- )
- self.graph2 = Graph.objects.create(
- type=interface_ct,
- name='Test Graph 2',
- source='http://example.com/graphs.py?interface={{ obj.name }}&foo=2'
- )
- self.graph3 = Graph.objects.create(
- type=interface_ct,
- name='Test Graph 3',
- source='http://example.com/graphs.py?interface={{ obj.name }}&foo=3'
- )
-
- url = reverse('dcim-api:interface-graphs', kwargs={'pk': self.interface1.pk})
+ url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
- self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Test Interface 1&foo=1')
-
- def test_list_interfaces(self):
-
- url = reverse('dcim-api:interface-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_interfaces_brief(self):
-
- url = reverse('dcim-api:interface-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'connection_status', 'device', 'id', 'name', 'url']
- )
-
- def test_create_interface(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Interface 4',
- 'type': '1000base-t',
- }
-
- url = reverse('dcim-api:interface-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Interface.objects.count(), 4)
- interface4 = Interface.objects.get(pk=response.data['id'])
- self.assertEqual(interface4.device_id, data['device'])
- self.assertEqual(interface4.name, data['name'])
-
- def test_create_interface_with_802_1q(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Interface 4',
- 'type': '1000base-t',
- 'mode': InterfaceModeChoices.MODE_TAGGED,
- 'untagged_vlan': self.vlan3.id,
- 'tagged_vlans': [self.vlan1.id, self.vlan2.id],
- }
-
- url = reverse('dcim-api:interface-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Interface.objects.count(), 4)
- self.assertEqual(response.data['device']['id'], data['device'])
- self.assertEqual(response.data['name'], data['name'])
- self.assertEqual(response.data['untagged_vlan']['id'], data['untagged_vlan'])
- self.assertEqual([v['id'] for v in response.data['tagged_vlans']], data['tagged_vlans'])
-
- def test_create_interface_bulk(self):
-
- data = [
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 4',
- 'type': '1000base-t',
- },
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 5',
- 'type': '1000base-t',
- },
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 6',
- 'type': '1000base-t',
- },
- ]
-
- url = reverse('dcim-api:interface-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Interface.objects.count(), 6)
- 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'])
-
- def test_create_interface_802_1q_bulk(self):
-
- data = [
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 4',
- 'type': '1000base-t',
- 'mode': InterfaceModeChoices.MODE_TAGGED,
- 'untagged_vlan': self.vlan2.id,
- 'tagged_vlans': [self.vlan1.id],
- },
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 5',
- 'type': '1000base-t',
- 'mode': InterfaceModeChoices.MODE_TAGGED,
- 'untagged_vlan': self.vlan2.id,
- 'tagged_vlans': [self.vlan1.id],
- },
- {
- 'device': self.device.pk,
- 'name': 'Test Interface 6',
- 'type': '1000base-t',
- 'mode': InterfaceModeChoices.MODE_TAGGED,
- 'untagged_vlan': self.vlan2.id,
- 'tagged_vlans': [self.vlan1.id],
- },
- ]
-
- url = reverse('dcim-api:interface-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Interface.objects.count(), 6)
- for i in range(0, 3):
- self.assertEqual(response.data[i]['name'], data[i]['name'])
- self.assertEqual([v['id'] for v in response.data[i]['tagged_vlans']], data[i]['tagged_vlans'])
- self.assertEqual(response.data[i]['untagged_vlan']['id'], data[i]['untagged_vlan'])
-
- def test_update_interface(self):
-
- lag_interface = Interface.objects.create(
- device=self.device, name='Test LAG Interface', type=InterfaceTypeChoices.TYPE_LAG
- )
-
- data = {
- 'device': self.device.pk,
- 'name': 'Test Interface X',
- 'type': '1000base-x-gbic',
- 'lag': lag_interface.pk,
- }
-
- url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Interface.objects.count(), 4)
- interface1 = Interface.objects.get(pk=response.data['id'])
- self.assertEqual(interface1.name, data['name'])
- self.assertEqual(interface1.lag_id, data['lag'])
-
- def test_delete_interface(self):
-
- url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Interface.objects.count(), 2)
+ self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
-class FrontPortTest(APITestCase):
+class FrontPortTest(APIViewTestCases.APIViewTestCase):
+ model = FrontPort
+ brief_fields = ['cable', 'device', 'id', 'name', 'url']
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ rear_ports = (
+ RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+ RearPort.objects.bulk_create(rear_ports)
+
+ front_ports = (
+ FrontPort(device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
+ FrontPort(device=device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
+ FrontPort(device=device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2]),
)
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- rear_ports = RearPort.objects.bulk_create((
- RearPort(device=self.device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
- RearPort(device=self.device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
- RearPort(device=self.device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
- RearPort(device=self.device, name='Rear Port 4', type=PortTypeChoices.TYPE_8P8C),
- RearPort(device=self.device, name='Rear Port 5', type=PortTypeChoices.TYPE_8P8C),
- RearPort(device=self.device, name='Rear Port 6', type=PortTypeChoices.TYPE_8P8C),
- ))
- self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0])
- self.frontport3 = FrontPort.objects.create(device=self.device, name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1])
- self.frontport1 = FrontPort.objects.create(device=self.device, name='Front Port 3', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[2])
+ FrontPort.objects.bulk_create(front_ports)
- def test_get_frontport(self):
-
- url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.frontport1.name)
-
- def test_list_frontports(self):
-
- url = reverse('dcim-api:frontport-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_frontports_brief(self):
-
- url = reverse('dcim-api:frontport-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'device', 'id', 'name', 'url']
- )
-
- def test_create_frontport(self):
-
- rear_port = RearPort.objects.get(name='Rear Port 4')
- data = {
- 'device': self.device.pk,
- 'name': 'Front Port 4',
- 'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_port.pk,
- 'rear_port_position': 1,
- }
-
- url = reverse('dcim-api:frontport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(FrontPort.objects.count(), 4)
- frontport4 = FrontPort.objects.get(pk=response.data['id'])
- self.assertEqual(frontport4.device_id, data['device'])
- self.assertEqual(frontport4.name, data['name'])
-
- def test_create_frontport_bulk(self):
-
- rear_ports = RearPort.objects.filter(frontports__isnull=True)
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Front Port 4',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_ports[0].pk,
+ 'rear_port': rear_ports[3].pk,
'rear_port_position': 1,
},
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Front Port 5',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_ports[1].pk,
+ 'rear_port': rear_ports[4].pk,
'rear_port_position': 1,
},
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Front Port 6',
'type': PortTypeChoices.TYPE_8P8C,
- 'rear_port': rear_ports[2].pk,
+ 'rear_port': rear_ports[5].pk,
'rear_port_position': 1,
},
]
- url = reverse('dcim-api:frontport-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(FrontPort.objects.count(), 6)
- 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'])
+class RearPortTest(APIViewTestCases.APIViewTestCase):
+ model = RearPort
+ brief_fields = ['cable', 'device', 'id', 'name', 'url']
- def test_update_frontport(self):
-
- rear_port = RearPort.objects.get(name='Rear Port 4')
- data = {
- 'device': self.device.pk,
- 'name': 'Front Port X',
- 'type': PortTypeChoices.TYPE_110_PUNCH,
- 'rear_port': rear_port.pk,
- 'rear_port_position': 1,
- }
-
- url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(FrontPort.objects.count(), 3)
- frontport1 = FrontPort.objects.get(pk=response.data['id'])
- self.assertEqual(frontport1.name, data['name'])
- self.assertEqual(frontport1.type, data['type'])
- self.assertEqual(frontport1.rear_port, rear_port)
-
- def test_delete_frontport(self):
-
- url = reverse('dcim-api:frontport-detail', kwargs={'pk': self.frontport1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(FrontPort.objects.count(), 2)
-
-
-class RearPortTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
+
+ rear_ports = (
+ RearPort(device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
+ RearPort(device=device, name='Rear Port 3', type=PortTypeChoices.TYPE_8P8C),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 1')
- self.rearport3 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 2')
- self.rearport1 = RearPort.objects.create(device=self.device, type=PortTypeChoices.TYPE_8P8C, name='Rear Port 3')
+ RearPort.objects.bulk_create(rear_ports)
- def test_get_rearport(self):
-
- url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.rearport1.name)
-
- def test_list_rearports(self):
-
- url = reverse('dcim-api:rearport-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_rearports_brief(self):
-
- url = reverse('dcim-api:rearport-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['cable', 'device', 'id', 'name', 'url']
- )
-
- def test_create_rearport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Front Port 4',
- 'type': PortTypeChoices.TYPE_8P8C,
- }
-
- url = reverse('dcim-api:rearport-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RearPort.objects.count(), 4)
- rearport4 = RearPort.objects.get(pk=response.data['id'])
- self.assertEqual(rearport4.device_id, data['device'])
- self.assertEqual(rearport4.name, data['name'])
-
- def test_create_rearport_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Rear Port 4',
'type': PortTypeChoices.TYPE_8P8C,
},
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Rear Port 5',
'type': PortTypeChoices.TYPE_8P8C,
},
{
- 'device': self.device.pk,
+ 'device': device.pk,
'name': 'Rear Port 6',
'type': PortTypeChoices.TYPE_8P8C,
},
]
- url = reverse('dcim-api:rearport-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(RearPort.objects.count(), 6)
- 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'])
+class DeviceBayTest(APIViewTestCases.APIViewTestCase):
+ model = DeviceBay
+ brief_fields = ['device', 'id', 'name', 'url']
- def test_update_rearport(self):
-
- data = {
- 'device': self.device.pk,
- 'name': 'Front Port X',
- 'type': PortTypeChoices.TYPE_110_PUNCH
- }
-
- url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(RearPort.objects.count(), 3)
- rearport1 = RearPort.objects.get(pk=response.data['id'])
- self.assertEqual(rearport1.name, data['name'])
- self.assertEqual(rearport1.type, data['type'])
-
- def test_delete_rearport(self):
-
- url = reverse('dcim-api:rearport-detail', kwargs={'pk': self.rearport1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(RearPort.objects.count(), 2)
-
-
-class DeviceBayTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
+ @classmethod
+ def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- self.devicetype1 = DeviceType.objects.create(
- manufacturer=manufacturer, model='Parent Device Type', slug='parent-device-type',
- subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+
+ device_types = (
+ DeviceType(
+ manufacturer=manufacturer,
+ model='Device Type 1',
+ slug='device-type-1',
+ subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
+ ),
+ DeviceType(
+ manufacturer=manufacturer,
+ model='Device Type 2',
+ slug='device-type-2',
+ subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+ ),
)
- self.devicetype2 = DeviceType.objects.create(
- manufacturer=manufacturer, model='Child Device Type', slug='child-device-type',
- subdevice_role=SubdeviceRoleChoices.ROLE_CHILD
+ DeviceType.objects.bulk_create(device_types)
+
+ devices = (
+ Device(device_type=device_types[0], device_role=devicerole, name='Device 1', site=site),
+ Device(device_type=device_types[1], device_role=devicerole, name='Device 2', site=site),
+ Device(device_type=device_types[1], device_role=devicerole, name='Device 3', site=site),
+ Device(device_type=device_types[1], device_role=devicerole, name='Device 4', site=site),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
+ Device.objects.bulk_create(devices)
+
+ device_bays = (
+ DeviceBay(device=devices[0], name='Device Bay 1'),
+ DeviceBay(device=devices[0], name='Device Bay 2'),
+ DeviceBay(device=devices[0], name='Device Bay 3'),
)
- self.parent_device = Device.objects.create(
- device_type=self.devicetype1, device_role=devicerole, name='Parent Device 1', site=site
- )
- self.child_device = Device.objects.create(
- device_type=self.devicetype2, device_role=devicerole, name='Child Device 1', site=site
- )
- self.devicebay1 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 1')
- self.devicebay2 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 2')
- self.devicebay3 = DeviceBay.objects.create(device=self.parent_device, name='Test Device Bay 3')
+ DeviceBay.objects.bulk_create(device_bays)
- def test_get_devicebay(self):
-
- url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.devicebay1.name)
-
- def test_list_devicebays(self):
-
- url = reverse('dcim-api:devicebay-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_devicebays_brief(self):
-
- url = reverse('dcim-api:devicebay-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['device', 'id', 'name', 'url']
- )
-
- def test_create_devicebay(self):
-
- data = {
- 'device': self.parent_device.pk,
- 'name': 'Test Device Bay 4',
- 'installed_device': self.child_device.pk,
- }
-
- url = reverse('dcim-api:devicebay-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceBay.objects.count(), 4)
- devicebay4 = DeviceBay.objects.get(pk=response.data['id'])
- self.assertEqual(devicebay4.device_id, data['device'])
- self.assertEqual(devicebay4.name, data['name'])
- self.assertEqual(devicebay4.installed_device_id, data['installed_device'])
-
- def test_create_devicebay_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.parent_device.pk,
- 'name': 'Test Device Bay 4',
+ 'device': devices[0].pk,
+ 'name': 'Device Bay 4',
+ 'installed_device': devices[1].pk,
},
{
- 'device': self.parent_device.pk,
- 'name': 'Test Device Bay 5',
+ 'device': devices[0].pk,
+ 'name': 'Device Bay 5',
+ 'installed_device': devices[2].pk,
},
{
- 'device': self.parent_device.pk,
- 'name': 'Test Device Bay 6',
+ 'device': devices[0].pk,
+ 'name': 'Device Bay 6',
+ 'installed_device': devices[3].pk,
},
]
- url = reverse('dcim-api:devicebay-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(DeviceBay.objects.count(), 6)
- 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'])
+class InventoryItemTest(APIViewTestCases.APIViewTestCase):
+ model = InventoryItem
+ brief_fields = ['id', 'name', 'url']
- def test_update_devicebay(self):
+ @classmethod
+ def setUpTestData(cls):
+ manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+ device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
- data = {
- 'device': self.parent_device.pk,
- 'name': 'Test Device Bay X',
- 'installed_device': self.child_device.pk,
- }
-
- url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(DeviceBay.objects.count(), 3)
- devicebay1 = DeviceBay.objects.get(pk=response.data['id'])
- self.assertEqual(devicebay1.name, data['name'])
- self.assertEqual(devicebay1.installed_device_id, data['installed_device'])
-
- def test_delete_devicebay(self):
-
- url = reverse('dcim-api:devicebay-detail', kwargs={'pk': self.devicebay1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(DeviceBay.objects.count(), 2)
-
-
-class InventoryItemTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ inventory_items = (
+ InventoryItem(device=device, name='Inventory Item 1', manufacturer=manufacturer),
+ InventoryItem(device=device, name='Inventory Item 2', manufacturer=manufacturer),
+ InventoryItem(device=device, name='Inventory Item 3', manufacturer=manufacturer),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.inventoryitem1 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 1')
- self.inventoryitem2 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 2')
- self.inventoryitem3 = InventoryItem.objects.create(device=self.device, name='Test Inventory Item 3')
+ InventoryItem.objects.bulk_create(inventory_items)
- def test_get_inventoryitem(self):
-
- url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.inventoryitem1.name)
-
- def test_list_inventoryitems(self):
-
- url = reverse('dcim-api:inventoryitem-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_inventoryitem(self):
-
- data = {
- 'device': self.device.pk,
- 'parent': self.inventoryitem1.pk,
- 'name': 'Test Inventory Item 4',
- 'manufacturer': self.manufacturer.pk,
- }
-
- url = reverse('dcim-api:inventoryitem-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(InventoryItem.objects.count(), 4)
- inventoryitem4 = InventoryItem.objects.get(pk=response.data['id'])
- self.assertEqual(inventoryitem4.device_id, data['device'])
- self.assertEqual(inventoryitem4.parent_id, data['parent'])
- self.assertEqual(inventoryitem4.name, data['name'])
- self.assertEqual(inventoryitem4.manufacturer_id, data['manufacturer'])
-
- def test_create_inventoryitem_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'device': self.device.pk,
- 'parent': self.inventoryitem1.pk,
- 'name': 'Test Inventory Item 4',
- 'manufacturer': self.manufacturer.pk,
+ 'device': device.pk,
+ 'name': 'Inventory Item 4',
+ 'manufacturer': manufacturer.pk,
},
{
- 'device': self.device.pk,
- 'parent': self.inventoryitem1.pk,
- 'name': 'Test Inventory Item 5',
- 'manufacturer': self.manufacturer.pk,
+ 'device': device.pk,
+ 'name': 'Inventory Item 5',
+ 'manufacturer': manufacturer.pk,
},
{
- 'device': self.device.pk,
- 'parent': self.inventoryitem1.pk,
- 'name': 'Test Inventory Item 6',
- 'manufacturer': self.manufacturer.pk,
+ 'device': device.pk,
+ 'name': 'Inventory Item 6',
+ 'manufacturer': manufacturer.pk,
},
]
- url = reverse('dcim-api:inventoryitem-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(InventoryItem.objects.count(), 6)
- 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'])
+class CableTest(APIViewTestCases.APIViewTestCase):
+ model = Cable
+ brief_fields = ['id', 'label', 'url']
- def test_update_inventoryitem(self):
+ # TODO: Allow updating cable terminations
+ test_update_object = None
- data = {
- 'device': self.device.pk,
- 'parent': self.inventoryitem1.pk,
- 'name': 'Test Inventory Item X',
- 'manufacturer': self.manufacturer.pk,
- }
+ @classmethod
+ def setUpTestData(cls):
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
- url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(InventoryItem.objects.count(), 3)
- inventoryitem1 = InventoryItem.objects.get(pk=response.data['id'])
- self.assertEqual(inventoryitem1.device_id, data['device'])
- self.assertEqual(inventoryitem1.parent_id, data['parent'])
- self.assertEqual(inventoryitem1.name, data['name'])
- self.assertEqual(inventoryitem1.manufacturer_id, data['manufacturer'])
-
- def test_delete_inventoryitem(self):
-
- url = reverse('dcim-api:inventoryitem-detail', kwargs={'pk': self.inventoryitem1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(InventoryItem.objects.count(), 2)
-
-
-class CableTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- site = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
- devicetype = DeviceType.objects.create(
- manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
+ devices = (
+ Device(device_type=devicetype, device_role=devicerole, name='Device 1', site=site),
+ Device(device_type=devicetype, device_role=devicerole, name='Device 2', site=site),
)
- devicerole = DeviceRole.objects.create(
- name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
- )
- self.device1 = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
- )
- self.device2 = Device.objects.create(
- device_type=devicetype, device_role=devicerole, name='Test Device 2', site=site
- )
- for device in [self.device1, self.device2]:
+ Device.objects.bulk_create(devices)
+
+ interfaces = []
+ for device in devices:
for i in range(0, 10):
- Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
+ interfaces.append(Interface(device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED, name=f'eth{i}'))
+ Interface.objects.bulk_create(interfaces)
- self.cable1 = Cable(
- termination_a=self.device1.interfaces.get(name='eth0'),
- termination_b=self.device2.interfaces.get(name='eth0'),
- label='Test Cable 1'
+ cables = (
+ Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'),
+ Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'),
+ Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'),
)
- self.cable1.save()
- self.cable2 = Cable(
- termination_a=self.device1.interfaces.get(name='eth1'),
- termination_b=self.device2.interfaces.get(name='eth1'),
- label='Test Cable 2'
- )
- self.cable2.save()
- self.cable3 = Cable(
- termination_a=self.device1.interfaces.get(name='eth2'),
- termination_b=self.device2.interfaces.get(name='eth2'),
- label='Test Cable 3'
- )
- self.cable3.save()
+ for cable in cables:
+ cable.save()
- def test_get_cable(self):
-
- url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['id'], self.cable1.pk)
-
- def test_list_cables(self):
-
- url = reverse('dcim-api:cable-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_cable(self):
-
- interface_a = self.device1.interfaces.get(name='eth3')
- interface_b = self.device2.interfaces.get(name='eth3')
- data = {
- 'termination_a_type': 'dcim.interface',
- 'termination_a_id': interface_a.pk,
- 'termination_b_type': 'dcim.interface',
- 'termination_b_id': interface_b.pk,
- 'status': CableStatusChoices.STATUS_PLANNED,
- 'label': 'Test Cable 4',
- }
-
- url = reverse('dcim-api:cable-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Cable.objects.count(), 4)
- cable4 = Cable.objects.get(pk=response.data['id'])
- self.assertEqual(cable4.termination_a, interface_a)
- self.assertEqual(cable4.termination_b, interface_b)
- self.assertEqual(cable4.status, data['status'])
- self.assertEqual(cable4.label, data['label'])
-
- def test_create_cable_bulk(self):
-
- data = [
+ cls.create_data = [
{
'termination_a_type': 'dcim.interface',
- 'termination_a_id': self.device1.interfaces.get(name='eth3').pk,
+ 'termination_a_id': interfaces[4].pk,
'termination_b_type': 'dcim.interface',
- 'termination_b_id': self.device2.interfaces.get(name='eth3').pk,
- 'label': 'Test Cable 4',
+ 'termination_b_id': interfaces[14].pk,
+ 'label': 'Cable 4',
},
{
'termination_a_type': 'dcim.interface',
- 'termination_a_id': self.device1.interfaces.get(name='eth4').pk,
+ 'termination_a_id': interfaces[5].pk,
'termination_b_type': 'dcim.interface',
- 'termination_b_id': self.device2.interfaces.get(name='eth4').pk,
- 'label': 'Test Cable 5',
+ 'termination_b_id': interfaces[15].pk,
+ 'label': 'Cable 5',
},
{
'termination_a_type': 'dcim.interface',
- 'termination_a_id': self.device1.interfaces.get(name='eth5').pk,
+ 'termination_a_id': interfaces[6].pk,
'termination_b_type': 'dcim.interface',
- 'termination_b_id': self.device2.interfaces.get(name='eth5').pk,
- 'label': 'Test Cable 6',
+ 'termination_b_id': interfaces[16].pk,
+ 'label': 'Cable 6',
},
]
- url = reverse('dcim-api:cable-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Cable.objects.count(), 6)
- self.assertEqual(response.data[0]['label'], data[0]['label'])
- self.assertEqual(response.data[1]['label'], data[1]['label'])
- self.assertEqual(response.data[2]['label'], data[2]['label'])
-
- def test_update_cable(self):
-
- data = {
- 'label': 'Test Cable X',
- 'status': CableStatusChoices.STATUS_CONNECTED,
- }
-
- url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk})
- response = self.client.patch(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Cable.objects.count(), 3)
- cable1 = Cable.objects.get(pk=response.data['id'])
- self.assertEqual(cable1.status, data['status'])
- self.assertEqual(cable1.label, data['label'])
-
- def test_delete_cable(self):
-
- url = reverse('dcim-api:cable-detail', kwargs={'pk': self.cable1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Cable.objects.count(), 2)
-
class ConnectionTest(APITestCase):
@@ -3839,426 +1804,159 @@ class ConnectedDeviceTest(APITestCase):
self.assertEqual(response.data['name'], self.device1.name)
-class VirtualChassisTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
+class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
+ model = VirtualChassis
+ brief_fields = ['id', 'master', 'member_count', 'url']
+ @classmethod
+ def setUpTestData(cls):
site = Site.objects.create(name='Test Site', slug='test-site')
- manufacturer = Manufacturer.objects.create(name='Test Manufacturer', slug='test-manufacturer')
- device_type = DeviceType.objects.create(
- manufacturer=manufacturer, model='Test Device Type', slug='test-device-type'
- )
- device_role = DeviceRole.objects.create(
- name='Test Device Role', slug='test-device-role', color='ff0000'
- )
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type', slug='device-type')
+ devicerole = DeviceRole.objects.create(name='Device Role', slug='device-role', color='ff0000')
- # Create 9 member Devices with 12 interfaces each
- self.device1 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch1', site=site
+ devices = (
+ Device(name='Device 1', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 2', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 3', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 4', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 5', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 6', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 7', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 8', device_type=devicetype, device_role=devicerole, site=site),
+ Device(name='Device 9', device_type=devicetype, device_role=devicerole, site=site),
)
- self.device2 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch2', site=site
- )
- self.device3 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch3', site=site
- )
- self.device4 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch4', site=site
- )
- self.device5 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch5', site=site
- )
- self.device6 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch6', site=site
- )
- self.device7 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch7', site=site
- )
- self.device8 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch8', site=site
- )
- self.device9 = Device.objects.create(
- device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
- )
- for i in range(0, 13):
- Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
- for i in range(0, 13):
- Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+ Device.objects.bulk_create(devices)
+
+ # Create 12 interfaces per device
+ interfaces = []
+ for i, device in enumerate(devices):
+ for j in range(0, 13):
+ interfaces.append(
+ # Interface name starts with parent device's position in VC; e.g. 1/1, 1/2, 1/3...
+ Interface(device=device, name=f'{i%3+1}/{j}', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+ )
+ Interface.objects.bulk_create(interfaces)
# Create two VirtualChassis with three members each
- self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
- Device.objects.filter(pk=self.device2.pk).update(virtual_chassis=self.vc1, vc_position=2)
- Device.objects.filter(pk=self.device3.pk).update(virtual_chassis=self.vc1, vc_position=3)
- self.vc2 = VirtualChassis.objects.create(master=self.device4, domain='test-domain-2')
- Device.objects.filter(pk=self.device5.pk).update(virtual_chassis=self.vc2, vc_position=2)
- Device.objects.filter(pk=self.device6.pk).update(virtual_chassis=self.vc2, vc_position=3)
-
- def test_get_virtualchassis(self):
-
- url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['domain'], self.vc1.domain)
-
- def test_list_virtualchassis(self):
-
- url = reverse('dcim-api:virtualchassis-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 2)
-
- def test_list_virtualchassis_brief(self):
-
- url = reverse('dcim-api:virtualchassis-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'master', 'member_count', 'url']
+ virtual_chassis = (
+ VirtualChassis(master=devices[0], domain='domain-1'),
+ VirtualChassis(master=devices[3], domain='domain-2'),
)
+ VirtualChassis.objects.bulk_create(virtual_chassis)
+ Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
+ Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
+ Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
+ Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
- def test_create_virtualchassis(self):
-
- data = {
- 'master': self.device7.pk,
- 'domain': 'test-domain-3',
+ cls.update_data = {
+ 'master': devices[1].pk,
+ 'domain': 'domain-x',
}
- url = reverse('dcim-api:virtualchassis-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(VirtualChassis.objects.count(), 3)
- vc3 = VirtualChassis.objects.get(pk=response.data['id'])
- self.assertEqual(vc3.master.pk, data['master'])
- self.assertEqual(vc3.domain, data['domain'])
-
- # Verify that the master device was automatically assigned to the VC
- self.assertTrue(Device.objects.filter(pk=vc3.master.pk, virtual_chassis=vc3.pk).exists())
-
- def test_create_virtualchassis_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'master': self.device7.pk,
- 'domain': 'test-domain-3',
+ 'master': devices[6].pk,
+ 'domain': 'domain-3',
},
{
- 'master': self.device8.pk,
- 'domain': 'test-domain-4',
+ 'master': devices[7].pk,
+ 'domain': 'domain-4',
},
{
- 'master': self.device9.pk,
- 'domain': 'test-domain-5',
+ 'master': devices[8].pk,
+ 'domain': 'domain-5',
},
]
- url = reverse('dcim-api:virtualchassis-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(VirtualChassis.objects.count(), 5)
- for i in range(0, 3):
- self.assertEqual(response.data[i]['master']['id'], data[i]['master'])
- self.assertEqual(response.data[i]['domain'], data[i]['domain'])
- def test_update_virtualchassis(self):
+class PowerPanelTest(APIViewTestCases.APIViewTestCase):
+ model = PowerPanel
+ brief_fields = ['id', 'name', 'powerfeed_count', 'url']
- data = {
- 'master': self.device2.pk,
- 'domain': 'test-domain-x',
- }
+ @classmethod
+ def setUpTestData(cls):
+ site = Site.objects.create(name='Site 1', slug='site-1')
- url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(VirtualChassis.objects.count(), 2)
- vc1 = VirtualChassis.objects.get(pk=response.data['id'])
- self.assertEqual(vc1.master.pk, data['master'])
- self.assertEqual(vc1.domain, data['domain'])
-
- def test_delete_virtualchassis(self):
-
- url = reverse('dcim-api:virtualchassis-detail', kwargs={'pk': self.vc1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(VirtualChassis.objects.count(), 1)
-
- # Verify that all VC members have had their VC-related fields nullified
- for d in [self.device1, self.device2, self.device3]:
- self.assertTrue(
- Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
- )
-
-
-class PowerPanelTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
- self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2')
- self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3')
- self.powerpanel1 = PowerPanel.objects.create(
- site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
- )
- self.powerpanel2 = PowerPanel.objects.create(
- site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2'
- )
- self.powerpanel3 = PowerPanel.objects.create(
- site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3'
+ rack_groups = (
+ RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=site),
+ RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=site),
+ RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=site),
)
- def test_get_powerpanel(self):
-
- url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.powerpanel1.name)
-
- def test_list_powerpanels(self):
-
- url = reverse('dcim-api:powerpanel-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_list_powerpanels_brief(self):
-
- url = reverse('dcim-api:powerpanel-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'powerfeed_count', 'url']
+ power_panels = (
+ PowerPanel(site=site, rack_group=rack_groups[0], name='Power Panel 1'),
+ PowerPanel(site=site, rack_group=rack_groups[1], name='Power Panel 2'),
+ PowerPanel(site=site, rack_group=rack_groups[2], name='Power Panel 3'),
)
+ PowerPanel.objects.bulk_create(power_panels)
- def test_create_powerpanel(self):
-
- data = {
- 'name': 'Test Power Panel 4',
- 'site': self.site1.pk,
- 'rack_group': self.rackgroup1.pk,
- }
-
- url = reverse('dcim-api:powerpanel-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPanel.objects.count(), 4)
- powerpanel4 = PowerPanel.objects.get(pk=response.data['id'])
- self.assertEqual(powerpanel4.name, data['name'])
- self.assertEqual(powerpanel4.site_id, data['site'])
- self.assertEqual(powerpanel4.rack_group_id, data['rack_group'])
-
- def test_create_powerpanel_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'name': 'Test Power Panel 4',
- 'site': self.site1.pk,
- 'rack_group': self.rackgroup1.pk,
+ 'name': 'Power Panel 4',
+ 'site': site.pk,
+ 'rack_group': rack_groups[0].pk,
},
{
- 'name': 'Test Power Panel 5',
- 'site': self.site1.pk,
- 'rack_group': self.rackgroup2.pk,
+ 'name': 'Power Panel 5',
+ 'site': site.pk,
+ 'rack_group': rack_groups[1].pk,
},
{
- 'name': 'Test Power Panel 6',
- 'site': self.site1.pk,
- 'rack_group': self.rackgroup3.pk,
+ 'name': 'Power Panel 6',
+ 'site': site.pk,
+ 'rack_group': rack_groups[2].pk,
},
]
- url = reverse('dcim-api:powerpanel-list')
- response = self.client.post(url, data, format='json', **self.header)
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerPanel.objects.count(), 6)
- 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'])
+class PowerFeedTest(APIViewTestCases.APIViewTestCase):
+ model = PowerFeed
+ brief_fields = ['id', 'name', 'url']
- def test_update_powerpanel(self):
+ @classmethod
+ def setUpTestData(cls):
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ rackgroup = RackGroup.objects.create(site=site, name='Rack Group 1', slug='rack-group-1')
+ rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000')
- data = {
- 'name': 'Test Power Panel X',
- 'rack_group': self.rackgroup2.pk,
- }
-
- url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
- response = self.client.patch(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerPanel.objects.count(), 3)
- powerpanel1 = PowerPanel.objects.get(pk=response.data['id'])
- self.assertEqual(powerpanel1.name, data['name'])
- self.assertEqual(powerpanel1.rack_group_id, data['rack_group'])
-
- def test_delete_powerpanel(self):
-
- url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerPanel.objects.count(), 2)
-
-
-class PowerFeedTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
- self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
- self.rack1 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
+ racks = (
+ Rack(site=site, group=rackgroup, role=rackrole, name='Rack 1'),
+ Rack(site=site, group=rackgroup, role=rackrole, name='Rack 2'),
+ Rack(site=site, group=rackgroup, role=rackrole, name='Rack 3'),
+ Rack(site=site, group=rackgroup, role=rackrole, name='Rack 4'),
)
- self.rack2 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
+ Rack.objects.bulk_create(racks)
+
+ power_panels = (
+ PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 1'),
+ PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 2'),
)
- self.rack3 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42,
- )
- self.rack4 = Rack.objects.create(
- site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42,
- )
- self.powerpanel1 = PowerPanel.objects.create(
- site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
- )
- self.powerpanel2 = PowerPanel.objects.create(
- site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2'
- )
- self.powerfeed1 = PowerFeed.objects.create(
- power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=PowerFeedTypeChoices.TYPE_PRIMARY
- )
- self.powerfeed2 = PowerFeed.objects.create(
- power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
- )
- self.powerfeed3 = PowerFeed.objects.create(
- power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=PowerFeedTypeChoices.TYPE_PRIMARY
- )
- self.powerfeed4 = PowerFeed.objects.create(
- power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
- )
- self.powerfeed5 = PowerFeed.objects.create(
- power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=PowerFeedTypeChoices.TYPE_PRIMARY
- )
- self.powerfeed6 = PowerFeed.objects.create(
- power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=PowerFeedTypeChoices.TYPE_REDUNDANT
+ PowerPanel.objects.bulk_create(power_panels)
+
+ PRIMARY = PowerFeedTypeChoices.TYPE_PRIMARY
+ REDUNDANT = PowerFeedTypeChoices.TYPE_REDUNDANT
+ power_feeds = (
+ PowerFeed(power_panel=power_panels[0], rack=racks[0], name='Power Feed 1A', type=PRIMARY),
+ PowerFeed(power_panel=power_panels[1], rack=racks[0], name='Power Feed 1B', type=REDUNDANT),
+ PowerFeed(power_panel=power_panels[0], rack=racks[1], name='Power Feed 2A', type=PRIMARY),
+ PowerFeed(power_panel=power_panels[1], rack=racks[1], name='Power Feed 2B', type=REDUNDANT),
+ PowerFeed(power_panel=power_panels[0], rack=racks[2], name='Power Feed 3A', type=PRIMARY),
+ PowerFeed(power_panel=power_panels[1], rack=racks[2], name='Power Feed 3B', type=REDUNDANT),
)
+ PowerFeed.objects.bulk_create(power_feeds)
- def test_get_powerfeed(self):
-
- url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.powerfeed1.name)
-
- def test_list_powerfeeds(self):
-
- url = reverse('dcim-api:powerfeed-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 6)
-
- def test_list_powerfeeds_brief(self):
-
- url = reverse('dcim-api:powerfeed-list')
- response = self.client.get('{}?brief=1'.format(url), **self.header)
-
- self.assertEqual(
- sorted(response.data['results'][0]),
- ['id', 'name', 'url']
- )
-
- def test_create_powerfeed(self):
-
- data = {
- 'name': 'Test Power Feed 4A',
- 'power_panel': self.powerpanel1.pk,
- 'rack': self.rack4.pk,
- 'type': PowerFeedTypeChoices.TYPE_PRIMARY,
- }
-
- url = reverse('dcim-api:powerfeed-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerFeed.objects.count(), 7)
- powerfeed4 = PowerFeed.objects.get(pk=response.data['id'])
- self.assertEqual(powerfeed4.name, data['name'])
- self.assertEqual(powerfeed4.power_panel_id, data['power_panel'])
- self.assertEqual(powerfeed4.rack_id, data['rack'])
-
- def test_create_powerfeed_bulk(self):
-
- data = [
+ cls.create_data = [
{
- 'name': 'Test Power Feed 4A',
- 'power_panel': self.powerpanel1.pk,
- 'rack': self.rack4.pk,
- 'type': PowerFeedTypeChoices.TYPE_PRIMARY,
+ 'name': 'Power Feed 4A',
+ 'power_panel': power_panels[0].pk,
+ 'rack': racks[3].pk,
+ 'type': PRIMARY,
},
{
- 'name': 'Test Power Feed 4B',
- 'power_panel': self.powerpanel1.pk,
- 'rack': self.rack4.pk,
- 'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
+ 'name': 'Power Feed 4B',
+ 'power_panel': power_panels[1].pk,
+ 'rack': racks[3].pk,
+ 'type': REDUNDANT,
},
]
-
- url = reverse('dcim-api:powerfeed-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(PowerFeed.objects.count(), 8)
- self.assertEqual(response.data[0]['name'], data[0]['name'])
- self.assertEqual(response.data[1]['name'], data[1]['name'])
-
- def test_update_powerfeed(self):
-
- data = {
- 'name': 'Test Power Feed X',
- 'rack': self.rack4.pk,
- 'type': PowerFeedTypeChoices.TYPE_REDUNDANT,
- }
-
- url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
- response = self.client.patch(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(PowerFeed.objects.count(), 6)
- powerfeed1 = PowerFeed.objects.get(pk=response.data['id'])
- self.assertEqual(powerfeed1.name, data['name'])
- self.assertEqual(powerfeed1.rack_id, data['rack'])
- self.assertEqual(powerfeed1.type, data['type'])
-
- def test_delete_powerfeed(self):
-
- url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(PowerFeed.objects.count(), 5)
From 665646707c77a08f25ab5fc8a62c0dc398ef3452 Mon Sep 17 00:00:00 2001
From: Jeremy Stretch
Date: Fri, 5 Jun 2020 13:41:54 -0400
Subject: [PATCH 170/300] Standardize extras API tests
---
netbox/extras/tests/test_api.py | 597 +++++++-------------------------
1 file changed, 125 insertions(+), 472 deletions(-)
diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py
index 3ab9eaaf0..cf4c20624 100644
--- a/netbox/extras/tests/test_api.py
+++ b/netbox/extras/tests/test_api.py
@@ -9,9 +9,8 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform,
from extras.api.views import ScriptViewSet
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
-from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup
-from utilities.testing import APITestCase
+from utilities.testing import APITestCase, APIViewTestCases
class AppTest(APITestCase):
@@ -24,489 +23,146 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
-class GraphTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- site_ct = ContentType.objects.get_for_model(Site)
- self.graph1 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 1',
- source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'
- )
- self.graph2 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 2',
- source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'
- )
- self.graph3 = Graph.objects.create(
- type=site_ct,
- name='Test Graph 3',
- source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'
- )
-
- def test_get_graph(self):
-
- url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.graph1.name)
-
- def test_list_graphs(self):
-
- url = reverse('extras-api:graph-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_graph(self):
-
- data = {
+class GraphTest(APIViewTestCases.APIViewTestCase):
+ model = Graph
+ create_data = [
+ {
'type': 'dcim.site',
- 'name': 'Test Graph 4',
+ 'name': 'Graph 4',
'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
- }
-
- url = reverse('extras-api:graph-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Graph.objects.count(), 4)
- graph4 = Graph.objects.get(pk=response.data['id'])
- self.assertEqual(graph4.type, ContentType.objects.get_for_model(Site))
- self.assertEqual(graph4.name, data['name'])
- self.assertEqual(graph4.source, data['source'])
-
- def test_create_graph_bulk(self):
-
- data = [
- {
- 'type': 'dcim.site',
- 'name': 'Test Graph 4',
- 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=4',
- },
- {
- 'type': 'dcim.site',
- 'name': 'Test Graph 5',
- 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
- },
- {
- 'type': 'dcim.site',
- 'name': 'Test Graph 6',
- 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
- },
- ]
-
- url = reverse('extras-api:graph-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Graph.objects.count(), 6)
- 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'])
-
- def test_update_graph(self):
-
- data = {
+ },
+ {
'type': 'dcim.site',
- 'name': 'Test Graph X',
- 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=99',
- }
+ 'name': 'Graph 5',
+ 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=5',
+ },
+ {
+ 'type': 'dcim.site',
+ 'name': 'Graph 6',
+ 'source': 'http://example.com/graphs.py?site={{ obj.name }}&foo=6',
+ },
+ ]
- url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
- response = self.client.put(url, data, format='json', **self.header)
+ @classmethod
+ def setUpTestData(cls):
+ ct = ContentType.objects.get_for_model(Site)
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Graph.objects.count(), 3)
- graph1 = Graph.objects.get(pk=response.data['id'])
- self.assertEqual(graph1.type, ContentType.objects.get_for_model(Site))
- self.assertEqual(graph1.name, data['name'])
- self.assertEqual(graph1.source, data['source'])
-
- def test_delete_graph(self):
-
- url = reverse('extras-api:graph-detail', kwargs={'pk': self.graph1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Graph.objects.count(), 2)
-
-
-class ExportTemplateTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- content_type = ContentType.objects.get_for_model(Device)
- self.exporttemplate1 = ExportTemplate.objects.create(
- content_type=content_type, name='Test Export Template 1',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
- )
- self.exporttemplate2 = ExportTemplate.objects.create(
- content_type=content_type, name='Test Export Template 2',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
- )
- self.exporttemplate3 = ExportTemplate.objects.create(
- content_type=content_type, name='Test Export Template 3',
- template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ graphs = (
+ Graph(type=ct, name='Graph 1', source='http://example.com/graphs.py?site={{ obj.name }}&foo=1'),
+ Graph(type=ct, name='Graph 2', source='http://example.com/graphs.py?site={{ obj.name }}&foo=2'),
+ Graph(type=ct, name='Graph 3', source='http://example.com/graphs.py?site={{ obj.name }}&foo=3'),
)
+ Graph.objects.bulk_create(graphs)
- def test_get_exporttemplate(self):
- url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.exporttemplate1.name)
-
- def test_list_exporttemplates(self):
-
- url = reverse('extras-api:exporttemplate-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_exporttemplate(self):
-
- data = {
+class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
+ model = ExportTemplate
+ create_data = [
+ {
'content_type': 'dcim.device',
'name': 'Test Export Template 4',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
- }
-
- url = reverse('extras-api:exporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ExportTemplate.objects.count(), 4)
- exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
- self.assertEqual(exporttemplate4.name, data['name'])
- self.assertEqual(exporttemplate4.template_code, data['template_code'])
-
- def test_create_exporttemplate_bulk(self):
-
- data = [
- {
- 'content_type': 'dcim.device',
- 'name': 'Test Export Template 4',
- 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
- },
- {
- 'content_type': 'dcim.device',
- 'name': 'Test Export Template 5',
- 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
- },
- {
- 'content_type': 'dcim.device',
- 'name': 'Test Export Template 6',
- 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
- },
- ]
-
- url = reverse('extras-api:exporttemplate-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ExportTemplate.objects.count(), 6)
- 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'])
-
- def test_update_exporttemplate(self):
-
- data = {
+ },
+ {
'content_type': 'dcim.device',
- 'name': 'Test Export Template X',
+ 'name': 'Test Export Template 5',
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
- }
+ },
+ {
+ 'content_type': 'dcim.device',
+ 'name': 'Test Export Template 6',
+ 'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+ },
+ ]
- url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
- response = self.client.put(url, data, format='json', **self.header)
+ @classmethod
+ def setUpTestData(cls):
+ ct = ContentType.objects.get_for_model(Device)
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ExportTemplate.objects.count(), 3)
- exporttemplate1 = ExportTemplate.objects.get(pk=response.data['id'])
- self.assertEqual(exporttemplate1.name, data['name'])
- self.assertEqual(exporttemplate1.template_code, data['template_code'])
-
- def test_delete_exporttemplate(self):
-
- url = reverse('extras-api:exporttemplate-detail', kwargs={'pk': self.exporttemplate1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ExportTemplate.objects.count(), 2)
-
-
-class TagTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
- self.tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
- self.tag3 = Tag.objects.create(name='Test Tag 3', slug='test-tag-3')
-
- def test_get_tag(self):
-
- url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.tag1.name)
-
- def test_list_tags(self):
-
- url = reverse('extras-api:tag-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_tag(self):
-
- data = {
- 'name': 'Test Tag 4',
- 'slug': 'test-tag-4',
- }
-
- url = reverse('extras-api:tag-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Tag.objects.count(), 4)
- tag4 = Tag.objects.get(pk=response.data['id'])
- self.assertEqual(tag4.name, data['name'])
- self.assertEqual(tag4.slug, data['slug'])
-
- def test_create_tag_bulk(self):
-
- data = [
- {
- 'name': 'Test Tag 4',
- 'slug': 'test-tag-4',
- },
- {
- 'name': 'Test Tag 5',
- 'slug': 'test-tag-5',
- },
- {
- 'name': 'Test Tag 6',
- 'slug': 'test-tag-6',
- },
- ]
-
- url = reverse('extras-api:tag-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(Tag.objects.count(), 6)
- 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'])
-
- def test_update_tag(self):
-
- data = {
- 'name': 'Test Tag X',
- 'slug': 'test-tag-x',
- }
-
- url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(Tag.objects.count(), 3)
- tag1 = Tag.objects.get(pk=response.data['id'])
- self.assertEqual(tag1.name, data['name'])
- self.assertEqual(tag1.slug, data['slug'])
-
- def test_delete_tag(self):
-
- url = reverse('extras-api:tag-detail', kwargs={'pk': self.tag1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(Tag.objects.count(), 2)
-
-
-class ConfigContextTest(APITestCase):
-
- def setUp(self):
-
- super().setUp()
-
- self.configcontext1 = ConfigContext.objects.create(
- name='Test Config Context 1',
- weight=100,
- data={'foo': 123}
+ export_templates = (
+ ExportTemplate(
+ content_type=ct,
+ name='Export Template 1',
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ ),
+ ExportTemplate(
+ content_type=ct,
+ name='Export Template 2',
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ ),
+ ExportTemplate(
+ content_type=ct,
+ name='Export Template 3',
+ template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
+ ),
)
- self.configcontext2 = ConfigContext.objects.create(
- name='Test Config Context 2',
- weight=200,
- data={'bar': 456}
+ ExportTemplate.objects.bulk_create(export_templates)
+
+
+class TagTest(APIViewTestCases.APIViewTestCase):
+ model = Tag
+ create_data = [
+ {
+ 'name': 'Tag 4',
+ 'slug': 'tag-4',
+ },
+ {
+ 'name': 'Tag 5',
+ 'slug': 'tag-5',
+ },
+ {
+ 'name': 'Tag 6',
+ 'slug': 'tag-6',
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+
+ tags = (
+ Tag(name='Tag 1', slug='tag-1'),
+ Tag(name='Tag 2', slug='tag-2'),
+ Tag(name='Tag 3', slug='tag-3'),
)
- self.configcontext3 = ConfigContext.objects.create(
- name='Test Config Context 3',
- weight=300,
- data={'baz': 789}
+ Tag.objects.bulk_create(tags)
+
+
+class ConfigContextTest(APIViewTestCases.APIViewTestCase):
+ model = ConfigContext
+ create_data = [
+ {
+ 'name': 'Config Context 4',
+ 'data': {'more_foo': True},
+ },
+ {
+ 'name': 'Config Context 5',
+ 'data': {'more_bar': False},
+ },
+ {
+ 'name': 'Config Context 6',
+ 'data': {'more_baz': None},
+ },
+ ]
+
+ @classmethod
+ def setUpTestData(cls):
+
+ config_contexts = (
+ ConfigContext(name='Config Context 1', weight=100, data={'foo': 123}),
+ ConfigContext(name='Config Context 2', weight=200, data={'bar': 456}),
+ ConfigContext(name='Config Context 3', weight=300, data={'baz': 789}),
)
-
- def test_get_configcontext(self):
-
- url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['name'], self.configcontext1.name)
- self.assertEqual(response.data['data'], self.configcontext1.data)
-
- def test_list_configcontexts(self):
-
- url = reverse('extras-api:configcontext-list')
- response = self.client.get(url, **self.header)
-
- self.assertEqual(response.data['count'], 3)
-
- def test_create_configcontext(self):
-
- region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
- region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
- site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
- site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
- role1 = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
- role2 = DeviceRole.objects.create(name='Test Role 2', slug='test-role-2')
- platform1 = Platform.objects.create(name='Test Platform 1', slug='test-platform-1')
- platform2 = Platform.objects.create(name='Test Platform 2', slug='test-platform-2')
- tenantgroup1 = TenantGroup(name='Test Tenant Group 1', slug='test-tenant-group-1')
- tenantgroup1.save()
- tenantgroup2 = TenantGroup(name='Test Tenant Group 2', slug='test-tenant-group-2')
- tenantgroup2.save()
- tenant1 = Tenant.objects.create(name='Test Tenant 1', slug='test-tenant-1')
- tenant2 = Tenant.objects.create(name='Test Tenant 2', slug='test-tenant-2')
- tag1 = Tag.objects.create(name='Test Tag 1', slug='test-tag-1')
- tag2 = Tag.objects.create(name='Test Tag 2', slug='test-tag-2')
-
- data = {
- 'name': 'Test Config Context 4',
- 'weight': 1000,
- 'regions': [region1.pk, region2.pk],
- 'sites': [site1.pk, site2.pk],
- 'roles': [role1.pk, role2.pk],
- 'platforms': [platform1.pk, platform2.pk],
- 'tenant_groups': [tenantgroup1.pk, tenantgroup2.pk],
- 'tenants': [tenant1.pk, tenant2.pk],
- 'tags': [tag1.slug, tag2.slug],
- 'data': {'foo': 'XXX'}
- }
-
- url = reverse('extras-api:configcontext-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConfigContext.objects.count(), 4)
- configcontext4 = ConfigContext.objects.get(pk=response.data['id'])
- self.assertEqual(configcontext4.name, data['name'])
- self.assertEqual(region1.pk, data['regions'][0])
- self.assertEqual(region2.pk, data['regions'][1])
- self.assertEqual(site1.pk, data['sites'][0])
- self.assertEqual(site2.pk, data['sites'][1])
- self.assertEqual(role1.pk, data['roles'][0])
- self.assertEqual(role2.pk, data['roles'][1])
- self.assertEqual(platform1.pk, data['platforms'][0])
- self.assertEqual(platform2.pk, data['platforms'][1])
- self.assertEqual(tenantgroup1.pk, data['tenant_groups'][0])
- self.assertEqual(tenantgroup2.pk, data['tenant_groups'][1])
- self.assertEqual(tenant1.pk, data['tenants'][0])
- self.assertEqual(tenant2.pk, data['tenants'][1])
- self.assertEqual(tag1.slug, data['tags'][0])
- self.assertEqual(tag2.slug, data['tags'][1])
- self.assertEqual(configcontext4.data, data['data'])
-
- def test_create_configcontext_bulk(self):
-
- data = [
- {
- 'name': 'Test Config Context 4',
- 'data': {'more_foo': True},
- },
- {
- 'name': 'Test Config Context 5',
- 'data': {'more_bar': False},
- },
- {
- 'name': 'Test Config Context 6',
- 'data': {'more_baz': None},
- },
- ]
-
- url = reverse('extras-api:configcontext-list')
- response = self.client.post(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_201_CREATED)
- self.assertEqual(ConfigContext.objects.count(), 6)
- for i in range(0, 3):
- self.assertEqual(response.data[i]['name'], data[i]['name'])
- self.assertEqual(response.data[i]['data'], data[i]['data'])
-
- def test_update_configcontext(self):
-
- region1 = Region.objects.create(name='Test Region 1', slug='test-region-1')
- region2 = Region.objects.create(name='Test Region 2', slug='test-region-2')
-
- data = {
- 'name': 'Test Config Context X',
- 'weight': 999,
- 'regions': [region1.pk, region2.pk],
- 'data': {'foo': 'XXX'}
- }
-
- url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
- response = self.client.put(url, data, format='json', **self.header)
-
- self.assertHttpStatus(response, status.HTTP_200_OK)
- self.assertEqual(ConfigContext.objects.count(), 3)
- configcontext1 = ConfigContext.objects.get(pk=response.data['id'])
- self.assertEqual(configcontext1.name, data['name'])
- self.assertEqual(configcontext1.weight, data['weight'])
- self.assertEqual(sorted([r.pk for r in configcontext1.regions.all()]), sorted(data['regions']))
- self.assertEqual(configcontext1.data, data['data'])
-
- def test_delete_configcontext(self):
-
- url = reverse('extras-api:configcontext-detail', kwargs={'pk': self.configcontext1.pk})
- response = self.client.delete(url, **self.header)
-
- self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
- self.assertEqual(ConfigContext.objects.count(), 2)
+ ConfigContext.objects.bulk_create(config_contexts)
def test_render_configcontext_for_object(self):
-
- # Create a Device for which we'll render a config context
- manufacturer = Manufacturer.objects.create(
- name='Test Manufacturer',
- slug='test-manufacturer'
- )
- device_type = DeviceType.objects.create(
- manufacturer=manufacturer,
- model='Test Device Type'
- )
- device_role = DeviceRole.objects.create(
- name='Test Role',
- slug='test-role'
- )
- site = Site.objects.create(
- name='Test Site',
- slug='test-site'
- )
- device = Device.objects.create(
- name='Test Device',
- device_type=device_type,
- device_role=device_role,
- site=site
- )
+ """
+ Test rendering config context data for a device.
+ """
+ manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+ devicetype = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+ devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+ site = Site.objects.create(name='Site-1', slug='site-1')
+ device = Device.objects.create(name='Device 1', device_type=devicetype, device_role=devicerole, site=site)
# Test default config contexts (created at test setup)
rendered_context = device.get_config_context()
@@ -516,7 +172,7 @@ class ConfigContextTest(APITestCase):
# Add another context specific to the site
configcontext4 = ConfigContext(
- name='Test Config Context 4',
+ name='Config Context 4',
data={'site_data': 'ABC'}
)
configcontext4.save()
@@ -526,7 +182,7 @@ class ConfigContextTest(APITestCase):
# Override one of the default contexts
configcontext5 = ConfigContext(
- name='Test Config Context 5',
+ name='Config Context 5',
weight=2000,
data={'foo': 999}
)
@@ -536,12 +192,9 @@ class ConfigContextTest(APITestCase):
self.assertEqual(rendered_context['foo'], 999)
# Add a context which does NOT match our device and ensure it does not apply
- site2 = Site.objects.create(
- name='Test Site 2',
- slug='test-site-2'
- )
+ site2 = Site.objects.create(name='Site 2', slug='site-2')
configcontext6 = ConfigContext(
- name='Test Config Context 6',
+ name='Config Context 6',
weight=2000,
data={'bar': 999}
)
From 286a3e6ca296609201d3f02892a3d50c338b9f78 Mon Sep 17 00:00:00 2001
From: Jonathan Senecal
Date: Fri, 5 Jun 2020 13:59:59 -0400
Subject: [PATCH 171/300] Add `label` to forms, views and templates
---
netbox/dcim/forms.py | 200 ++++++++++++++++++++--
netbox/templates/dcim/interface.html | 4 +
netbox/templates/dcim/interface_edit.html | 1 +
netbox/utilities/views.py | 19 +-
4 files changed, 207 insertions(+), 17 deletions(-)
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index 94cf51fcd..0e9d9763e 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -1032,7 +1032,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1046,11 +1046,27 @@ class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1072,7 +1088,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1086,11 +1102,27 @@ class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
widget=StaticSelect2()
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1112,7 +1144,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerPortTemplate
fields = [
- 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+ 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1126,6 +1158,10 @@ class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False
@@ -1141,6 +1177,18 @@ class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
help_text="Allocated power draw (watts)"
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1172,7 +1220,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = PowerOutletTemplate
fields = [
- 'device_type', 'name', 'type', 'power_port', 'feed_leg',
+ 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1196,6 +1244,10 @@ class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False
@@ -1221,6 +1273,18 @@ class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
device_type=device_type
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1247,7 +1311,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'name', 'type', 'mgmt_only',
+ 'device_type', 'name', 'label', 'type', 'mgmt_only',
]
widgets = {
'device_type': forms.HiddenInput(),
@@ -1262,6 +1326,10 @@ class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect2()
@@ -1271,6 +1339,18 @@ class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
label='Management only'
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -1504,7 +1584,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsolePortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
@@ -1513,7 +1593,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'device_type', 'name', 'type',
+ 'device_type', 'name', 'label', 'type',
]
@@ -1522,7 +1602,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerPortTemplate
fields = [
- 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw',
+ 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
]
@@ -1536,7 +1616,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerOutletTemplate
fields = [
- 'device_type', 'name', 'type', 'power_port', 'feed_leg',
+ 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
]
@@ -1548,7 +1628,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'name', 'type', 'mgmt_only',
+ 'device_type', 'name', 'label', 'type', 'mgmt_only',
]
@@ -2199,12 +2279,28 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
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'))
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} {}}, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, self.type, label_pattern_count)
+ })
+
#
# Console ports
@@ -2229,7 +2325,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = ConsolePort
fields = [
- 'device', 'name', 'type', 'description', 'tags',
+ 'device', 'name', 'label', 'type', 'description', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
@@ -2243,6 +2339,10 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
@@ -2256,6 +2356,18 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form):
required=False
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class ConsolePortBulkCreateForm(
form_from_model(ConsolePort, ['type', 'description', 'tags']),
@@ -2329,6 +2441,10 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(ConsolePortTypeChoices),
required=False,
@@ -2342,6 +2458,18 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
required=False
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class ConsoleServerPortBulkCreateForm(
form_from_model(ConsoleServerPort, ['type', 'description', 'tags']),
@@ -2429,6 +2557,10 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerPortTypeChoices),
required=False,
@@ -2451,6 +2583,17 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form):
tags = TagField(
required=False
)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
class PowerPortBulkCreateForm(
@@ -2538,6 +2681,10 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=add_blank_choice(PowerOutletTypeChoices),
required=False,
@@ -2568,6 +2715,18 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form):
)
self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} ports, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
class PowerOutletBulkCreateForm(
form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']),
@@ -2721,7 +2880,7 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
class Meta:
model = Interface
fields = [
- 'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
+ 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description',
'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
]
widgets = {
@@ -2763,6 +2922,10 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
name_pattern = ExpandableNameField(
label='Name'
)
+ label_pattern = ExpandableNameField(
+ label='Label',
+ required=False
+ )
type = forms.ChoiceField(
choices=InterfaceTypeChoices,
widget=StaticSelect2(),
@@ -2843,6 +3006,19 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form):
self.fields['untagged_vlan'].widget.add_additional_query_param('site_id', device.site.pk)
self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk)
+ def clean(self):
+
+ # Validate that the number of ports being created from both the name_pattern and label_pattern are equal
+ name_pattern_count = len(self.cleaned_data['name_pattern'])
+ label_pattern_count = len(self.cleaned_data['label_pattern'])
+ if label_pattern_count and name_pattern_count != label_pattern_count:
+ raise forms.ValidationError({
+ 'label_pattern': 'The provided name pattern will create {} interfaces, however {} labels will '
+ 'be generated. These counts must match.'.format(
+ name_pattern_count, label_pattern_count)
+ })
+
+
class InterfaceBulkCreateForm(
form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']),
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index 9d94e0639..d35504368 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -58,6 +58,10 @@