Kaynağa Gözat

Merge pull request #7130 from netbox-community/develop

Release v3.0.1
Jeremy Stretch 4 yıl önce
ebeveyn
işleme
593874b45f
61 değiştirilmiş dosya ile 522 ekleme ve 214 silme
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 31 0
      docs/release-notes/version-3.0.md
  4. 2 2
      netbox/dcim/api/views.py
  5. 4 4
      netbox/dcim/forms.py
  6. 4 1
      netbox/extras/filters.py
  7. 12 0
      netbox/extras/filtersets.py
  8. 16 3
      netbox/extras/tests/test_customfields.py
  9. 1 1
      netbox/ipam/filtersets.py
  10. 3 20
      netbox/ipam/forms.py
  11. 2 2
      netbox/ipam/models/ip.py
  12. 1 1
      netbox/ipam/tests/test_filtersets.py
  13. 25 5
      netbox/ipam/views.py
  14. 6 16
      netbox/netbox/api/pagination.py
  15. 4 4
      netbox/netbox/middleware.py
  16. 5 1
      netbox/netbox/settings.py
  17. 0 1
      netbox/netbox/views/generic.py
  18. 0 0
      netbox/project-static/dist/config.js
  19. 0 0
      netbox/project-static/dist/config.js.map
  20. 0 0
      netbox/project-static/dist/jobs.js
  21. 0 0
      netbox/project-static/dist/jobs.js.map
  22. 0 0
      netbox/project-static/dist/lldp.js
  23. 0 0
      netbox/project-static/dist/lldp.js.map
  24. 0 0
      netbox/project-static/dist/netbox-dark.css
  25. 0 0
      netbox/project-static/dist/netbox-light.css
  26. 0 0
      netbox/project-static/dist/netbox-print.css
  27. 0 0
      netbox/project-static/dist/netbox.js
  28. 0 0
      netbox/project-static/dist/netbox.js.map
  29. 0 0
      netbox/project-static/dist/status.js
  30. 0 0
      netbox/project-static/dist/status.js.map
  31. 39 3
      netbox/project-static/src/bs.ts
  32. 14 6
      netbox/project-static/src/device/config.ts
  33. 43 11
      netbox/project-static/src/forms/vlanTags.ts
  34. 11 3
      netbox/project-static/src/global.d.ts
  35. 55 11
      netbox/project-static/src/select/api/apiSelect.ts
  36. 1 1
      netbox/project-static/src/select/static.ts
  37. 70 7
      netbox/project-static/src/util.ts
  38. 5 0
      netbox/project-static/styles/netbox.scss
  39. 3 3
      netbox/templates/500.html
  40. 1 0
      netbox/templates/base/base.html
  41. 2 2
      netbox/templates/base/sidenav.html
  42. 3 3
      netbox/templates/dcim/site.html
  43. 9 9
      netbox/templates/ipam/ipaddress_assign.html
  44. 6 2
      netbox/templates/ipam/prefix/ip_addresses.html
  45. 5 2
      netbox/templates/ipam/prefix/ip_ranges.html
  46. 6 18
      netbox/templates/ipam/prefix/prefixes.html
  47. 1 1
      netbox/templates/media_failure.html
  48. 1 1
      netbox/templates/rest_framework/api.html
  49. 27 21
      netbox/templates/utilities/obj_table.html
  50. 5 5
      netbox/templates/utilities/templatetags/table_config_form.html
  51. 1 1
      netbox/templates/virtualization/virtualmachine.html
  52. 2 1
      netbox/utilities/api.py
  53. 1 1
      netbox/utilities/forms/fields.py
  54. 3 2
      netbox/utilities/forms/utils.py
  55. 2 2
      netbox/utilities/forms/widgets.py
  56. 2 2
      netbox/utilities/management/commands/makemigrations.py
  57. 8 4
      netbox/utilities/paginator.py
  58. 4 0
      netbox/utilities/tables.py
  59. 26 26
      netbox/utilities/templatetags/helpers.py
  60. 47 2
      netbox/utilities/utils.py
  61. 1 1
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v3.0.0
+      placeholder: v3.0.1
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.0.0
+      placeholder: v3.0.1
     validations:
       required: true
   - type: dropdown

+ 31 - 0
docs/release-notes/version-3.0.md

@@ -1,5 +1,36 @@
 # NetBox v3.0
 
+## v3.0.1 (2021-09-01)
+
+### Bug Fixes
+
+* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device
+* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI
+* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM
+* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views
+* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name
+* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews
+* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data
+* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table
+* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute
+* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface
+* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission
+* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type
+* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables
+* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH`
+* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table
+* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match
+* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration
+* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination
+* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field
+* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab
+* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests
+* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects
+* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field
+* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select
+
+---
+
 ## v3.0.0 (2021-08-30)
 
 !!! warning "Existing Deployments Must Upgrade from v2.11"

+ 2 - 2
netbox/dcim/api/views.py

@@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from utilities.api import get_serializer_for_model
-from utilities.utils import count_related
+from utilities.utils import count_related, decode_dict
 from virtualization.models import VirtualMachine
 from . import serializers
 from .exceptions import MissingFilterException
@@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
                 response[method] = {'error': 'Only get_* NAPALM methods are supported'}
                 continue
             try:
-                response[method] = getattr(d, method)()
+                response[method] = decode_dict(getattr(d, method)())
             except NotImplementedError:
                 response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
             except Exception as e:

+ 4 - 4
netbox/dcim/forms.py

@@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form):
         super().clean()
 
         parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
-        tagged_vlans = self.cleaned_data['tagged_vlans']
+        tagged_vlans = self.cleaned_data.get('tagged_vlans')
 
         # Untagged interfaces cannot be assigned tagged VLANs
         if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
@@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form):
             self.cleaned_data['tagged_vlans'] = []
 
         # Validate tagged VLANs; must be a global VLAN or in the same site
-        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED:
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
             valid_sites = [None, self.cleaned_data[parent_field].site]
             invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
 
@@ -4586,8 +4586,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
     color = ColorField(
         required=False
     )
-    length = forms.IntegerField(
-        min_value=1,
+    length = forms.DecimalField(
+        min_value=0,
         required=False
     )
     length_unit = forms.ChoiceField(

+ 4 - 1
netbox/extras/filters.py

@@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = (
     CustomFieldTypeChoices.TYPE_DATE,
     CustomFieldTypeChoices.TYPE_INTEGER,
     CustomFieldTypeChoices.TYPE_SELECT,
+    CustomFieldTypeChoices.TYPE_MULTISELECT,
 )
 
 
@@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter):
 
         self.field_name = f'custom_field_data__{self.field_name}'
 
-        if custom_field.type not in EXACT_FILTER_TYPES:
+        if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
+            self.lookup_expr = 'has_key'
+        elif custom_field.type not in EXACT_FILTER_TYPES:
             if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
                 self.lookup_expr = 'icontains'
 

+ 12 - 0
netbox/extras/filtersets.py

@@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet):
 #
 
 class ContentTypeFilterSet(django_filters.FilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
 
     class Meta:
         model = ContentType
         fields = ['id', 'app_label', 'model']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(app_label__icontains=value) |
+            Q(model__icontains=value)
+        )

+ 16 - 3
netbox/extras/tests/test_customfields.py

@@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase):
         cf.content_types.set([obj_type])
 
         # Selection filtering
-        cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz'])
+        cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz'])
+        cf.save()
+        cf.content_types.set([obj_type])
+
+        # Multiselect filtering
+        cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
         cf.save()
         cf.content_types.set([obj_type])
 
@@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase):
                 'cf6': 'http://foo.example.com/',
                 'cf7': 'http://foo.example.com/',
                 'cf8': 'Foo',
+                'cf9': ['A', 'B'],
             }),
             Site(name='Site 2', slug='site-2', custom_field_data={
                 'cf1': 200,
@@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase):
                 'cf6': 'http://bar.example.com/',
                 'cf7': 'http://bar.example.com/',
                 'cf8': 'Bar',
+                'cf9': ['AA', 'B'],
             }),
-            Site(name='Site 3', slug='site-3', custom_field_data={
-            }),
+            Site(name='Site 3', slug='site-3'),
         ])
 
     def test_filter_integer(self):
@@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase):
 
     def test_filter_select(self):
         self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
+
+    def test_filter_multiselect(self):
+        self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
+        self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)

+ 1 - 1
netbox/ipam/filtersets.py

@@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     children = MultiValueNumberFilter(
         field_name='_children'
     )
-    mask_length = django_filters.NumberFilter(
+    mask_length = MultiValueNumberFilter(
         field_name='prefix',
         lookup_expr='net_mask_length'
     )

+ 3 - 20
netbox/ipam/forms.py

@@ -491,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'status': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.fields['vrf'].empty_label = 'Global'
-
 
 class PrefixCSVForm(CustomFieldModelCSVForm):
     vrf = CSVModelChoiceField(
@@ -658,11 +653,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
         label=_('Address family'),
         widget=StaticSelect()
     )
-    mask_length = forms.ChoiceField(
+    mask_length = forms.MultipleChoiceField(
         required=False,
         choices=PREFIX_MASK_LENGTH_CHOICES,
         label=_('Mask length'),
-        widget=StaticSelect()
+        widget=StaticSelectMultiple()
     )
     vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
@@ -760,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'status': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.fields['vrf'].empty_label = 'Global'
-
 
 class IPRangeCSVForm(CustomFieldModelCSVForm):
     vrf = CSVModelChoiceField(
@@ -1026,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
 
         super().__init__(*args, **kwargs)
 
-        self.fields['vrf'].empty_label = 'Global'
-
         # Initialize primary_for_parent if IP address is already assigned
         if self.instance.pk and self.instance.assigned_object:
             parent = self.instance.assigned_object.parent_object
@@ -1102,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
             'role': StaticSelect(),
         }
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-        self.fields['vrf'].empty_label = 'Global'
-
 
 class IPAddressCSVForm(CustomFieldModelCSVForm):
     vrf = CSVModelChoiceField(
@@ -1256,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
     vrf_id = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label='VRF',
-        empty_label='Global'
+        label='VRF'
     )
     q = forms.CharField(
         required=False,

+ 2 - 2
netbox/ipam/models/ip.py

@@ -825,9 +825,9 @@ class IPAddress(PrimaryModel):
         if self.pk:
             for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
                 parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
-                if parent and getattr(self.assigned_object, attr) != parent:
+                if parent and getattr(self.assigned_object, attr, None) != parent:
                     # Check for a NAT relationship
-                    if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent:
+                    if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent:
                         raise ValidationError({
                             'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
                                          f"not assigned to it!"

+ 1 - 1
netbox/ipam/tests/test_filtersets.py

@@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_mask_length(self):
-        params = {'mask_length': '24'}
+        params = {'mask_length': ['24']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
     def test_vrf(self):

+ 25 - 5
netbox/ipam/views.py

@@ -403,13 +403,19 @@ class PrefixPrefixesView(generic.ObjectView):
 
         bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
 
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'change': request.user.has_perm('ipam.change_prefix'),
+            'delete': request.user.has_perm('ipam.delete_prefix'),
+        }
+
         return {
-            'first_available_prefix': instance.get_first_available_prefix(),
             'table': table,
+            'permissions': permissions,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'prefixes',
+            'first_available_prefix': instance.get_first_available_prefix(),
             'show_available': request.GET.get('show_available', 'true') == 'true',
-            'table_config_form': TableConfigForm(table=table),
         }
 
 
@@ -421,15 +427,22 @@ class PrefixIPRangesView(generic.ObjectView):
         # Find all IPRanges belonging to this Prefix
         ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
 
-        table = tables.IPRangeTable(ip_ranges)
+        table = tables.IPRangeTable(ip_ranges, user=request.user)
         if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
             table.columns.show('pk')
         paginate_table(table, request)
 
         bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
 
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'change': request.user.has_perm('ipam.change_iprange'),
+            'delete': request.user.has_perm('ipam.delete_iprange'),
+        }
+
         return {
             'table': table,
+            'permissions': permissions,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'ip-ranges',
         }
@@ -449,18 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView):
         if request.GET.get('show_available', 'true') == 'true':
             ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
 
-        table = tables.IPAddressTable(ipaddresses)
+        table = tables.IPAddressTable(ipaddresses, user=request.user)
         if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
             table.columns.show('pk')
         paginate_table(table, request)
 
         bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
 
+        # Compile permissions list for rendering the object table
+        permissions = {
+            'change': request.user.has_perm('ipam.change_ipaddress'),
+            'delete': request.user.has_perm('ipam.delete_ipaddress'),
+        }
+
         return {
-            'first_available_ip': instance.get_first_available_ip(),
             'table': table,
+            'permissions': permissions,
             'bulk_querystring': bulk_querystring,
             'active_tab': 'ip-addresses',
+            'first_available_ip': instance.get_first_available_ip(),
             'show_available': request.GET.get('show_available', 'true') == 'true',
         }
 

+ 6 - 16
netbox/netbox/api/pagination.py

@@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
             return list(queryset[self.offset:])
 
     def get_limit(self, request):
+        limit = super().get_limit(request)
 
-        if self.limit_query_param:
-            try:
-                limit = int(request.query_params[self.limit_query_param])
-                if limit < 0:
-                    raise ValueError()
-                # Enforce maximum page size, if defined
-                if settings.MAX_PAGE_SIZE:
-                    if limit == 0:
-                        return settings.MAX_PAGE_SIZE
-                    else:
-                        return min(limit, settings.MAX_PAGE_SIZE)
-                return limit
-            except (KeyError, ValueError):
-                pass
-
-        return self.default_limit
+        # Enforce maximum page size
+        if settings.MAX_PAGE_SIZE:
+            limit = min(limit, settings.MAX_PAGE_SIZE)
+
+        return limit
 
     def get_next_link(self):
 

+ 4 - 4
netbox/netbox/middleware.py

@@ -113,6 +113,10 @@ class ExceptionHandlingMiddleware(object):
 
     def process_exception(self, request, exception):
 
+        # Handle exceptions that occur from REST API requests
+        if is_api_request(request):
+            return rest_api_server_error(request)
+
         # Don't catch exceptions when in debug mode
         if settings.DEBUG:
             return
@@ -121,10 +125,6 @@ class ExceptionHandlingMiddleware(object):
         if isinstance(exception, Http404):
             return
 
-        # Handle exceptions that occur from REST API requests
-        if is_api_request(request):
-            return rest_api_server_error(request)
-
         # Determine the type of exception. If it's a common issue, return a custom error page with instructions.
         custom_template = None
         if isinstance(exception, ProgrammingError):

+ 5 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '3.0.0'
+VERSION = '3.0.1'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -560,6 +560,10 @@ RQ_QUEUES = {
 #
 
 # Pagination
+if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE:
+    raise ImproperlyConfigured(
+        f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set."
+    )
 PER_PAGE_DEFAULTS = [
     25, 50, 100, 250, 500, 1000
 ]

+ 0 - 1
netbox/netbox/views/generic.py

@@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
             'table': table,
             'permissions': permissions,
             'action_buttons': self.action_buttons,
-            'table_config_form': TableConfigForm(table=table),
             'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None,
         }
         context.update(self.extra_context())

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/config.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/config.js.map


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/jobs.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/lldp.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/status.js


Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 39 - 3
netbox/project-static/src/bs.ts

@@ -1,6 +1,6 @@
-import { Collapse, Modal, Tab, Toast, Tooltip } from 'bootstrap';
+import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap';
 import Masonry from 'masonry-layout';
-import { getElements } from './util';
+import { createElement, getElements } from './util';
 
 type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
 
@@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info';
 // plugins).
 window.Collapse = Collapse;
 window.Modal = Modal;
+window.Popover = Popover;
 window.Toast = Toast;
 window.Tooltip = Tooltip;
 
@@ -156,13 +157,48 @@ function initSidebarAccordions(): void {
   }
 }
 
+/**
+ * Initialize image preview popover, which shows a preview of an image from an image link with the
+ * `.image-preview` class.
+ */
+function initImagePreview(): void {
+  for (const element of getElements<HTMLAnchorElement>('a.image-preview')) {
+    // Generate a max-width that's a quarter of the screen's width (note - the actual element
+    // width will be slightly larger due to the popover body's padding).
+    const maxWidth = `${Math.round(window.innerWidth / 4)}px`;
+
+    // Create an image element that uses the linked image as its `src`.
+    const image = createElement('img', { src: element.href });
+    image.style.maxWidth = maxWidth;
+
+    // Create a container for the image.
+    const content = createElement('div', null, null, [image]);
+
+    // Initialize the Bootstrap Popper instance.
+    new Popover(element, {
+      // Attach this custom class to the popover so that it styling can be controlled via CSS.
+      customClass: 'image-preview-popover',
+      trigger: 'hover',
+      html: true,
+      content,
+    });
+  }
+}
+
 /**
  * Enable any defined Bootstrap Tooltips.
  *
  * @see https://getbootstrap.com/docs/5.0/components/tooltips
  */
 export function initBootstrap(): void {
-  for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) {
+  for (const func of [
+    initTooltips,
+    initModals,
+    initMasonry,
+    initTabs,
+    initImagePreview,
+    initSidebarAccordions,
+  ]) {
     func();
   }
 }

+ 14 - 6
netbox/project-static/src/device/config.ts

@@ -13,18 +13,26 @@ function initConfig(): void {
       .then(data => {
         if (hasError(data)) {
           createToast('danger', 'Error Fetching Device Config', data.error).show();
+          console.error(data.error);
+          return;
+        } else if (hasError<Required<DeviceConfig['get_config']>>(data.get_config)) {
+          createToast('danger', 'Error Fetching Device Config', data.get_config.error).show();
+          console.error(data.get_config.error);
           return;
         } else {
-          const configTypes = [
-            'running',
-            'startup',
-            'candidate',
-          ] as (keyof DeviceConfig['get_config'])[];
+          const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[];
 
           for (const configType of configTypes) {
             const element = document.getElementById(`${configType}_config`);
             if (element !== null) {
-              element.innerHTML = data.get_config[configType];
+              const config = data.get_config[configType];
+              if (typeof config === 'string') {
+                // If the returned config is a string, set the element innerHTML as-is.
+                element.innerHTML = config;
+              } else {
+                // If the returned config is an object (dict), convert it to JSON.
+                element.innerHTML = JSON.stringify(data.get_config[configType], null, 2);
+              }
             }
           }
         }

+ 43 - 11
netbox/project-static/src/forms/vlanTags.ts

@@ -1,4 +1,4 @@
-import { all, getElement, resetSelect, toggleVisibility } from '../util';
+import { all, getElement, resetSelect, toggleVisibility as _toggleVisibility } from '../util';
 
 /**
  * Get a select element's containing `.row` element.
@@ -14,6 +14,38 @@ function fieldContainer(element: Nullable<HTMLSelectElement>): Nullable<HTMLElem
   return null;
 }
 
+/**
+ * Toggle visibility of the select element's container and disable the select element itself.
+ *
+ * @param element Select element.
+ * @param action 'show' or 'hide'
+ */
+function toggleVisibility<E extends Nullable<HTMLSelectElement>>(
+  element: E,
+  action: 'show' | 'hide',
+): void {
+  // Find the select element's containing element.
+  const parent = fieldContainer(element);
+  if (element !== null && parent !== null) {
+    // Toggle container visibility to visually remove it from the form.
+    _toggleVisibility(parent, action);
+    // Create a new event so that the APISelect instance properly handles the enable/disable
+    // action.
+    const event = new Event(`netbox.select.disabled.${element.name}`);
+    switch (action) {
+      case 'hide':
+        // Disable the native select element and dispatch the event APISelect is listening for.
+        element.disabled = true;
+        element.dispatchEvent(event);
+        break;
+      case 'show':
+        // Enable the native select element and dispatch the event APISelect is listening for.
+        element.disabled = false;
+        element.dispatchEvent(event);
+    }
+  }
+}
+
 /**
  * Toggle element visibility when the mode field does not have a value.
  */
@@ -29,7 +61,7 @@ function handleModeNone(): void {
     resetSelect(untaggedVlan);
     resetSelect(taggedVlans);
     for (const element of elements) {
-      toggleVisibility(fieldContainer(element), 'hide');
+      toggleVisibility(element, 'hide');
     }
   }
 }
@@ -46,9 +78,9 @@ function handleModeAccess(): void {
   if (all(elements)) {
     const [taggedVlans, untaggedVlan, vlanGroup] = elements;
     resetSelect(taggedVlans);
-    toggleVisibility(fieldContainer(vlanGroup), 'show');
-    toggleVisibility(fieldContainer(untaggedVlan), 'show');
-    toggleVisibility(fieldContainer(taggedVlans), 'hide');
+    toggleVisibility(vlanGroup, 'show');
+    toggleVisibility(untaggedVlan, 'show');
+    toggleVisibility(taggedVlans, 'hide');
   }
 }
 
@@ -63,9 +95,9 @@ function handleModeTagged(): void {
   ];
   if (all(elements)) {
     const [taggedVlans, untaggedVlan, vlanGroup] = elements;
-    toggleVisibility(fieldContainer(taggedVlans), 'show');
-    toggleVisibility(fieldContainer(vlanGroup), 'show');
-    toggleVisibility(fieldContainer(untaggedVlan), 'show');
+    toggleVisibility(taggedVlans, 'show');
+    toggleVisibility(vlanGroup, 'show');
+    toggleVisibility(untaggedVlan, 'show');
   }
 }
 
@@ -81,9 +113,9 @@ function handleModeTaggedAll(): void {
   if (all(elements)) {
     const [taggedVlans, untaggedVlan, vlanGroup] = elements;
     resetSelect(taggedVlans);
-    toggleVisibility(fieldContainer(vlanGroup), 'show');
-    toggleVisibility(fieldContainer(untaggedVlan), 'show');
-    toggleVisibility(fieldContainer(taggedVlans), 'hide');
+    toggleVisibility(vlanGroup, 'show');
+    toggleVisibility(untaggedVlan, 'show');
+    toggleVisibility(taggedVlans, 'hide');
   }
 }
 

+ 11 - 3
netbox/project-static/src/global.d.ts

@@ -17,6 +17,11 @@ interface Window {
    */
   Modal: typeof import('bootstrap').Modal;
 
+  /**
+   * Bootstrap Popover Instance.
+   */
+  Popover: typeof import('bootstrap').Popover;
+
   /**
    * Bootstrap Toast Instance.
    */
@@ -147,12 +152,15 @@ type LLDPNeighborDetail = {
 
 type DeviceConfig = {
   get_config: {
-    candidate: string;
-    running: string;
-    startup: string;
+    candidate: string | Record<string, unknown>;
+    running: string | Record<string, unknown>;
+    startup: string | Record<string, unknown>;
+    error?: string;
   };
 };
 
+type DeviceConfigType = Exclude<keyof DeviceConfig['get_config'], 'error'>;
+
 type DeviceEnvironment = {
   cpu?: {
     [core: string]: { '%usage': number };

+ 55 - 11
netbox/project-static/src/select/api/apiSelect.ts

@@ -320,6 +320,7 @@ export class APISelect {
         this.slim.slim.multiSelected.container.setAttribute('disabled', '');
       }
     }
+    this.slim.disable();
   }
 
   /**
@@ -335,6 +336,7 @@ export class APISelect {
         this.slim.slim.multiSelected.container.removeAttribute('disabled');
       }
     }
+    this.slim.enable();
   }
 
   /**
@@ -357,6 +359,11 @@ export class APISelect {
       this.fetchOptions(this.more, 'merge'),
     );
 
+    // When the base select element is disabled or enabled, properly disable/enable this instance.
+    this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
+      this.handleDisableEnable(event),
+    );
+
     // Create a unique iterator of all possible form fields which, when changed, should cause this
     // element to update its API query.
     // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
@@ -389,6 +396,19 @@ export class APISelect {
     }
   }
 
+  /**
+   * Get all options from the native select element that are already selected and do not contain
+   * placeholder values.
+   */
+  private getPreselectedOptions(): HTMLOptionElement[] {
+    return Array.from(this.base.options)
+      .filter(option => option.selected)
+      .filter(option => {
+        if (option.value === '---------' || option.innerText === '---------') return false;
+        return true;
+      });
+  }
+
   /**
    * Process a valid API response and add results to this instance's options.
    *
@@ -398,13 +418,19 @@ export class APISelect {
     data: APIAnswer<APIObjectBase>,
     action: ApplyMethod = 'merge',
   ): Promise<void> {
-    // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an
-    // existing object. When we fetch options from the API later, we can set any of the options
-    // contained in this array to `selected`.
-    const selectOptions = Array.from(this.base.options)
-      .filter(option => option.selected)
-      .map(option => option.getAttribute('value'))
-      .filter(isTruthy);
+    // Get all already-selected options.
+    const preSelected = this.getPreselectedOptions();
+
+    // Get the values of all already-selected options.
+    const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
+
+    // Build SlimSelect options from all already-selected options.
+    const preSelectedOptions = preSelected.map(option => ({
+      value: option.value,
+      text: option.innerText,
+      selected: true,
+      disabled: false,
+    })) as Option[];
 
     let options = [] as Option[];
 
@@ -441,12 +467,12 @@ export class APISelect {
       }
 
       // Set option to disabled if it is contained within the disabled array.
-      if (selectOptions.some(option => this.disabledOptions.includes(option))) {
+      if (selectedValues.some(option => this.disabledOptions.includes(option))) {
         disabled = true;
       }
 
       // Set pre-selected options.
-      if (selectOptions.includes(value)) {
+      if (selectedValues.includes(value)) {
         selected = true;
         // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
         // the rest of the form, resulting in that field's value being deleting from the object.
@@ -469,7 +495,8 @@ export class APISelect {
         this.options = [...this.options, ...options];
         break;
       case 'replace':
-        this.options = options;
+        this.options = [...preSelectedOptions, ...options];
+        break;
     }
 
     if (hasMore(data)) {
@@ -558,6 +585,23 @@ export class APISelect {
     Promise.all([this.loadData()]);
   }
 
+  /**
+   * Event handler to be dispatched when the base select element is disabled or enabled. When that
+   * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
+   * desired action.
+   *
+   * @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
+   */
+  private handleDisableEnable(event: Event): void {
+    const target = event.target as HTMLSelectElement;
+
+    if (target.disabled === true) {
+      this.disable();
+    } else if (target.disabled === false) {
+      this.enable();
+    }
+  }
+
   /**
    * When the API returns an error, show it to the user and reset this element's available options.
    *
@@ -715,7 +759,7 @@ export class APISelect {
   private getPlaceholder(): string {
     let placeholder = this.name;
     if (this.base.id) {
-      const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement;
+      const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
       // Set the placeholder text to the label value, if it exists.
       if (label !== null) {
         placeholder = `Select ${label.innerText.trim()}`;

+ 1 - 1
netbox/project-static/src/select/static.ts

@@ -4,7 +4,7 @@ import { getElements } from '../util';
 export function initStaticSelect(): void {
   for (const select of getElements<HTMLSelectElement>('.netbox-static-select')) {
     if (select !== null) {
-      const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement;
+      const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
 
       let placeholder;
       if (label !== null) {

+ 70 - 7
netbox/project-static/src/util.ts

@@ -1,4 +1,5 @@
 import Cookie from 'cookie';
+import queryString from 'query-string';
 
 type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
 type ReqData = URLSearchParams | Dict | undefined | unknown;
@@ -11,14 +12,16 @@ type InferredProps<
   // Element name.
   T extends keyof HTMLElementTagNameMap,
   // Element type.
-  E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T]
+  E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T],
 > = Partial<Record<keyof E, E[keyof E]>>;
 
 export function isApiError(data: Record<string, unknown>): data is APIError {
   return 'error' in data && 'exception' in data;
 }
 
-export function hasError(data: Record<string, unknown>): data is ErrorBase {
+export function hasError<E extends ErrorBase = ErrorBase>(
+  data: Record<string, unknown>,
+): data is E {
   return 'error' in data;
 }
 
@@ -94,7 +97,7 @@ export function isElement(obj: Element | null | undefined): obj is Element {
 /**
  * Retrieve the CSRF token from cookie storage.
  */
-export function getCsrfToken(): string {
+function getCsrfToken(): string {
   const { csrftoken: csrfToken } = Cookie.parse(document.cookie);
   if (typeof csrfToken === 'undefined') {
     throw new Error('Invalid or missing CSRF token');
@@ -102,8 +105,60 @@ export function getCsrfToken(): string {
   return csrfToken;
 }
 
+/**
+ * Get the NetBox `settings.BASE_PATH` from the `<html/>` element's data attributes.
+ *
+ * @returns If there is no `BASE_PATH` specified, the return value will be `''`.
+ */ function getBasePath(): string {
+  const value = document.documentElement.getAttribute('data-netbox-base-path');
+  if (value === null) {
+    return '';
+  }
+  return value;
+}
+
+/**
+ * Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes.
+ *
+ * @example
+ * ```js
+ * // With a BASE_PATH of 'netbox/'
+ * const url = buildUrl('/api/dcim/devices');
+ * console.log(url);
+ * // => /netbox/api/dcim/devices/
+ * ```
+ *
+ * @param path Relative path _after_ (excluding) the `BASE_PATH`.
+ */
+function buildUrl(destination: string): string {
+  // Separate the path from any URL search params.
+  const [pathname, search] = destination.split(/(?=\?)/g);
+
+  // If the `origin` exists in the API path (as in the case of paginated responses), remove it.
+  const origin = new RegExp(window.location.origin, 'g');
+  const path = pathname.replaceAll(origin, '');
+
+  const basePath = getBasePath();
+
+  // Combine `BASE_PATH` with this request's path, removing _all_ slashes.
+  let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p);
+
+  if (combined[0] !== '/') {
+    // Ensure the URL has a leading slash.
+    combined = ['', ...combined];
+  }
+  if (combined[combined.length - 1] !== '/') {
+    // Ensure the URL has a trailing slash.
+    combined = [...combined, ''];
+  }
+  const url = combined.join('/');
+  // Construct an object from the URL search params so it can be re-serialized with the new URL.
+  const query = Object.fromEntries(new URLSearchParams(search).entries());
+  return queryString.stringifyUrl({ url, query });
+}
+
 export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
-  url: string,
+  path: string,
   method: Method,
   data?: D,
 ): Promise<APIResponse<R>> {
@@ -115,6 +170,7 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
     body = JSON.stringify(data);
     headers.set('content-type', 'application/json');
   }
+  const url = buildUrl(path);
 
   const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
   const contentType = res.headers.get('Content-Type');
@@ -367,8 +423,13 @@ export function createElement<
   // Element props.
   P extends InferredProps<T>,
   // Child element type.
-  C extends HTMLElement = HTMLElement
->(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] {
+  C extends HTMLElement = HTMLElement,
+>(
+  tag: T,
+  properties: P | null,
+  classes: Nullable<string[]> = null,
+  children: C[] = [],
+): HTMLElementTagNameMap[T] {
   // Create the base element.
   const element = document.createElement<T>(tag);
 
@@ -384,7 +445,9 @@ export function createElement<
   }
 
   // Add each CSS class to the element's class list.
-  element.classList.add(...classes);
+  if (classes !== null && classes.length > 0) {
+    element.classList.add(...classes);
+  }
 
   for (const child of children) {
     // Add each child element to the base element.

+ 5 - 0
netbox/project-static/styles/netbox.scss

@@ -956,6 +956,11 @@ div.card-overlay {
   }
 }
 
+// Remove the max-width from image preview popovers as this is controlled on the image element.
+.popover.image-preview-popover {
+  max-width: unset;
+}
+
 #django-messages {
   position: fixed;
   right: $spacer;

+ 3 - 3
netbox/templates/500.html

@@ -4,7 +4,7 @@
 
 <head>
     <title>Server Error</title>
-    <link rel="stylesheet" href="{% static 'netbox.css'%}" />
+    <link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
     <meta charset="UTF-8">
 </head>
 
@@ -12,7 +12,7 @@
     <div class="container-fluid">
         <div class="row">
             <div class="col col-md-6 offset-md-3">
-                <div class="card bg-danger mt-5">
+                <div class="card border-danger mt-5">
                     <h5 class="card-header">
                         <i class="mdi mdi-alert"></i> Server Error
                     </h5>
@@ -32,7 +32,7 @@
 Python version: {{ python_version }}
 NetBox version: {{ netbox_version }}</pre>
                         <p>
-                            If further assistance is required, please post to the <a href="https://groups.google.com/g/netbox-discuss">NetBox mailing list</a>.
+                            If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub.
                         </p>
                         <div class="text-end">
                             <a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>

+ 1 - 0
netbox/templates/base/base.html

@@ -5,6 +5,7 @@
 <html
   lang="en"
   data-netbox-path="{{ request.path }}"
+  data-netbox-base-path="{{ settings.BASE_PATH }}"
   {% if preferences|get_key:'ui.colormode' == 'dark'%}
     data-netbox-color-mode="dark"
   {% else %}

+ 2 - 2
netbox/templates/base/sidenav.html

@@ -7,12 +7,12 @@
   {# Brand #}
 
     {# Full Logo #}
-    <a class="sidenav-brand" href="/">
+    <a class="sidenav-brand" href="{% url 'home' %}">
       <img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
     </a>
 
     {# Icon Logo #}
-    <a class="sidenav-brand-icon" href="/">
+    <a class="sidenav-brand-icon" href="{% url 'home' %}">
       <img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="NetBox Logo">
     </a>
 

+ 3 - 3
netbox/templates/dcim/site.html

@@ -109,8 +109,8 @@
                         <td>
                             {% if object.physical_address %}
                                 <div class="float-end noprint">
-                                    <a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
-                                        <i class="mdi mdi-map-marker"></i> Map it
+                                    <a href="{{ settings.MAPS_URL }}{{ object.physical_address|urlencode }}" target="_blank" class="btn btn-primary btn-sm">
+                                        <i class="mdi mdi-map-marker"></i> Map It
                                     </a>
                                 </div>
                                 <span>{{ object.physical_address|linebreaksbr }}</span>
@@ -129,7 +129,7 @@
                             {% if object.latitude and object.longitude %}
                                 <div class="float-end noprint">
                                     <a href="{{ settings.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
-                                        <i class="mdi mdi-map-marker"></i> Map it
+                                        <i class="mdi mdi-map-marker"></i> Map It
                                     </a>
                                 </div>
                                 <span>{{ object.latitude }}, {{ object.longitude }}</span>

+ 9 - 9
netbox/templates/ipam/ipaddress_assign.html

@@ -9,7 +9,7 @@
   {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
 {% endblock %}
 
-{% block content %}
+{% block form %}
     <form action="{% querystring request %}" method="post" class="form form-horizontal">
         {% csrf_token %}
         {% for field in form.hidden_fields %}
@@ -17,13 +17,10 @@
         {% endfor %}
         <div class="row mb-3">
             <div class="col col-md-8 offset-md-2">
-                {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
-                <div class="card">
-                    <h5 class="card-header">Select IP Address</h5>
-                    <div class="card-body">
-                        {% render_field form.vrf_id %}
-                        {% render_field form.q %}
-                    </div>
+                <div class="field-group">
+                    <h6>Select IP Address</h6>
+                    {% render_field form.vrf_id %}
+                    {% render_field form.q %}
                 </div>
             </div>
         </div>
@@ -42,4 +39,7 @@
             </div>
         </div>
     {% endif %}
-{% endblock %}
+{% endblock form %}
+
+{% block buttons %}
+{% endblock buttons%}

+ 6 - 2
netbox/templates/ipam/prefix/ip_addresses.html

@@ -1,8 +1,10 @@
 {% extends 'ipam/prefix/base.html' %}
+{% load helpers %}
+{% load static %}
 
 {% block extra_controls %}
-  {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-primary">
+  {% if perms.ipam.add_ipaddress and first_available_ip %}
+    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
         <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add IP Address
     </a>
   {% endif %}
@@ -11,7 +13,9 @@
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
+      {% include 'inc/table_controls.html' with table_modal="IPAddressTable_config" %}
       {% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
     </div>
   </div>
+  {% table_config_form table table_name="IPAddressTable" %}
 {% endblock %}

+ 5 - 2
netbox/templates/ipam/prefix/ip_ranges.html

@@ -1,10 +1,13 @@
 {% extends 'ipam/prefix/base.html' %}
-
+{% load helpers %}
+{% load static %}
 
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
-      {% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
+      {% include 'inc/table_controls.html' with table_modal="IPRangeTable_config" %}
+      {% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:iprange_bulk_edit' bulk_delete_url='ipam:iprange_bulk_delete' parent=prefix %}
     </div>
   </div>
+  {% table_config_form table table_name="IPRangeTable" %}
 {% endblock %}

+ 6 - 18
netbox/templates/ipam/prefix/prefixes.html

@@ -2,20 +2,11 @@
 {% load helpers %}
 {% load static %}
 
-{% block buttons %}
+{% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
-  {% if request.user.is_authenticated and table_config_form %}
-      <button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
-  {% endif %}
-  {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
-    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
-      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
-    </a>
-  {% endif %}
-  {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %}
-    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}&vrf={{ object.vrf.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
-      <span class="mdi mdi-plus-thick" aria-hidden="true"></span>
-      Add an IP Address
+  {% if perms.ipam.add_prefix and first_available_prefix %}
+    <a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-sm btn-success">
+      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Prefix
     </a>
   {% endif %}
   {{ block.super }}
@@ -24,12 +15,9 @@
 {% block content %}
   <div class="row">
     <div class="col col-md-12">
+      {% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %}
       {% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
     </div>
   </div>
-  {% table_config_form prefix_table table_name="PrefixDetailTable" %}
-{% endblock %}
-
-{% block javascript %}
-<script src="{% static 'js/tableconfig.js' %}"></script>
+  {% table_config_form table table_name="PrefixDetailTable" %}
 {% endblock %}

+ 1 - 1
netbox/templates/media_failure.html

@@ -42,7 +42,7 @@
                 The file <code>{{ filename }}</code> exists in the static root directory and is readable by the HTTP process.
             </li>
         </ul>
-        <p>Click <a href="/">here</a> to attempt loading NetBox again.</p>
+        <p>Click <a href="{% url 'home' %}">here</a> to attempt loading NetBox again.</p>
     </div>
 </body>
 </html>

+ 1 - 1
netbox/templates/rest_framework/api.html

@@ -9,5 +9,5 @@
 {% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %}
 
 {% block branding %}
-  <a class="navbar-brand" href="/{{ settings.BASE_PATH }}">NetBox</a>
+  <a class="navbar-brand" href="{% url 'home' %}">NetBox</a>
 {% endblock branding %}

+ 27 - 21
netbox/templates/utilities/obj_table.html

@@ -1,40 +1,44 @@
 {% load helpers %}
+
 {% if permissions.change or permissions.delete %}
     <form method="post" class="form form-horizontal">
         {% csrf_token %}
         <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
+
         {% if table.paginator.num_pages > 1 %}
             <div id="select-all-box" class="d-none card noprint">
-                <div class="card-body">
-                    <div class="float-end">
-                        {% if bulk_edit_url and permissions.change %}
-                            <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
-                                <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
-                            </button>
-                        {% endif %}
-                        {% if bulk_delete_url and permissions.delete %}
-                            <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
-                                <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
-                            </button>
-                        {% endif %}
-                    </div>
-                    <div class="form-check">
-                      <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
-                      <label for="select-all" class="form-check-label">
-                        Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
-                      </label>
-                    </div>
+                <div class="float-end">
+                    {% if bulk_edit_url and permissions.change %}
+                        <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
+                            <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
+                        </button>
+                    {% endif %}
+                    {% if bulk_delete_url and permissions.delete %}
+                        <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
+                            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
+                        </button>
+                    {% endif %}
+                </div>
+                <div class="form-check">
+                    <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
+                    <label for="select-all" class="form-check-label">
+                    Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                    </label>
                 </div>
             </div>
         {% endif %}
+
         {% include table_template|default:'inc/responsive_table.html' %}
+
         <div class="float-start noprint">
             {% block extra_actions %}{% endblock %}
+
             {% if bulk_edit_url and permissions.change %}
                 <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
                 </button>
             {% endif %}
+
             {% if bulk_delete_url and permissions.delete %}
                 <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
@@ -43,7 +47,9 @@
         </div>
     </form>
 {% else %}
+
     {% include table_template|default:'inc/responsive_table.html' %}
+
 {% endif %}
-    {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
-<div class="clearfix"></div>
+
+{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}

+ 5 - 5
netbox/templates/utilities/templatetags/table_config_form.html

@@ -7,11 +7,11 @@
         <h5 class="modal-title">Table Configuration</h5>
         <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
       </div>
-      <form class="form-horizontal userconfigform" data-config-root="tables.{{ table_config_form.table_name }}">
+      <form class="form-horizontal userconfigform" data-config-root="tables.{{ form.table_name }}">
         <div class="modal-body row">
           <div class="col-5 text-center">
-            {{ table_config_form.available_columns.label }}
-            {{ table_config_form.available_columns }}
+            {{ form.available_columns.label }}
+            {{ form.available_columns }}
           </div>
           <div class="col-2 d-flex align-items-center">
             <div>
@@ -24,8 +24,8 @@
             </div>
           </div>
           <div class="col-5 text-center">
-            {{ table_config_form.columns.label }}
-            {{ table_config_form.columns }}
+            {{ form.columns.label }}
+            {{ form.columns }}
             <a class="btn btn-primary btn-sm mt-2" id="move-option-up" data-target="id_columns">
                 <i class="mdi mdi-arrow-up-bold"></i> Move Up
             </a>

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -131,7 +131,7 @@
                         <th scope="row"><i class="mdi mdi-chip"></i> Memory</th>
                         <td>
                             {% if object.memory %}
-                                {{ object.memory|humanize_megabytes }} MB
+                                {{ object.memory|humanize_megabytes }}
                             {% else %}
                                 <span class="text-muted">&mdash;</span>
                             {% endif %}

+ 2 - 1
netbox/utilities/api.py

@@ -48,7 +48,8 @@ def is_api_request(request):
     Return True of the request is being made via the REST API.
     """
     api_path = reverse('api-root')
-    return request.path_info.startswith(api_path)
+
+    return request.path_info.startswith(api_path) and request.content_type == 'application/json'
 
 
 def get_view_name(view, suffix=None):

+ 1 - 1
netbox/utilities/forms/fields.py

@@ -435,7 +435,7 @@ class DynamicModelChoiceMixin:
             filter = self.filter(field_name=field_name)
             try:
                 self.queryset = filter.filter(self.queryset, data)
-            except TypeError:
+            except (TypeError, ValueError):
                 # Catch any error caused by invalid initial data passed from the user
                 self.queryset = self.queryset.none()
         else:

+ 3 - 2
netbox/utilities/forms/utils.py

@@ -119,13 +119,14 @@ def get_selected_values(form, field_name):
     """
     if not hasattr(form, 'cleaned_data'):
         form.is_valid()
+    filter_data = form.cleaned_data.get(field_name)
 
     # Selection field
     if hasattr(form.fields[field_name], 'choices'):
         try:
             choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
             return [
-                label for value, label in choices.items() if value in form.cleaned_data[field_name]
+                label for value, label in choices.items() if str(value) in filter_data
             ]
         except TypeError:
             # Field uses dynamic choices. Show all that have been populated.
@@ -134,7 +135,7 @@ def get_selected_values(form, field_name):
             ]
 
     # Non-selection field
-    return [str(form.cleaned_data[field_name])]
+    return [str(filter_data)]
 
 
 def add_blank_choice(choices):

+ 2 - 2
netbox/utilities/forms/widgets.py

@@ -185,7 +185,7 @@ class APISelect(SelectWithDisabled):
                 # layer.
                 if key in self.static_params:
                     current = self.static_params[key]
-                    self.static_params[key] = [*current, value]
+                    self.static_params[key] = [v for v in set([*current, value])]
                 else:
                     self.static_params[key] = [value]
         else:
@@ -194,7 +194,7 @@ class APISelect(SelectWithDisabled):
             # `$`).
             if key in self.static_params:
                 current = self.static_params[key]
-                self.static_params[key] = [*current, value]
+                self.static_params[key] = [v for v in set([*current, value])]
             else:
                 self.static_params[key] = [value]
 

+ 2 - 2
netbox/utilities/management/commands/makemigrations.py

@@ -21,8 +21,8 @@ class Command(_Command):
             raise CommandError(
                 "This command is available for development purposes only. It will\n"
                 "NOT resolve any issues with missing or unapplied migrations. For assistance,\n"
-                "please post to the NetBox mailing list:\n"
-                "    https://groups.google.com/g/netbox-discuss"
+                "please post to the NetBox discussion forum on GitHub:\n"
+                "    https://github.com/netbox-community/netbox/discussions"
             )
 
         super().handle(*args, **kwargs)

+ 8 - 4
netbox/utilities/paginator.py

@@ -49,21 +49,25 @@ class EnhancedPage(Page):
 
 def get_paginate_count(request):
     """
-    Determine the length of a page, using the following in order:
+    Determine the desired length of a page, using the following in order:
 
         1. per_page URL query parameter
         2. Saved user preference
         3. PAGINATE_COUNT global setting.
+
+    Return the lesser of the calculated value and MAX_PAGE_SIZE.
     """
     if 'per_page' in request.GET:
         try:
             per_page = int(request.GET.get('per_page'))
             if request.user.is_authenticated:
                 request.user.config.set('pagination.per_page', per_page, commit=True)
-            return per_page
+            return min(per_page, settings.MAX_PAGE_SIZE)
         except ValueError:
             pass
 
     if request.user.is_authenticated:
-        return request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
-    return settings.PAGINATE_COUNT
+        per_page = request.user.config.get('pagination.per_page', settings.PAGINATE_COUNT)
+        return min(per_page, settings.MAX_PAGE_SIZE)
+
+    return min(settings.PAGINATE_COUNT, settings.MAX_PAGE_SIZE)

+ 4 - 0
netbox/utilities/tables.py

@@ -237,9 +237,13 @@ class ContentTypeColumn(tables.Column):
     Display a ContentType instance.
     """
     def render(self, value):
+        if value is None:
+            return None
         return content_type_name(value)
 
     def value(self, value):
+        if value is None:
+            return None
         return f"{value.app_label}.{value.model}"
 
 

+ 26 - 26
netbox/utilities/templatetags/helpers.py

@@ -304,28 +304,6 @@ def get_item(value: object, attr: str) -> Any:
     return value[attr]
 
 
-#
-# Tags
-#
-
-@register.simple_tag()
-def querystring(request, **kwargs):
-    """
-    Append or update the page number in a querystring.
-    """
-    querydict = request.GET.copy()
-    for k, v in kwargs.items():
-        if v is not None:
-            querydict[k] = str(v)
-        elif k in querydict:
-            querydict.pop(k)
-    querystring = querydict.urlencode(safe='/')
-    if querystring:
-        return '?' + querystring
-    else:
-        return ''
-
-
 @register.filter
 def status_from_tag(tag: str = "info") -> str:
     """
@@ -355,6 +333,28 @@ def icon_from_status(status: str = "info") -> str:
     return icon_map.get(status.lower(), 'information')
 
 
+#
+# Tags
+#
+
+@register.simple_tag()
+def querystring(request, **kwargs):
+    """
+    Append or update the page number in a querystring.
+    """
+    querydict = request.GET.copy()
+    for k, v in kwargs.items():
+        if v is not None:
+            querydict[k] = str(v)
+        elif k in querydict:
+            querydict.pop(k)
+    querystring = querydict.urlencode(safe='/')
+    if querystring:
+        return '?' + querystring
+    else:
+        return ''
+
+
 @register.inclusion_tag('utilities/templatetags/utilization_graph.html')
 def utilization_graph(utilization, warning_threshold=75, danger_threshold=90):
     """
@@ -401,7 +401,7 @@ def badge(value, bg_class='secondary', show_empty=False):
 def table_config_form(table, table_name=None):
     return {
         'table_name': table_name or table.__class__.__name__,
-        'table_config_form': TableConfigForm(table=table),
+        'form': TableConfigForm(table=table),
     }
 
 
@@ -411,16 +411,16 @@ def applied_filters(form, query_params):
     Display the active filters for a given filter form.
     """
     form.is_valid()
+    querydict = query_params.copy()
 
     applied_filters = []
     for filter_name in form.changed_data:
-        if filter_name not in query_params:
+        if filter_name not in querydict:
             continue
 
         bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
-        querydict = query_params.copy()
         querydict.pop(filter_name)
-        display_value = ', '.join(get_selected_values(form, filter_name))
+        display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
 
         applied_filters.append({
             'name': filter_name,

+ 47 - 2
netbox/utilities/utils.py

@@ -1,7 +1,9 @@
 import datetime
 import json
+import urllib
 from collections import OrderedDict
 from itertools import count, groupby
+from typing import Any, Dict, List, Tuple
 
 from django.core.serializers import serialize
 from django.db.models import Count, OuterRef, Subquery
@@ -286,6 +288,45 @@ def flatten_dict(d, prefix='', separator='.'):
     return ret
 
 
+def decode_dict(encoded_dict: Dict, *, decode_keys: bool = True) -> Dict:
+    """
+    Recursively URL decode string keys and values of a dict.
+
+    For example, `{'1%2F1%2F1': {'1%2F1%2F2': ['1%2F1%2F3', '1%2F1%2F4']}}` would
+    become: `{'1/1/1': {'1/1/2': ['1/1/3', '1/1/4']}}`
+
+    :param encoded_dict: Dictionary to be decoded.
+    :param decode_keys: (Optional) Enable/disable decoding of dict keys.
+    """
+
+    def decode_value(value: Any, _decode_keys: bool) -> Any:
+        """
+        Handle URL decoding of any supported value type.
+        """
+        # Decode string values.
+        if isinstance(value, str):
+            return urllib.parse.unquote(value)
+        # Recursively decode each list item.
+        elif isinstance(value, list):
+            return [decode_value(v, _decode_keys) for v in value]
+        # Recursively decode each tuple item.
+        elif isinstance(value, Tuple):
+            return tuple(decode_value(v, _decode_keys) for v in value)
+        # Recursively decode each dict key/value pair.
+        elif isinstance(value, dict):
+            # Don't decode keys, if `decode_keys` is false.
+            if not _decode_keys:
+                return {k: decode_value(v, _decode_keys) for k, v in value.items()}
+            return {urllib.parse.unquote(k): decode_value(v, _decode_keys) for k, v in value.items()}
+        return value
+
+    if not decode_keys:
+        # Don't decode keys, if `decode_keys` is false.
+        return {k: decode_value(v, decode_keys) for k, v in encoded_dict.items()}
+
+    return {urllib.parse.unquote(k): decode_value(v, decode_keys) for k, v in encoded_dict.items()}
+
+
 # Taken from django.utils.functional (<3.0)
 def curry(_curried_func, *args, **kwargs):
     def _curried(*moreargs, **morekwargs):
@@ -307,8 +348,12 @@ def content_type_name(contenttype):
     """
     Return a proper ContentType name.
     """
-    meta = contenttype.model_class()._meta
-    return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
+    try:
+        meta = contenttype.model_class()._meta
+        return f'{meta.app_config.verbose_name} > {meta.verbose_name}'
+    except AttributeError:
+        # Model no longer exists
+        return f'{contenttype.app_label} > {contenttype.model}'
 
 
 #

+ 1 - 1
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.2.6
+Django==3.2.7
 django-cors-headers==3.8.0
 django-debug-toolbar==3.2.2
 django-filter==2.4.0

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor