Просмотр исходного кода

Merge pull request #8640 from netbox-community/develop

Release v3.1.8
Jeremy Stretch 4 лет назад
Родитель
Сommit
90f91eeea4
39 измененных файлов с 294 добавлено и 92 удалено
  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:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.7
+      placeholder: v3.1.8
     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.1.7
+      placeholder: v3.1.8
     validations:
       required: true
   - type: dropdown

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

@@ -38,6 +38,19 @@ jobs:
       uses: actions/setup-node@v2
       with:
         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
       run: |
@@ -45,7 +58,6 @@ jobs:
         pip install -r requirements.txt
         pip install pycodestyle coverage
         ln -s configuration.testing.py netbox/netbox/configuration.py
-        yarn --cwd netbox/project-static
 
     - name: Build documentation
       run: mkdocs build
@@ -63,7 +75,7 @@ jobs:
       run: scripts/verify-bundles.sh
 
     - 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
       run: coverage report --skip-covered --omit *migrations*

+ 0 - 7
CONTRIBUTING.md

@@ -16,13 +16,6 @@ categories for discussions:
   feature request
 * **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
 
 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
 * [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
 

+ 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)
 
-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`
 
-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:
 
 * [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.
-* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
 
 ## 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)
 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
 
 | Dependency | Minimum Version |

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

@@ -1,5 +1,29 @@
 # 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)
 
 ### Enhancements

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

@@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
     def clean(self):
         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
-        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({
                 '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'))
 
     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
         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>
             <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='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='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>

+ 5 - 0
netbox/extras/filtersets.py

@@ -317,6 +317,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
         to_field_name='slug',
         label='Tenant (slug)',
     )
+    tag_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='tags',
+        queryset=Tag.objects.all(),
+        label='Tag',
+    )
     tag = django_filters.ModelMultipleChoiceFilter(
         field_name='tags__slug',
         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.models import *
 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__ = (
     'ConfigContextBulkEditForm',
@@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
         required=False
     )
     button_class = forms.ChoiceField(
-        choices=CustomLinkButtonClassChoices,
+        choices=add_blank_choice(CustomLinkButtonClassChoices),
         required=False,
         widget=StaticSelect()
     )
@@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
         widget=BulkEditNullBooleanSelect()
     )
     http_method = forms.ChoiceField(
-        choices=WebhookHttpMethodChoices,
-        required=False
+        choices=add_blank_choice(WebhookHttpMethodChoices),
+        required=False,
+        label='HTTP method'
     )
     payload_url = forms.CharField(
-        required=False
+        required=False,
+        label='Payload URL'
     )
     ssl_verification = forms.NullBooleanField(
         required=False,
-        widget=BulkEditNullBooleanSelect()
+        widget=BulkEditNullBooleanSelect(),
+        label='SSL verification'
     )
     secret = forms.CharField(
         required=False
     )
     ca_file_path = forms.CharField(
-        required=False
+        required=False,
+        label='CA file path'
     )
 
     class Meta:
@@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
         widget=forms.MultipleHiddenInput
     )
     kind = forms.ChoiceField(
-        choices=JournalEntryKindChoices,
+        choices=add_blank_choice(JournalEntryKindChoices),
         required=False
     )
     comments = forms.CharField(

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

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

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

@@ -70,10 +70,23 @@ class Command(BaseCommand):
         return namespace
 
     def handle(self, **options):
+        namespace = self.get_namespace()
+
         # If Python code has been passed, execute it and exit.
         if options['command']:
-            exec(options['command'], self.get_namespace())
+            exec(options['command'], namespace)
             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

+ 14 - 1
netbox/extras/tables.py

@@ -29,6 +29,11 @@ CONFIGCONTEXT_ACTIONS = """
 {% endif %}
 """
 
+OBJECTCHANGE_FULL_NAME = """
+{% load helpers %}
+{{ record.user.get_full_name|placeholder }}
+"""
+
 OBJECTCHANGE_OBJECT = """
 {% if record.changed_object and record.changed_object.get_absolute_url %}
     <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
@@ -204,6 +209,14 @@ class ObjectChangeTable(BaseTable):
         linkify=True,
         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()
     changed_object_type = ContentTypeColumn(
         verbose_name='Type'
@@ -219,7 +232,7 @@ class ObjectChangeTable(BaseTable):
 
     class Meta(BaseTable.Meta):
         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):

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

@@ -12,7 +12,7 @@ from extras.filtersets import *
 from extras.models import *
 from ipam.models import IPAddress
 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
 
 
@@ -429,6 +429,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         Tenant.objects.bulk_create(tenants)
 
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
         for i in range(0, 3):
             is_active = bool(i % 2)
             c = ConfigContext.objects.create(
@@ -446,6 +448,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
             c.clusters.set([clusters[i]])
             c.tenant_groups.set([tenant_groups[i]])
             c.tenants.set([tenants[i]])
+            c.tags.set([tags[i]])
 
     def test_name(self):
         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]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
-    def test_tenant_(self):
+    def test_tenant(self):
         tenants = Tenant.objects.all()[:2]
         params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         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):
     queryset = Tag.objects.all()

+ 2 - 1
netbox/extras/views.py

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

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

@@ -20,19 +20,28 @@ PARAMS = (
         name='BANNER_LOGIN',
         label='Login banner',
         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(
         name='BANNER_TOP',
         label='Top banner',
         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(
         name='BANNER_BOTTOM',
         label='Bottom banner',
         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

+ 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.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
 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 utilities.utils import count_related
 from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
@@ -186,7 +186,7 @@ SEARCH_TYPES = OrderedDict((
         'url': 'tenancy:tenant_list',
     }),
     ('contact', {
-        'queryset': Contact.objects.prefetch_related('group', 'assignments'),
+        'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
         'filterset': ContactFilterSet,
         'table': ContactTable,
         'url': 'tenancy:contact_list',

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 #
 
-VERSION = '3.1.7'
+VERSION = '3.1.8'
 
 # Hostname
 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(
             'user', 'changed_object_type'
         )[:10]
-        changelog_table = ObjectChangeTable(changelog)
+        changelog_table = ObjectChangeTable(changelog, user=request.user)
 
         # Check whether a new release is available. (Only for staff/superusers.)
         new_release = None

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 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 {
   hasMore,
-  isTruthy,
   hasError,
-  getElement,
+  isTruthy,
   getApiData,
+  getElement,
   isApiError,
+  replaceAll,
   createElement,
   uniqueByProperty,
   findFirstAdjacent,
@@ -461,7 +462,7 @@ export class APISelect {
       // Set any primitive k/v pairs as data attributes on each option.
       for (const [k, v] of Object.entries(result)) {
         if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
-          const key = k.replaceAll('_', '-');
+          const key = replaceAll(k, '_', '-');
           data[key] = String(v);
         }
         // 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 result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
         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.
    */
   private updatePathValues(id: string): void {
-    const key = id.replaceAll(/^id_/gi, '');
+    const key = replaceAll(id, /^id_/i, '');
     const element = getElement<HTMLSelectElement>(`id_${key}`);
     if (element !== null) {
       // 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);
 
         // 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-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
    {
     background-color: ${bg} !important;
     color: ${fg} !important;
   }
-              `
-          .replaceAll('\n', '')
-          .trim();
+              `,
+          '\n',
+          '',
+        ).trim();
 
         // Add the style element to the DOM.
         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.
  */
@@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
 /**
  * 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);
 }
 
@@ -70,25 +64,46 @@ function handleSubmit(event: Event): void {
   const url = element.getAttribute('data-url');
   if (url == null) {
     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();
     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.
-  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.
   const formData: Dict<Dict<string>> = Object.assign(
     {},
     ...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
   // 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')) {
     element.addEventListener('click', saveTableConfig);
   }
-  for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
-    element.addEventListener('click', resetTableConfig);
-  }
   for (const element of getElements<HTMLButtonElement>('#add_columns')) {
     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 ShowHide = 'show' | 'hide';
@@ -105,9 +105,9 @@ class ButtonState {
    */
   private toggleButton(): void {
     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') {
-      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.
  *
  * @param base Select element.
+ * @param selector Optionally specify a selector. 'select' by default.
  * @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[];
-  for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
+  for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
     if (element !== null) {
       const select = { name: element.name, options: [] } as SelectedOption;
       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')) {
     if (element !== null) {
       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());
 }
+
+/**
+ * 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>
                             </td>
                         </tr>
-                        <th scope="row">Location</th>
+                        <tr>
+                            <th scope="row">Location</th>
                             <td>
                             {% if object.location %}
                                 {% for location in object.location.get_ancestors %}
@@ -129,7 +130,7 @@
                                         <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
                                     </td>
                                     <td>
-                                      {% badge vc_member.vc_position %}
+                                      {% badge vc_member.vc_position show_empty=True %}
                                     </td>
                                     <td>
                                       {% 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>
                         <th scope="row">User</th>
                         <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>
                     </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.models import ContentType
 from django.core.exceptions import FieldDoesNotExist
+from django.db.models import DateField, DateTimeField
 from django.db.models.fields.related import RelatedField
 from django.urls import reverse
+from django.utils.formats import date_format
 from django.utils.safestring import mark_safe
 from django_tables2 import RequestConfig
+from django_tables2.columns import library
 from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
 
@@ -205,6 +208,42 @@ class TemplateColumn(tables.TemplateColumn):
         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):
     """
     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)
     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)
         else:
             merged[key] = val

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

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

+ 3 - 3
requirements.txt

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

Некоторые файлы не были показаны из-за большого количества измененных файлов