Sfoglia il codice sorgente

Merge pull request #8640 from netbox-community/develop

Release v3.1.8
Jeremy Stretch 4 anni fa
parent
commit
90f91eeea4
39 ha cambiato i file con 294 aggiunte e 92 eliminazioni
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 14 2
      .github/workflows/ci.yml
  4. 0 7
      CONTRIBUTING.md
  5. 0 1
      README.md
  6. 2 2
      docs/configuration/remote-authentication.md
  7. 1 2
      docs/development/index.md
  8. 0 4
      docs/installation/index.md
  9. 24 0
      docs/release-notes/version-3.1.md
  10. 7 1
      netbox/dcim/forms/bulk_edit.py
  11. 10 4
      netbox/dcim/svg.py
  12. 2 0
      netbox/dcim/tables/template_code.py
  13. 5 0
      netbox/extras/filtersets.py
  14. 14 8
      netbox/extras/forms/bulk_edit.py
  15. 2 3
      netbox/extras/forms/filtersets.py
  16. 15 2
      netbox/extras/management/commands/nbshell.py
  17. 14 1
      netbox/extras/tables.py
  18. 12 2
      netbox/extras/tests/test_filtersets.py
  19. 2 1
      netbox/extras/views.py
  20. 12 3
      netbox/netbox/config/parameters.py
  21. 2 2
      netbox/netbox/constants.py
  22. 1 1
      netbox/netbox/settings.py
  23. 1 1
      netbox/netbox/views/__init__.py
  24. 0 0
      netbox/project-static/dist/config.js.map
  25. 0 0
      netbox/project-static/dist/lldp.js.map
  26. 0 0
      netbox/project-static/dist/netbox.js
  27. 0 0
      netbox/project-static/dist/netbox.js.map
  28. 0 0
      netbox/project-static/dist/status.js
  29. 0 0
      netbox/project-static/dist/status.js.map
  30. 12 9
      netbox/project-static/src/select/api/apiSelect.ts
  31. 32 20
      netbox/project-static/src/tableConfig.ts
  32. 3 3
      netbox/project-static/src/tables/interfaceTable.ts
  33. 53 3
      netbox/project-static/src/util.ts
  34. 3 2
      netbox/templates/dcim/device.html
  35. 5 1
      netbox/templates/extras/objectchange.html
  36. 39 0
      netbox/utilities/tables.py
  37. 1 1
      netbox/utilities/utils.py
  38. 1 1
      netbox/virtualization/forms/bulk_import.py
  39. 3 3
      requirements.txt

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

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

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

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

+ 14 - 2
.github/workflows/ci.yml

@@ -38,6 +38,19 @@ jobs:
       uses: actions/setup-node@v2
       uses: actions/setup-node@v2
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
+    
+    - name: Install Yarn Package Manager
+      run: npm install -g yarn
+    
+    - name: Setup Node.js with Yarn Caching
+      uses: actions/setup-node@v2
+      with:
+        node-version: ${{ matrix.node-version }}
+        cache: yarn
+        cache-dependency-path: netbox/project-static/yarn.lock
+    
+    - name: Install Frontend Dependencies
+      run: yarn --cwd netbox/project-static
 
 
     - name: Install dependencies & set up configuration
     - name: Install dependencies & set up configuration
       run: |
       run: |
@@ -45,7 +58,6 @@ jobs:
         pip install -r requirements.txt
         pip install -r requirements.txt
         pip install pycodestyle coverage
         pip install pycodestyle coverage
         ln -s configuration.testing.py netbox/netbox/configuration.py
         ln -s configuration.testing.py netbox/netbox/configuration.py
-        yarn --cwd netbox/project-static
 
 
     - name: Build documentation
     - name: Build documentation
       run: mkdocs build
       run: mkdocs build
@@ -63,7 +75,7 @@ jobs:
       run: scripts/verify-bundles.sh
       run: scripts/verify-bundles.sh
 
 
     - name: Run tests
     - name: Run tests
-      run: coverage run --source="netbox/" netbox/manage.py test netbox/
+      run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
 
 
     - name: Show coverage report
     - name: Show coverage report
       run: coverage report --skip-covered --omit *migrations*
       run: coverage report --skip-covered --omit *migrations*

+ 0 - 7
CONTRIBUTING.md

@@ -16,13 +16,6 @@ categories for discussions:
   feature request
   feature request
 * **Q&A** - Request help with installing or using NetBox
 * **Q&A** - Request help with installing or using NetBox
 
 
-### Mailing List
-
-We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
-for general discussion, however we're encouraging people to use GitHub
-discussions where possible, as it's much easier for newcomers to review past
-discussions.
-
 ### Slack
 ### Slack
 
 
 For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
 For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).

+ 0 - 1
README.md

@@ -68,7 +68,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
 
 
 * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
 * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
 * [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
 * [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
-* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
 
 
 ### Installation
 ### Installation
 
 

+ 2 - 2
docs/configuration/remote-authentication.md

@@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
 
 
 Default: `{}` (Empty dictionary)
 Default: `{}` (Empty dictionary)
 
 
-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`.)
+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` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
 
 
 ---
 ---
 
 
@@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
 
 
 Default: `False`
 Default: `False`
 
 
-NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
+NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
 
 
 ---
 ---
 
 

+ 1 - 2
docs/development/index.md

@@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
 There are several official forums for communication among the developers and community members:
 There are several official forums for communication among the developers and community members:
 
 
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
 * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
-* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
+* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
 * [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
 * [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
-* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
 
 
 ## Governance
 ## Governance
 
 

+ 0 - 4
docs/installation/index.md

@@ -11,10 +11,6 @@ The following sections detail how to set up a new instance of NetBox:
 5. [HTTP server](5-http-server.md)
 5. [HTTP server](5-http-server.md)
 6. [LDAP authentication](6-ldap.md) (optional)
 6. [LDAP authentication](6-ldap.md) (optional)
 
 
-The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
-
-<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
-
 ## Requirements
 ## Requirements
 
 
 | Dependency | Minimum Version |
 | Dependency | Minimum Version |

+ 24 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,29 @@
 # NetBox v3.1
 # NetBox v3.1
 
 
+## v3.1.8 (2022-02-15)
+
+### Enhancements
+
+* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
+* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
+* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
+* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
+
+### Bug Fixes
+
+* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
+* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
+* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
+* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
+* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
+* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
+* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
+* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
+* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
+* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
+
+---
+
 ## v3.1.7 (2022-02-03)
 ## v3.1.7 (2022-02-03)
 
 
 ### Enhancements
 ### Enhancements

+ 7 - 1
netbox/dcim/forms/bulk_edit.py

@@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
+        if not self.cleaned_data['mode']:
+            if self.cleaned_data['untagged_vlan']:
+                raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
+            elif self.cleaned_data['tagged_vlans']:
+                raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
+
         # Untagged interfaces cannot be assigned tagged VLANs
         # Untagged interfaces cannot be assigned tagged VLANs
-        if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
+        elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
             raise forms.ValidationError({
             raise forms.ValidationError({
                 'mode': "An access interface cannot have tagged VLANs assigned."
                 'mode': "An access interface cannot have tagged VLANs assigned."
             })
             })

+ 10 - 4
netbox/dcim/svg.py

@@ -126,10 +126,16 @@ class RackElevationSVG:
             link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
             link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
 
 
     def _draw_device_rear(self, drawing, device, start, end, text):
     def _draw_device_rear(self, drawing, device, start, end, text):
-        rect = drawing.rect(start, end, class_="slot blocked")
-        rect.set_desc(self._get_device_description(device))
-        drawing.add(rect)
-        drawing.add(drawing.text(get_device_name(device), insert=text))
+        link = drawing.add(
+            drawing.a(
+                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
+                target='_top',
+                fill='black'
+            )
+        )
+        link.set_desc(self._get_device_description(device))
+        link.add(drawing.rect(start, end, class_="slot blocked"))
+        link.add(drawing.text(get_device_name(device), insert=text))
 
 
         # Embed rear device type image if one exists
         # Embed rear device type image if one exists
         if self.include_images and device.device_type.rear_image:
         if self.include_images and device.device_type.rear_image:

+ 2 - 0
netbox/dcim/tables/template_code.py

@@ -298,6 +298,8 @@ REARPORT_BUTTONS = """
             </button>
             </button>
             <ul class="dropdown-menu dropdown-menu-end">
             <ul class="dropdown-menu dropdown-menu-end">
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
+                <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
                 <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>

+ 5 - 0
netbox/extras/filtersets.py

@@ -317,6 +317,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Tenant (slug)',
         label='Tenant (slug)',
     )
     )
+    tag_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tags',
+        queryset=Tag.objects.all(),
+        label='Tag',
+    )
     tag = django_filters.ModelMultipleChoiceFilter(
     tag = django_filters.ModelMultipleChoiceFilter(
         field_name='tags__slug',
         field_name='tags__slug',
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),

+ 14 - 8
netbox/extras/forms/bulk_edit.py

@@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
 from extras.choices import *
 from extras.choices import *
 from extras.models import *
 from extras.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
-from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
+from utilities.forms import (
+    add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
+)
 
 
 __all__ = (
 __all__ = (
     'ConfigContextBulkEditForm',
     'ConfigContextBulkEditForm',
@@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
         required=False
         required=False
     )
     )
     button_class = forms.ChoiceField(
     button_class = forms.ChoiceField(
-        choices=CustomLinkButtonClassChoices,
+        choices=add_blank_choice(CustomLinkButtonClassChoices),
         required=False,
         required=False,
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
@@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
         widget=BulkEditNullBooleanSelect()
         widget=BulkEditNullBooleanSelect()
     )
     )
     http_method = forms.ChoiceField(
     http_method = forms.ChoiceField(
-        choices=WebhookHttpMethodChoices,
-        required=False
+        choices=add_blank_choice(WebhookHttpMethodChoices),
+        required=False,
+        label='HTTP method'
     )
     )
     payload_url = forms.CharField(
     payload_url = forms.CharField(
-        required=False
+        required=False,
+        label='Payload URL'
     )
     )
     ssl_verification = forms.NullBooleanField(
     ssl_verification = forms.NullBooleanField(
         required=False,
         required=False,
-        widget=BulkEditNullBooleanSelect()
+        widget=BulkEditNullBooleanSelect(),
+        label='SSL verification'
     )
     )
     secret = forms.CharField(
     secret = forms.CharField(
         required=False
         required=False
     )
     )
     ca_file_path = forms.CharField(
     ca_file_path = forms.CharField(
-        required=False
+        required=False,
+        label='CA file path'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
         widget=forms.MultipleHiddenInput
         widget=forms.MultipleHiddenInput
     )
     )
     kind = forms.ChoiceField(
     kind = forms.ChoiceField(
-        choices=JournalEntryKindChoices,
+        choices=add_blank_choice(JournalEntryKindChoices),
         required=False
         required=False
     )
     )
     comments = forms.CharField(
     comments = forms.CharField(

+ 2 - 3
netbox/extras/forms/filtersets.py

@@ -155,7 +155,7 @@ class TagFilterForm(FilterForm):
 
 
 class ConfigContextFilterForm(FilterForm):
 class ConfigContextFilterForm(FilterForm):
     field_groups = [
     field_groups = [
-        ['q', 'tag'],
+        ['q', 'tag_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['region_id', 'site_group_id', 'site_id'],
         ['device_type_id', 'platform_id', 'role_id'],
         ['device_type_id', 'platform_id', 'role_id'],
         ['cluster_group_id', 'cluster_id'],
         ['cluster_group_id', 'cluster_id'],
@@ -211,9 +211,8 @@ class ConfigContextFilterForm(FilterForm):
         required=False,
         required=False,
         label=_('Tenant')
         label=_('Tenant')
     )
     )
-    tag = DynamicModelMultipleChoiceField(
+    tag_id = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),
-        to_field_name='slug',
         required=False,
         required=False,
         label=_('Tags')
         label=_('Tags')
     )
     )

+ 15 - 2
netbox/extras/management/commands/nbshell.py

@@ -70,10 +70,23 @@ class Command(BaseCommand):
         return namespace
         return namespace
 
 
     def handle(self, **options):
     def handle(self, **options):
+        namespace = self.get_namespace()
+
         # If Python code has been passed, execute it and exit.
         # If Python code has been passed, execute it and exit.
         if options['command']:
         if options['command']:
-            exec(options['command'], self.get_namespace())
+            exec(options['command'], namespace)
             return
             return
 
 
-        shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
+        # Try to enable tab-complete
+        try:
+            import readline
+            import rlcompleter
+        except ModuleNotFoundError:
+            pass
+        else:
+            readline.set_completer(rlcompleter.Completer(namespace).complete)
+            readline.parse_and_bind('tab: complete')
+
+        # Run interactive shell
+        shell = code.interact(banner=BANNER_TEXT, local=namespace)
         return shell
         return shell

+ 14 - 1
netbox/extras/tables.py

@@ -29,6 +29,11 @@ CONFIGCONTEXT_ACTIONS = """
 {% endif %}
 {% endif %}
 """
 """
 
 
+OBJECTCHANGE_FULL_NAME = """
+{% load helpers %}
+{{ record.user.get_full_name|placeholder }}
+"""
+
 OBJECTCHANGE_OBJECT = """
 OBJECTCHANGE_OBJECT = """
 {% if record.changed_object and record.changed_object.get_absolute_url %}
 {% if record.changed_object and record.changed_object.get_absolute_url %}
     <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
     <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
@@ -204,6 +209,14 @@ class ObjectChangeTable(BaseTable):
         linkify=True,
         linkify=True,
         format=settings.SHORT_DATETIME_FORMAT
         format=settings.SHORT_DATETIME_FORMAT
     )
     )
+    user_name = tables.Column(
+        verbose_name='Username'
+    )
+    full_name = tables.TemplateColumn(
+        template_code=OBJECTCHANGE_FULL_NAME,
+        verbose_name='Full Name',
+        orderable=False
+    )
     action = ChoiceFieldColumn()
     action = ChoiceFieldColumn()
     changed_object_type = ContentTypeColumn(
     changed_object_type = ContentTypeColumn(
         verbose_name='Type'
         verbose_name='Type'
@@ -219,7 +232,7 @@ class ObjectChangeTable(BaseTable):
 
 
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = ObjectChange
         model = ObjectChange
-        fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
+        fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
 
 
 
 
 class ObjectJournalTable(BaseTable):
 class ObjectJournalTable(BaseTable):

+ 12 - 2
netbox/extras/tests/test_filtersets.py

@@ -12,7 +12,7 @@ from extras.filtersets import *
 from extras.models import *
 from extras.models import *
 from ipam.models import IPAddress
 from ipam.models import IPAddress
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
-from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
+from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
 
 
@@ -429,6 +429,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Tenant.objects.bulk_create(tenants)
         Tenant.objects.bulk_create(tenants)
 
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         for i in range(0, 3):
         for i in range(0, 3):
             is_active = bool(i % 2)
             is_active = bool(i % 2)
             c = ConfigContext.objects.create(
             c = ConfigContext.objects.create(
@@ -446,6 +448,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             c.clusters.set([clusters[i]])
             c.clusters.set([clusters[i]])
             c.tenant_groups.set([tenant_groups[i]])
             c.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[i]])
             c.tenants.set([tenants[i]])
+            c.tags.set([tags[i]])
 
 
     def test_name(self):
     def test_name(self):
         params = {'name': ['Config Context 1', 'Config Context 2']}
         params = {'name': ['Config Context 1', 'Config Context 2']}
@@ -516,13 +519,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
-    def test_tenant_(self):
+    def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+    def test_tags(self):
+        tags = Tag.objects.all()[:2]
+        params = {'tag_id': [tags[0].pk, tags[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tag': [tags[0].slug, tags[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
 
 
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
 class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Tag.objects.all()
     queryset = Tag.objects.all()

+ 2 - 1
netbox/extras/views.py

@@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
         )
         )
         objectchanges_table = tables.ObjectChangeTable(
         objectchanges_table = tables.ObjectChangeTable(
             data=objectchanges,
             data=objectchanges,
-            orderable=False
+            orderable=False,
+            user=request.user
         )
         )
         paginate_table(objectchanges_table, request)
         paginate_table(objectchanges_table, request)
 
 

+ 12 - 3
netbox/netbox/config/parameters.py

@@ -20,19 +20,28 @@ PARAMS = (
         name='BANNER_LOGIN',
         name='BANNER_LOGIN',
         label='Login banner',
         label='Login banner',
         default='',
         default='',
-        description="Additional content to display on the login page"
+        description="Additional content to display on the login page",
+        field_kwargs={
+            'widget': forms.Textarea(),
+        },
     ),
     ),
     ConfigParam(
     ConfigParam(
         name='BANNER_TOP',
         name='BANNER_TOP',
         label='Top banner',
         label='Top banner',
         default='',
         default='',
-        description="Additional content to display at the top of every page"
+        description="Additional content to display at the top of every page",
+        field_kwargs={
+            'widget': forms.Textarea(),
+        },
     ),
     ),
     ConfigParam(
     ConfigParam(
         name='BANNER_BOTTOM',
         name='BANNER_BOTTOM',
         label='Bottom banner',
         label='Bottom banner',
         default='',
         default='',
-        description="Additional content to display at the bottom of every page"
+        description="Additional content to display at the bottom of every page",
+        field_kwargs={
+            'widget': forms.Textarea(),
+        },
     ),
     ),
 
 
     # IPAM
     # IPAM

+ 2 - 2
netbox/netbox/constants.py

@@ -18,7 +18,7 @@ from ipam.filtersets import (
 from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
 from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
 from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 from tenancy.filtersets import ContactFilterSet, TenantFilterSet
 from tenancy.filtersets import ContactFilterSet, TenantFilterSet
-from tenancy.models import Contact, Tenant
+from tenancy.models import Contact, Tenant, ContactAssignment
 from tenancy.tables import ContactTable, TenantTable
 from tenancy.tables import ContactTable, TenantTable
 from utilities.utils import count_related
 from utilities.utils import count_related
 from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
 from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
@@ -186,7 +186,7 @@ SEARCH_TYPES = OrderedDict((
         'url': 'tenancy:tenant_list',
         'url': 'tenancy:tenant_list',
     }),
     }),
     ('contact', {
     ('contact', {
-        'queryset': Contact.objects.prefetch_related('group', 'assignments'),
+        'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
         'filterset': ContactFilterSet,
         'filterset': ContactFilterSet,
         'table': ContactTable,
         'table': ContactTable,
         'url': 'tenancy:contact_list',
         'url': 'tenancy:contact_list',

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '3.1.7'
+VERSION = '3.1.8'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()

+ 1 - 1
netbox/netbox/views/__init__.py

@@ -133,7 +133,7 @@ class HomeView(View):
         changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
         changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
             'user', 'changed_object_type'
             'user', 'changed_object_type'
         )[:10]
         )[:10]
-        changelog_table = ObjectChangeTable(changelog)
+        changelog_table = ObjectChangeTable(changelog, user=request.user)
 
 
         # Check whether a new release is available. (Only for staff/superusers.)
         # Check whether a new release is available. (Only for staff/superusers.)
         new_release = None
         new_release = None

File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/config.js.map


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/lldp.js.map


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox.js.map


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/status.js


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 12 - 9
netbox/project-static/src/select/api/apiSelect.ts

@@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
 import { isStaticParams, isOption } from './types';
 import { isStaticParams, isOption } from './types';
 import {
 import {
   hasMore,
   hasMore,
-  isTruthy,
   hasError,
   hasError,
-  getElement,
+  isTruthy,
   getApiData,
   getApiData,
+  getElement,
   isApiError,
   isApiError,
+  replaceAll,
   createElement,
   createElement,
   uniqueByProperty,
   uniqueByProperty,
   findFirstAdjacent,
   findFirstAdjacent,
@@ -461,7 +462,7 @@ export class APISelect {
       // Set any primitive k/v pairs as data attributes on each option.
       // Set any primitive k/v pairs as data attributes on each option.
       for (const [k, v] of Object.entries(result)) {
       for (const [k, v] of Object.entries(result)) {
         if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
         if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
-          const key = k.replaceAll('_', '-');
+          const key = replaceAll(k, '_', '-');
           data[key] = String(v);
           data[key] = String(v);
         }
         }
         // Set option to disabled if the result contains a matching key and is truthy.
         // Set option to disabled if the result contains a matching key and is truthy.
@@ -659,7 +660,7 @@ export class APISelect {
     for (const [key, value] of this.pathValues.entries()) {
     for (const [key, value] of this.pathValues.entries()) {
       for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
       for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         if (isTruthy(value)) {
         if (isTruthy(value)) {
-          url = url.replaceAll(result[1], value.toString());
+          url = replaceAll(url, result[1], value.toString());
         }
         }
       }
       }
     }
     }
@@ -741,7 +742,7 @@ export class APISelect {
    * @param id DOM ID of the other element.
    * @param id DOM ID of the other element.
    */
    */
   private updatePathValues(id: string): void {
   private updatePathValues(id: string): void {
-    const key = id.replaceAll(/^id_/gi, '');
+    const key = replaceAll(id, /^id_/i, '');
     const element = getElement<HTMLSelectElement>(`id_${key}`);
     const element = getElement<HTMLSelectElement>(`id_${key}`);
     if (element !== null) {
     if (element !== null) {
       // If this element's URL contains Django template tags ({{), replace the template tag
       // If this element's URL contains Django template tags ({{), replace the template tag
@@ -919,16 +920,18 @@ export class APISelect {
         style.setAttribute('data-netbox', id);
         style.setAttribute('data-netbox', id);
 
 
         // Scope the CSS to apply both the list item and the selected item.
         // Scope the CSS to apply both the list item and the selected item.
-        style.innerHTML = `
+        style.innerHTML = replaceAll(
+          `
   div.ss-values div.ss-value[data-id="${id}"],
   div.ss-values div.ss-value[data-id="${id}"],
   div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
   div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
    {
    {
     background-color: ${bg} !important;
     background-color: ${bg} !important;
     color: ${fg} !important;
     color: ${fg} !important;
   }
   }
-              `
-          .replaceAll('\n', '')
-          .trim();
+              `,
+          '\n',
+          '',
+        ).trim();
 
 
         // Add the style element to the DOM.
         // Add the style element to the DOM.
         document.head.appendChild(style);
         document.head.appendChild(style);

+ 32 - 20
netbox/project-static/src/tableConfig.ts

@@ -11,15 +11,6 @@ function saveTableConfig(): void {
   }
   }
 }
 }
 
 
-/**
- * Delete all selected columns, which reverts the user's preferences to the default column set.
- */
-function resetTableConfig(): void {
-  for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
-    element.value = '';
-  }
-}
-
 /**
 /**
  * Add columns to the table config select element.
  * Add columns to the table config select element.
  */
  */
@@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
 /**
 /**
  * Submit form configuration to the NetBox API.
  * Submit form configuration to the NetBox API.
  */
  */
-async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
+async function submitFormConfig(
+  url: string,
+  formConfig: Dict<Dict>,
+): Promise<APIResponse<APIUserConfig>> {
   return await apiPatch<APIUserConfig>(url, formConfig);
   return await apiPatch<APIUserConfig>(url, formConfig);
 }
 }
 
 
@@ -70,25 +64,46 @@ function handleSubmit(event: Event): void {
   const url = element.getAttribute('data-url');
   const url = element.getAttribute('data-url');
   if (url == null) {
   if (url == null) {
     const toast = createToast(
     const toast = createToast(
-        'danger',
-        'Error Updating Table Configuration',
-        'No API path defined for configuration form.'
+      'danger',
+      'Error Updating Table Configuration',
+      'No API path defined for configuration form.',
     );
     );
     toast.show();
     toast.show();
     return;
     return;
   }
   }
 
 
+  // Determine if the form action is to reset the table config.
+  const reset = document.activeElement?.getAttribute('value') === 'Reset';
+
+  // Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
+  // ['tables', 'DevicePowerOutletTable']
+  const path = element.getAttribute('data-config-root')?.split('.') ?? [];
+
+  if (reset) {
+    // If we're resetting the table config, create an empty object for this table. E.g.
+    // tables.PlatformTable becomes {tables: PlatformTable: {}}
+    const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
+
+    // Submit the reset for configuration to the API.
+    submitFormConfig(url, data).then(res => {
+      if (hasError(res)) {
+        const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
+        toast.show();
+      } else {
+        location.reload();
+      }
+    });
+    return;
+  }
+
   // Get all the selected options from any select element in the form.
   // Get all the selected options from any select element in the form.
-  const options = getSelectedOptions(element);
+  const options = getSelectedOptions(element, 'select[name=columns]');
 
 
   // Create an object mapping the select element's name to all selected options for that element.
   // Create an object mapping the select element's name to all selected options for that element.
   const formData: Dict<Dict<string>> = Object.assign(
   const formData: Dict<Dict<string>> = Object.assign(
     {},
     {},
     ...options.map(opt => ({ [opt.name]: opt.options })),
     ...options.map(opt => ({ [opt.name]: opt.options })),
   );
   );
-  // Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
-  // ['tables', 'DevicePowerOutletTable']
-  const path = element.getAttribute('data-config-root')?.split('.') ?? [];
 
 
   // Create an object mapping the configuration path to the select element names, which contain the
   // Create an object mapping the configuration path to the select element names, which contain the
   // selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
   // selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
@@ -112,9 +127,6 @@ export function initTableConfig(): void {
   for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
   for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
     element.addEventListener('click', saveTableConfig);
     element.addEventListener('click', saveTableConfig);
   }
   }
-  for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
-    element.addEventListener('click', resetTableConfig);
-  }
   for (const element of getElements<HTMLButtonElement>('#add_columns')) {
   for (const element of getElements<HTMLButtonElement>('#add_columns')) {
     element.addEventListener('click', addColumns);
     element.addEventListener('click', addColumns);
   }
   }

+ 3 - 3
netbox/project-static/src/tables/interfaceTable.ts

@@ -1,4 +1,4 @@
-import { getElements, findFirstAdjacent } from '../util';
+import { getElements, replaceAll, findFirstAdjacent } from '../util';
 
 
 type InterfaceState = 'enabled' | 'disabled';
 type InterfaceState = 'enabled' | 'disabled';
 type ShowHide = 'show' | 'hide';
 type ShowHide = 'show' | 'hide';
@@ -105,9 +105,9 @@ class ButtonState {
    */
    */
   private toggleButton(): void {
   private toggleButton(): void {
     if (this.buttonState === 'show') {
     if (this.buttonState === 'show') {
-      this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide');
+      this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
     } else if (this.buttonState === 'hide') {
     } else if (this.buttonState === 'hide') {
-      this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show');
+      this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
     }
     }
   }
   }
 
 

+ 53 - 3
netbox/project-static/src/util.ts

@@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
  * Iterate through a select element's options and return an array of options that are selected.
  * Iterate through a select element's options and return an array of options that are selected.
  *
  *
  * @param base Select element.
  * @param base Select element.
+ * @param selector Optionally specify a selector. 'select' by default.
  * @returns Array of selected options.
  * @returns Array of selected options.
  */
  */
-export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
+export function getSelectedOptions<E extends HTMLElement>(
+  base: E,
+  selector: string = 'select',
+): SelectedOption[] {
   let selected = [] as SelectedOption[];
   let selected = [] as SelectedOption[];
-  for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
+  for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
     if (element !== null) {
     if (element !== null) {
       const select = { name: element.name, options: [] } as SelectedOption;
       const select = { name: element.name, options: [] } as SelectedOption;
       for (const option of element.options) {
       for (const option of element.options) {
@@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
   for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
   for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
     if (element !== null) {
     if (element !== null) {
       if (isTruthy(element.innerText) && element.innerText !== '—') {
       if (isTruthy(element.innerText) && element.innerText !== '—') {
-        yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
+        yield replaceAll(element.innerText, '[\n\r]', '').trim();
       }
       }
     }
     }
   }
   }
@@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
   }
   }
   return Array.from(baseMap.values());
   return Array.from(baseMap.values());
 }
 }
+
+/**
+ * Replace all occurrences of a pattern with a replacement string.
+ *
+ * This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
+ * introduced in ES2021.
+ *
+ * @param input string to be processed.
+ * @param pattern regex pattern string or RegExp object to search for.
+ * @param replacement replacement substring with which `pattern` matches will be replaced.
+ * @returns processed version of `input`.
+ */
+export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
+  // Ensure input is a string.
+  if (typeof input !== 'string') {
+    throw new TypeError("replaceAll 'input' argument must be a string");
+  }
+  // Ensure pattern is a string or RegExp.
+  if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
+    throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
+  }
+  // Ensure replacement is able to be stringified.
+  switch (typeof replacement) {
+    case 'boolean':
+      replacement = String(replacement);
+      break;
+    case 'number':
+      replacement = String(replacement);
+      break;
+    case 'string':
+      break;
+    default:
+      throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
+  }
+
+  if (pattern instanceof RegExp) {
+    // Add global flag to existing RegExp object and deduplicate
+    const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
+    pattern = new RegExp(pattern.source, flags);
+  } else {
+    // Create a RegExp object with the global flag set.
+    pattern = new RegExp(pattern, 'g');
+  }
+
+  return input.replace(pattern, replacement);
+}

+ 3 - 2
netbox/templates/dcim/device.html

@@ -33,7 +33,8 @@
                                 <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
                                 <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
                             </td>
                             </td>
                         </tr>
                         </tr>
-                        <th scope="row">Location</th>
+                        <tr>
+                            <th scope="row">Location</th>
                             <td>
                             <td>
                             {% if object.location %}
                             {% if object.location %}
                                 {% for location in object.location.get_ancestors %}
                                 {% for location in object.location.get_ancestors %}
@@ -129,7 +130,7 @@
                                         <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
                                         <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
                                     </td>
                                     </td>
                                     <td>
                                     <td>
-                                      {% badge vc_member.vc_position %}
+                                      {% badge vc_member.vc_position show_empty=True %}
                                     </td>
                                     </td>
                                     <td>
                                     <td>
                                       {% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
                                       {% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}

+ 5 - 1
netbox/templates/extras/objectchange.html

@@ -38,7 +38,11 @@
                     <tr>
                     <tr>
                         <th scope="row">User</th>
                         <th scope="row">User</th>
                         <td>
                         <td>
-                            {{ object.user|default:object.user_name }}
+                            {% if object.user.get_full_name %}
+                              {{ object.user.get_full_name }} ({{ object.user_name }})
+                            {% else %}
+                              {{ object.user_name }}
+                            {% endif %}
                         </td>
                         </td>
                     </tr>
                     </tr>
                     <tr>
                     <tr>

+ 39 - 0
netbox/utilities/tables.py

@@ -4,10 +4,13 @@ from django.contrib.auth.models import AnonymousUser
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.fields import GenericForeignKey
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
 from django.core.exceptions import FieldDoesNotExist
+from django.db.models import DateField, DateTimeField
 from django.db.models.fields.related import RelatedField
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
 from django.urls import reverse
+from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from django_tables2 import RequestConfig
 from django_tables2 import RequestConfig
+from django_tables2.columns import library
 from django_tables2.data import TableQuerysetData
 from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
@@ -205,6 +208,42 @@ class TemplateColumn(tables.TemplateColumn):
         return ret
         return ret
 
 
 
 
+@library.register
+class DateColumn(tables.DateColumn):
+    """
+    Overrides the default implementation of DateColumn to better handle null values, returning a default value for
+    tables and null when exporting data. It is registered in the tables library to use this class instead of the
+    default, making this behavior consistent in all fields of type DateField.
+    """
+
+    def value(self, value):
+        return value
+
+    @classmethod
+    def from_field(cls, field, **kwargs):
+        if isinstance(field, DateField):
+            return cls(**kwargs)
+
+
+@library.register
+class DateTimeColumn(tables.DateTimeColumn):
+    """
+    Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for
+    tables and null when exporting data. It is registered in the tables library to use this class instead of the
+    default, making this behavior consistent in all fields of type DateTimeField.
+    """
+
+    def value(self, value):
+        if value:
+            return date_format(value, format="SHORT_DATETIME_FORMAT")
+        return None
+
+    @classmethod
+    def from_field(cls, field, **kwargs):
+        if isinstance(field, DateTimeField):
+            return cls(**kwargs)
+
+
 class ButtonsColumn(tables.TemplateColumn):
 class ButtonsColumn(tables.TemplateColumn):
     """
     """
     Render edit, delete, and changelog buttons for an object.
     Render edit, delete, and changelog buttons for an object.

+ 1 - 1
netbox/utilities/utils.py

@@ -183,7 +183,7 @@ def deepmerge(original, new):
     """
     """
     merged = OrderedDict(original)
     merged = OrderedDict(original)
     for key, val in new.items():
     for key, val in new.items():
-        if key in original and isinstance(original[key], dict) and isinstance(val, dict):
+        if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
             merged[key] = deepmerge(original[key], val)
             merged[key] = deepmerge(original[key], val)
         else:
         else:
             merged[key] = val
             merged[key] = val

+ 1 - 1
netbox/virtualization/forms/bulk_import.py

@@ -64,7 +64,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
 class VirtualMachineCSVForm(CustomFieldModelCSVForm):
 class VirtualMachineCSVForm(CustomFieldModelCSVForm):
     status = CSVChoiceField(
     status = CSVChoiceField(
         choices=VirtualMachineStatusChoices,
         choices=VirtualMachineStatusChoices,
-        help_text='Operational status of device'
+        help_text='Operational status'
     )
     )
     cluster = CSVModelChoiceField(
     cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),

+ 3 - 3
requirements.txt

@@ -9,7 +9,7 @@ django-prometheus==2.2.0
 django-redis==5.2.0
 django-redis==5.2.0
 django-rq==2.5.1
 django-rq==2.5.1
 django-tables2==2.4.1
 django-tables2==2.4.1
-django-taggit==2.0.0
+django-taggit==2.1.0
 django-timezone-field==4.2.3
 django-timezone-field==4.2.3
 djangorestframework==3.12.4
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 drf-yasg[validation]==1.20.0
@@ -18,9 +18,9 @@ gunicorn==20.1.0
 Jinja2==3.0.3
 Jinja2==3.0.3
 Markdown==3.3.6
 Markdown==3.3.6
 markdown-include==0.6.0
 markdown-include==0.6.0
-mkdocs-material==8.1.9
+mkdocs-material==8.1.11
 netaddr==0.8.0
 netaddr==0.8.0
-Pillow==8.4.0
+Pillow==9.0.1
 psycopg2-binary==2.9.3
 psycopg2-binary==2.9.3
 PyYAML==6.0
 PyYAML==6.0
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0

Some files were not shown because too many files changed in this diff