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

Merge pull request #10570 from netbox-community/develop

Release v3.3.5
Jeremy Stretch 3 лет назад
Родитель
Сommit
56d9725c39
49 измененных файлов с 402 добавлено и 525 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 2 0
      .github/workflows/ci.yml
  4. 5 1
      .github/workflows/lock.yml
  5. 8 1
      .github/workflows/stale.yml
  6. 3 2
      base_requirements.txt
  7. 2 2
      docs/_theme/main.html
  8. 1 1
      docs/configuration/system.md
  9. 13 0
      docs/customization/custom-scripts.md
  10. 8 0
      docs/plugins/development/index.md
  11. 28 0
      docs/release-notes/version-3.3.md
  12. 0 1
      mkdocs.yml
  13. 2 0
      netbox/dcim/forms/models.py
  14. 8 0
      netbox/dcim/models/devices.py
  15. 1 1
      netbox/dcim/svg/cables.py
  16. 2 1
      netbox/dcim/svg/racks.py
  17. 3 0
      netbox/dcim/tables/devicetypes.py
  18. 3 2
      netbox/dcim/tests/test_views.py
  19. 3 2
      netbox/dcim/views.py
  20. 3 8
      netbox/extras/forms/customfields.py
  21. 8 1
      netbox/extras/models/customfields.py
  22. 8 0
      netbox/extras/models/models.py
  23. 24 20
      netbox/ipam/querysets.py
  24. 8 3
      netbox/netbox/forms/base.py
  25. 1 2
      netbox/netbox/models/__init__.py
  26. 1 1
      netbox/netbox/settings.py
  27. 0 0
      netbox/project-static/dist/netbox.js
  28. 0 0
      netbox/project-static/dist/netbox.js.map
  29. 14 0
      netbox/project-static/src/netbox.ts
  30. 32 72
      netbox/templates/dcim/consoleport.html
  31. 31 73
      netbox/templates/dcim/consoleserverport.html
  32. 20 4
      netbox/templates/dcim/device.html
  33. 1 1
      netbox/templates/dcim/devicetype.html
  34. 0 14
      netbox/templates/dcim/inc/cabletermination.html
  35. 36 0
      netbox/templates/dcim/inc/connection_endpoints.html
  36. 2 84
      netbox/templates/dcim/interface.html
  37. 27 67
      netbox/templates/dcim/powerfeed.html
  38. 21 61
      netbox/templates/dcim/poweroutlet.html
  39. 31 71
      netbox/templates/dcim/powerport.html
  40. 3 0
      netbox/templates/tenancy/contactassignment_edit.html
  41. 2 2
      netbox/templates/virtualization/cluster.html
  42. 3 1
      netbox/tenancy/forms/models.py
  43. 2 2
      netbox/tenancy/models/contacts.py
  44. 1 1
      netbox/tenancy/tables/tenants.py
  45. 2 2
      netbox/utilities/templatetags/helpers.py
  46. 5 7
      netbox/virtualization/models.py
  47. 4 3
      netbox/virtualization/tests/test_models.py
  48. 9 0
      pyproject.toml
  49. 9 9
      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.3.4
+      placeholder: v3.3.5
     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.3.4
+      placeholder: v3.3.5
     validations:
       required: true
   - type: dropdown

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

@@ -1,5 +1,7 @@
 name: CI
 on: [push, pull_request]
+permissions:
+  contents: read
 jobs:
   build:
     runs-on: ubuntu-latest

+ 5 - 1
.github/workflows/lock.yml

@@ -4,6 +4,11 @@ name: 'Lock threads'
 on:
   schedule:
     - cron: '0 3 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+  pull-requests: write
 
 jobs:
   lock:
@@ -11,7 +16,6 @@ jobs:
     steps:
       - uses: dessant/lock-threads@v3
         with:
-          github-token: ${{ github.token }}
           issue-inactive-days: 90
           pr-inactive-days: 30
           issue-lock-reason: 'resolved'

+ 8 - 1
.github/workflows/stale.yml

@@ -1,14 +1,21 @@
 # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
 name: 'Close stale issues/PRs'
+
 on:
   schedule:
     - cron: '0 4 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+  pull-requests: write
 
 jobs:
   stale:
+
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/stale@v5
+      - uses: actions/stale@v6
         with:
           close-issue-message: >
             This issue has been automatically closed due to lack of activity. In an

+ 3 - 2
base_requirements.txt

@@ -68,7 +68,7 @@ drf-yasg[validation]
 
 # Django wrapper for Graphene (GraphQL support)
 # https://github.com/graphql-python/graphene-django
-graphene_django
+graphene_django<3.0
 
 # WSGI HTTP server
 # https://gunicorn.org/
@@ -80,7 +80,8 @@ Jinja2
 
 # Simple markup language for rendering HTML
 # https://github.com/Python-Markdown/markdown
-Markdown
+# mkdocs currently requires Markdown v3.3
+Markdown<3.4
 
 # File inclusion plugin for Python-Markdown
 # https://github.com/cmacmackin/markdown-include

+ 2 - 2
docs/_theme/main.html

@@ -2,8 +2,8 @@
 
 {% block site_meta %}
   {{ super() }}
-  {# Disable search indexing unless we're building for ReadTheDocs #}
-  {% if not config.extra.readthedocs %}
+  {# Disable search indexing unless we're building for ReadTheDocs (see #10496) #}
+  {% if page.canonical_url != 'https://docs.netbox.dev/' %}
     <meta name="robots" content="noindex">
   {% endif %}
 {% endblock %}

+ 1 - 1
docs/configuration/system.md

@@ -58,7 +58,7 @@ Email is sent from NetBox only for critical events or if configured for [logging
 
 Default: None
 
-A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example:
+A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://requests.readthedocs.io/en/latest/user/advanced/#proxies). For example:
 
 ```python
 HTTP_PROXIES = {

+ 13 - 0
docs/customization/custom-scripts.md

@@ -129,6 +129,19 @@ The Script object provides a set of convenient functions for recording messages
 
 Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
 
+## Change Logging
+
+To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object.
+
+```python
+if obj.pk and hasattr(obj, 'snapshot'):
+    obj.snapshot()
+
+obj.property = "New Value"
+obj.full_clean()
+obj.save()
+```
+
 ## Variable Reference
 
 ### Default Options

+ 8 - 0
docs/plugins/development/index.md

@@ -112,6 +112,14 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i
 
 All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored.
 
+!!! tip "Accessing Config Parameters"
+    Plugin configuration parameters can be accessed in `settings.PLUGINS_CONFIG`, mapped by plugin name. For example:
+    
+    ```python
+    from django.conf import settings
+    settings.PLUGINS_CONFIG['myplugin']['verbose_name']
+    ```
+
 ## Create setup.py
 
 `setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below:

+ 28 - 0
docs/release-notes/version-3.3.md

@@ -1,5 +1,33 @@
 # NetBox v3.3
 
+## v3.3.5 (2022-10-05)
+
+### Enhancements
+
+* [#8424](https://github.com/netbox-community/netbox/issues/8424) - Include rack elevation under device view
+* [#10352](https://github.com/netbox-community/netbox/issues/10352) - Omit extraneous URL query attributes during search
+* [#10465](https://github.com/netbox-community/netbox/issues/10465) - Improve formatting of device heights and rack positions
+
+### Bug Fixes
+
+* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view
+* [#10408](https://github.com/netbox-community/netbox/issues/10408) - Fix validation when attempting to add redundant contact assignments
+* [#10423](https://github.com/netbox-community/netbox/issues/10423) - Enforce object type validation when creating journal entries
+* [#10435](https://github.com/netbox-community/netbox/issues/10435) - Fix exception when filtering VLANs by virtual machine with no cluster assigned
+* [#10439](https://github.com/netbox-community/netbox/issues/10439) - Fix form widget styling for DeviceType airflow field
+* [#10445](https://github.com/netbox-community/netbox/issues/10445) - Avoid rounding virtual machine memory values
+* [#10460](https://github.com/netbox-community/netbox/issues/10460) - Restore missing connection details for device components
+* [#10461](https://github.com/netbox-community/netbox/issues/10461) - Enable filtering by read-only custom fields in the UI
+* [#10470](https://github.com/netbox-community/netbox/issues/10470) - Omit read-only custom fields from CSV import forms
+* [#10480](https://github.com/netbox-community/netbox/issues/10480) - Cable trace SVG links should not force a new window
+* [#10491](https://github.com/netbox-community/netbox/issues/10491) - Clarify representation of blocking contact assignments during contact deletion
+* [#10513](https://github.com/netbox-community/netbox/issues/10513) - Disable the reassignment of a module to a new device
+* [#10517](https://github.com/netbox-community/netbox/issues/10517) - Automatically inherit site assignment from cluster when creating a virtual machine
+* [#10559](https://github.com/netbox-community/netbox/issues/10559) - Permit the pinning of a VM to a particular device within a cluster which has no site assignment
+* [#10562](https://github.com/netbox-community/netbox/issues/10562) - Correct URL for contacts table tags column
+
+---
+
 ## v3.3.4 (2022-09-16)
 
 ### Bug Fixes

+ 0 - 1
mkdocs.yml

@@ -38,7 +38,6 @@ plugins:
             show_root_toc_entry: false
             show_source: false
 extra:
-  readthedocs: !ENV READTHEDOCS
   social:
     - icon: fontawesome/brands/github
       link: https://github.com/netbox-community/netbox

+ 2 - 0
netbox/dcim/forms/models.py

@@ -373,6 +373,7 @@ class DeviceTypeForm(NetBoxModelForm):
             'front_image', 'rear_image', 'comments', 'tags',
         ]
         widgets = {
+            'airflow': StaticSelect(),
             'subdevice_role': StaticSelect(),
             'front_image': ClearableFileInput(attrs={
                 'accept': DEVICETYPE_IMAGE_FORMATS
@@ -678,6 +679,7 @@ class ModuleForm(NetBoxModelForm):
         super().__init__(*args, **kwargs)
 
         if self.instance.pk:
+            self.fields['device'].disabled = True
             self.fields['replicate_components'].initial = False
             self.fields['replicate_components'].disabled = True
             self.fields['adopt_components'].initial = False

+ 8 - 0
netbox/dcim/models/devices.py

@@ -987,6 +987,14 @@ class Module(NetBoxModel, ConfigContextModel):
     def get_absolute_url(self):
         return reverse('dcim:module', args=[self.pk])
 
+    def clean(self):
+        super().clean()
+
+        if self.module_bay.device != self.device:
+            raise ValidationError(
+                f"Module must be installed within a module bay belonging to the assigned device ({self.device})."
+            )
+
     def save(self, *args, **kwargs):
         is_new = self.pk is None
 

+ 1 - 1
netbox/dcim/svg/cables.py

@@ -35,7 +35,7 @@ class Node(Hyperlink):
     """
 
     def __init__(self, position, width, url, color, labels, radius=10, **extra):
-        super(Node, self).__init__(href=url, target='_blank', **extra)
+        super(Node, self).__init__(href=url, target='_parent', **extra)
 
         x, y = position
 

+ 2 - 1
netbox/dcim/svg/racks.py

@@ -9,6 +9,7 @@ from svgwrite.text import Text
 from django.conf import settings
 from django.core.exceptions import FieldError
 from django.db.models import Q
+from django.template.defaultfilters import floatformat
 from django.urls import reverse
 from django.utils.http import urlencode
 
@@ -41,7 +42,7 @@ def get_device_description(device):
         device.device_role,
         device.device_type.manufacturer.name,
         device.device_type.model,
-        device.device_type.u_height,
+        floatformat(device.device_type.u_height),
         device.asset_tag or '',
         device.serial or ''
     )

+ 3 - 0
netbox/dcim/tables/devicetypes.py

@@ -85,6 +85,9 @@ class DeviceTypeTable(NetBoxTable):
     tags = columns.TagColumn(
         url_name='dcim:devicetype_list'
     )
+    u_height = columns.TemplateColumn(
+        template_code='{{ value|floatformat }}'
+    )
 
     class Meta(NetBoxTable.Meta):
         model = DeviceType

+ 3 - 2
netbox/dcim/tests/test_views.py

@@ -1778,10 +1778,12 @@ class ModuleTestCase(
             ModuleBay(device=devices[0], name='Module Bay 2'),
             ModuleBay(device=devices[0], name='Module Bay 3'),
             ModuleBay(device=devices[0], name='Module Bay 4'),
+            ModuleBay(device=devices[0], name='Module Bay 5'),
             ModuleBay(device=devices[1], name='Module Bay 1'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[1], name='Module Bay 3'),
             ModuleBay(device=devices[1], name='Module Bay 4'),
+            ModuleBay(device=devices[1], name='Module Bay 5'),
         )
         ModuleBay.objects.bulk_create(module_bays)
 
@@ -1795,7 +1797,7 @@ class ModuleTestCase(
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
-            'device': devices[1].pk,
+            'device': devices[0].pk,
             'module_bay': module_bays[3].pk,
             'module_type': module_types[0].pk,
             'serial': 'A',
@@ -1867,7 +1869,6 @@ class ModuleTestCase(
         self.assertIsNone(interface.module)
 
         # Create a module with adopted components
-        form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
         form_data['module_type'] = module_type
         form_data['replicate_components'] = False
         form_data['adopt_components'] = True

+ 3 - 2
netbox/dcim/views.py

@@ -355,7 +355,7 @@ class SiteView(generic.ObjectView):
 
         nonracked_devices = Device.objects.filter(
             site=instance,
-            position__isnull=True,
+            rack__isnull=True,
             parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
 
@@ -450,7 +450,7 @@ class LocationView(generic.ObjectView):
 
         nonracked_devices = Device.objects.filter(
             location=instance,
-            position__isnull=True,
+            rack__isnull=True,
             parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
 
@@ -1616,6 +1616,7 @@ class DeviceView(generic.ObjectView):
         return {
             'services': services,
             'vc_members': vc_members,
+            'svg_extra': f'highlight=id:{instance.pk}'
         }
 
 

+ 3 - 8
netbox/extras/forms/customfields.py

@@ -34,7 +34,9 @@ class CustomFieldsMixin:
         return ContentType.objects.get_for_model(self.model)
 
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(content_types=content_type)
+        return CustomField.objects.filter(content_types=content_type).exclude(
+            ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN
+        )
 
     def _get_form_field(self, customfield):
         return customfield.to_form_field()
@@ -50,13 +52,6 @@ class CustomFieldsMixin:
             field_name = f'cf_{customfield.name}'
             self.fields[field_name] = self._get_form_field(customfield)
 
-            if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
-                self.fields[field_name].disabled = True
-                if self.fields[field_name].help_text:
-                    self.fields[field_name].help_text += '<br />'
-                self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
-                                                     'Field is set to read-only.'
-
             # Annotate the field in the list of CustomField form fields
             self.custom_fields[field_name] = customfield
             if customfield.group_name not in self.custom_field_groups:

+ 8 - 1
netbox/extras/models/customfields.py

@@ -297,12 +297,13 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
             return model.objects.filter(pk__in=value)
         return value
 
-    def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False):
+    def to_form_field(self, set_initial=True, enforce_required=True, enforce_visibility=True, for_csv_import=False):
         """
         Return a form field suitable for setting a CustomField's value for an object.
 
         set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
         enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
+        enforce_visibility: Honor the value of CustomField.ui_visibility. Set to False for filtering.
         for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
         """
         initial = self.default if set_initial else None
@@ -398,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
         if self.description:
             field.help_text = escape(self.description)
 
+        # Annotate read-only fields
+        if enforce_visibility and self.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY:
+            field.disabled = True
+            prepend = '<br />' if field.help_text else ''
+            field.help_text += f'{prepend}<i class="mdi mdi-alert-circle-outline"></i> Field is set to read-only.'
+
         return field
 
     def to_filter(self, lookup_expr=None):

+ 8 - 0
netbox/extras/models/models.py

@@ -463,6 +463,14 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, WebhooksMixin
     def get_absolute_url(self):
         return reverse('extras:journalentry', args=[self.pk])
 
+    def clean(self):
+        super().clean()
+
+        # Prevent the creation of journal entries on unsupported models
+        permitted_types = ContentType.objects.filter(FeatureQuery('journaling').get_query())
+        if self.assigned_object_type not in permitted_types:
+            raise ValidationError(f"Journaling is not supported for this object type ({self.assigned_object_type}).")
+
     def get_kind_color(self):
         return JournalEntryKindChoices.colors.get(self.kind)
 

+ 24 - 20
netbox/ipam/querysets.py

@@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
 
         # Find all relevant VLANGroups
         q = Q()
-        if vm.cluster.site:
-            if vm.cluster.site.region:
+        site = vm.site or vm.cluster.site
+        if vm.cluster:
+            # Add VLANGroups scoped to the assigned cluster (or its group)
+            q |= Q(
+                scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
+                scope_id=vm.cluster_id
+            )
+            if vm.cluster.group:
+                q |= Q(
+                    scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
+                    scope_id=vm.cluster.group_id
+                )
+        if site:
+            # Add VLANGroups scoped to the assigned site (or its group or region)
+            q |= Q(
+                scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
+                scope_id=site.pk
+            )
+            if site.region:
                 q |= Q(
                     scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
-                    scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True)
+                    scope_id__in=site.region.get_ancestors(include_self=True)
                 )
-            if vm.cluster.site.group:
+            if site.group:
                 q |= Q(
                     scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
-                    scope_id__in=vm.cluster.site.group.get_ancestors(include_self=True)
+                    scope_id__in=site.group.get_ancestors(include_self=True)
                 )
-            q |= Q(
-                scope_type=ContentType.objects.get_by_natural_key('dcim', 'site'),
-                scope_id=vm.cluster.site_id
-            )
-        if vm.cluster.group:
-            q |= Q(
-                scope_type=ContentType.objects.get_by_natural_key('virtualization', 'clustergroup'),
-                scope_id=vm.cluster.group_id
-            )
-        q |= Q(
-            scope_type=ContentType.objects.get_by_natural_key('virtualization', 'cluster'),
-            scope_id=vm.cluster_id
-        )
         vlan_groups = VLANGroup.objects.filter(q)
 
         # Return all applicable VLANs
@@ -113,7 +117,7 @@ class VLANQuerySet(RestrictedQuerySet):
             Q(group__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
             Q(group__isnull=True, site__isnull=True)  # Global VLANs
         )
-        if vm.cluster.site:
-            q |= Q(site=vm.cluster.site)
+        if site:
+            q |= Q(site=site)
 
         return self.filter(q)

+ 8 - 3
netbox/netbox/forms/base.py

@@ -2,7 +2,7 @@ from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 
-from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
+from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices, CustomFieldVisibilityChoices
 from extras.forms.customfields import CustomFieldsMixin
 from extras.models import CustomField, Tag
 from utilities.forms import BootstrapMixin, CSVModelForm
@@ -63,6 +63,11 @@ class NetBoxModelCSVForm(CSVModelForm, NetBoxModelForm):
     """
     tags = None  # Temporary fix in lieu of tag import support (see #9158)
 
+    def _get_custom_fields(self, content_type):
+        return CustomField.objects.filter(content_types=content_type).filter(
+            ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE
+        )
+
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)
 
@@ -125,10 +130,10 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
     )
 
     def _get_custom_fields(self, content_type):
-        return CustomField.objects.filter(content_types=content_type).exclude(
+        return super()._get_custom_fields(content_type).exclude(
             Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
             Q(type=CustomFieldTypeChoices.TYPE_JSON)
         )
 
     def _get_form_field(self, customfield):
-        return customfield.to_form_field(set_initial=False, enforce_required=False)
+        return customfield.to_form_field(set_initial=False, enforce_required=False, enforce_visibility=False)

+ 1 - 2
netbox/netbox/models/__init__.py

@@ -20,7 +20,6 @@ class NetBoxFeatureSet(
     CustomLinksMixin,
     CustomValidationMixin,
     ExportTemplatesMixin,
-    JournalingMixin,
     TagsMixin,
     WebhooksMixin
 ):
@@ -51,7 +50,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
         abstract = True
 
 
-class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
+class NetBoxModel(CloningMixin, JournalingMixin, NetBoxFeatureSet, models.Model):
     """
     Primary models represent real objects within the infrastructure being modeled.
     """

+ 1 - 1
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.3.4'
+VERSION = '3.3.5'
 
 # Hostname
 HOSTNAME = platform.node()

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


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


+ 14 - 0
netbox/project-static/src/netbox.ts

@@ -37,6 +37,20 @@ function initDocument(): void {
 }
 
 function initWindow(): void {
+
+  const documentForms = document.forms
+  for (var documentForm of documentForms) {
+    if (documentForm.method.toUpperCase() == 'GET') {
+      // @ts-ignore: Our version of typescript seems to be too old for FormDataEvent
+      documentForm.addEventListener('formdata', function(event: FormDataEvent) {
+      let formData: FormData = event.formData;
+      for (let [name, value] of Array.from(formData.entries())) {
+          if (value === '') formData.delete(name);
+        }
+      });
+    }
+  }
+
   const contentContainer = document.querySelector<HTMLElement>('.content-container');
   if (contentContainer !== null) {
     // Focus the content container for accessible navigation.

+ 32 - 72
netbox/templates/dcim/consoleport.html

@@ -54,80 +54,40 @@
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Connection
-                </h5>
-                <div class="card-body">
-                    {% if object.mark_connected %}
-                        <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
-                    {% elif object.cable %}
-                        <table class="table table-hover attr-table">
-                            <tr>
-                                <th scope="row">Cable</th>
-                                <td>
-                                    {{ object.cable|linkify }}
-                                    <a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                        <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                    </a>
-                                </td>
-                            </tr>
-                            {% if object.connected_endpoint %}
-                                <tr>
-                                    <th scope="row">Device</th>
-                                    <td>{{ object.connected_endpoint.device|linkify }}</td>
-                                </tr>
-                                <tr>
-                                    <th scope="row">Name</th>
-                                    <td>{{ object.connected_endpoint|linkify:"name" }}</td>
-                                </tr>
-                                <tr>
-                                    <th scope="row">Type</th>
-                                    <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                                </tr>
-                                <tr>
-                                    <th scope="row">Description</th>
-                                    <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                                </tr>
-                                <tr>
-                                    <th scope="row">Path Status</th>
-                                    <td>
-                                        {% if object.path.is_active %}
-                                            <span class="badge bg-success">Reachable</span>
-                                        {% else %}
-                                            <span class="badge bg-danger">Not Reachable</span>
-                                        {% endif %}
-                                    </td>
-                                </tr>
-                            {% endif %}
-                        </table>
-                    {% else %}
-                        <div class="text-muted">
-                            Not Connected
-                            {% if perms.dcim.add_cable %}
-                                <div class="dropdown float-end">
-                                    <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-                                        <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
-                                    </button>
-                                    <ul class="dropdown-menu dropdown-menu-end">
-                                        <li>
-                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
-                                        </li>
-                                        <li>
-                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
-                                        </li>
-                                        <li>
-                                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
-                                        </li>
-                                    </ul>
-                                </div>
-                            {% endif %}
-                        </div>
-                    {% endif %}
+          <div class="card">
+            <h5 class="card-header">Connection</h5>
+            <div class="card-body">
+              {% if object.mark_connected %}
+                <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
+              {% elif object.cable %}
+                {% include 'dcim/inc/connection_endpoints.html' %}
+              {% else %}
+                <div class="text-muted">
+                  Not Connected
+                  {% if perms.dcim.add_cable %}
+                    <div class="dropdown float-end">
+                      <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                        <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
+                      </button>
+                      <ul class="dropdown-menu dropdown-menu-end">
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
+                        </li>
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
+                        </li>
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
+                        </li>
+                      </ul>
+                    </div>
+                  {% endif %}
                 </div>
+              {% endif %}
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
-            {% plugin_right_page object %}
+          </div>
+          {% include 'dcim/inc/panels/inventory_items.html' %}
+          {% plugin_right_page object %}
         </div>
     </div>
     <div class="row">

+ 31 - 73
netbox/templates/dcim/consoleserverport.html

@@ -54,82 +54,40 @@
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Connection
-                </h5>
-                <div class="card-body">
-                    {% if object.mark_connected %}
-                    <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
-                    {% elif object.cable %}
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Cable</th>
-                            <td>
-                                {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:consoleserverport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                    <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                </a>
-                            </td>
-                        </tr>
-                        {% if object.connected_endpoint %}
-                            <tr>
-                                <th scope="row">Device</th>
-                                <td>
-                                    {{ object.connected_endpoint.device|linkify }}
-                                </td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Name</th>
-                                <td>{{ object.connected_endpoint|linkify:"name" }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Type</th>
-                                <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Description</th>
-                                <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Path Status</th>
-                                <td>
-                                    {% if object.path.is_active %}
-                                        <span class="badge bg-success">Reachable</span>
-                                    {% else %}
-                                        <span class="badge bg-danger">Not Reachable</span>
-                                    {% endif %}
-                                </td>
-                            </tr>
-                        {% endif %}
-                    </table>
-                {% else %}
-                    <div class="text-muted">
-                        Not Connected
-                        {% if perms.dcim.add_cable %}
-                            <div class="dropdown float-end">
-                                <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-                                    <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
-                                </button>
-                                <ul class="dropdown-menu dropdown-menu-end">
-                                    <li>
-                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
-                                    </li>
-                                    <li>
-                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
-                                    </li>
-                                    <li>
-                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
-                                    </li>
-                                </ul>
-                            </div>
-                        {% endif %}
+          <div class="card">
+            <h5 class="card-header">Connection</h5>
+            <div class="card-body">
+              {% if object.mark_connected %}
+                <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
+              {% elif object.cable %}
+                {% include 'dcim/inc/connection_endpoints.html' %}
+              {% else %}
+                <div class="text-muted">
+                  Not Connected
+                  {% if perms.dcim.add_cable %}
+                    <div class="dropdown float-end">
+                      <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+                        <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
+                      </button>
+                      <ul class="dropdown-menu dropdown-menu-end">
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
+                        </li>
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
+                        </li>
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
+                        </li>
+                      </ul>
                     </div>
-                {% endif %}
+                  {% endif %}
                 </div>
+              {% endif %}
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
-            {% plugin_right_page object %}
+          </div>
+          {% include 'dcim/inc/panels/inventory_items.html' %}
+          {% plugin_right_page object %}
         </div>
     </div>
     <div class="row">

+ 20 - 4
netbox/templates/dcim/device.html

@@ -7,7 +7,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col col-md-6">
+        <div class="col col-12 col-xl-6">
             <div class="card">
                 <h5 class="card-header">
                     Device
@@ -66,7 +66,7 @@
                                     {% with object.parent_bay.device as parent %}
                                         {{ parent|linkify }} / {{ object.parent_bay }}
                                         {% if parent.position %}
-                                            (U{{ parent.position }} / {{ parent.get_face_display }})
+                                            (U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
                                         {% endif %}
                                     {% endwith %}
                                 {% elif object.rack and object.position %}
@@ -90,7 +90,7 @@
                         <tr>
                             <th scope="row">Device Type</th>
                             <td>
-                                {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U)
+                                {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U)
                             </td>
                         </tr>
                         <tr>
@@ -153,7 +153,7 @@
             {% include 'inc/panels/comments.html' %}
             {% plugin_left_page object %}
         </div>
-        <div class="col col-md-6">
+        <div class="col col-12 col-xl-6">
             <div class="card">
                 <h5 class="card-header">Management</h5>
                 <div class="card-body">
@@ -286,6 +286,22 @@
             </div>
             {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/image_attachments.html' %}
+            {% if object.rack and object.position %}
+            <div class="row" style="margin-bottom: 20px">
+                <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+                  <div style="margin-left: 30px">
+                    <h4>Front</h4>
+                    {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
+                  </div>
+                </div>
+                <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
+                  <div style="margin-left: 30px">
+                    <h4>Rear</h4>
+                    {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
+                  </div>
+                </div>
+            </div>
+            {% endif %}
             {% plugin_right_page object %}
         </div>
     </div>

+ 1 - 1
netbox/templates/dcim/devicetype.html

@@ -29,7 +29,7 @@
                         </tr>
                         <tr>
                             <td>Height (U)</td>
-                            <td>{{ object.u_height }}</td>
+                            <td>{{ object.u_height|floatformat }}</td>
                         </tr>
                         <tr>
                             <td>Full Depth</td>

+ 0 - 14
netbox/templates/dcim/inc/cabletermination.html

@@ -1,14 +0,0 @@
-<td>
-    {% if termination.parent_object.provider %}
-        <i class="mdi mdi-lightning-bolt" title="Circuit"></i>
-        <a href="{{ termination.parent_object.get_absolute_url }}">
-            {{ termination.parent_object.provider }}
-            {{ termination.parent_object }}
-        </a>
-    {% else %}
-        {{ termination.parent_object|linkify }}
-    {% endif %}
-</td>
-<td>
-    {{ termination|linkify }}
-</td>

+ 36 - 0
netbox/templates/dcim/inc/connection_endpoints.html

@@ -0,0 +1,36 @@
+<table class="table table-hover">
+  <tr>
+    <th scope="row">Cable</th>
+    <td>
+      {{ object.cable|linkify }}
+      <a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
+        <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
+      </a>
+    </td>
+  </tr>
+  <tr>
+    <th scope="row">Path Status</th>
+    <td>
+      {% if object.path.is_complete and object.path.is_active %}
+        <span class="badge bg-success">Reachable</span>
+      {% else %}
+        <span class="badge bg-danger">Not Reachable</span>
+      {% endif %}
+    </td>
+  </tr>
+  <tr>
+    <th scope="row">Path Endpoints</th>
+    <td>
+      {% for endpoint in object.connected_endpoints %}
+        {% if endpoint.parent_object %}
+          {{ endpoint.parent_object|linkify }}
+          <i class="mdi mdi-chevron-right"></i>
+        {% endif %}
+        {{ endpoint|linkify }}
+        {% if not forloop.last %}<br />{% endif %}
+      {% empty %}
+        {{ ''|placeholder }}
+      {% endfor %}
+    </td>
+  </tr>
+</table>

+ 2 - 84
netbox/templates/dcim/interface.html

@@ -144,89 +144,7 @@
                 <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
               </div>
             {% elif object.cable %}
-              <table class="table table-hover">
-                {% if object.connected_endpoint.device %}
-                  <tr>
-                    <td colspan="2">
-                      {% if object.connected_endpoint.enabled %}
-                        <span class="badge bg-success">Enabled</span>
-                      {% else %}
-                        <span class="badge bg-danger">Disabled</span>
-                      {% endif %}
-                    </td>
-                  </tr>
-                {% endif %}
-                <tr>
-                  <th scope="row">Cable</th>
-                  <td>
-                    {{ object.cable|linkify }}
-                    <a href="{% url 'dcim:interface_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                      <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                    </a>
-                  </td>
-                </tr>
-                {% if object.connected_endpoint.device %}
-                  {% with iface=object.connected_endpoint %}
-                    <tr>
-                      <th scope="row">Device</th>
-                      <td>{{ iface.device|linkify }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">Name</th>
-                      <td>{{ iface|linkify:"name" }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">Type</th>
-                      <td>{{ iface.get_type_display }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">LAG</th>
-                      <td>{{ iface.lag|linkify|placeholder }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">Description</th>
-                      <td>{{ iface.description|placeholder }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">MTU</th>
-                      <td>{{ iface.mtu|placeholder }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">MAC Address</th>
-                      <td>{{ iface.mac_address|placeholder }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">802.1Q Mode</th>
-                      <td>{{ iface.get_mode_display }}</td>
-                    </tr>
-                  {% endwith %}
-                {% elif object.connected_endpoint.circuit %}
-                  {% with ct=object.connected_endpoint %}
-                    <tr>
-                      <th scope="row">Provider</th>
-                      <td>{{ ct.circuit.provider|linkify }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">Circuit</th>
-                      <td>{{ ct.circuit|linkify }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">Side</th>
-                      <td>{{ ct.term_side }}</td>
-                    </tr>
-                  {% endwith %}
-                {% endif %}
-                <tr>
-                  <th scope="row">Path Status</th>
-                  <td>
-                    {% if object.path.is_complete and object.path.is_active %}
-                      <span class="badge bg-success">Reachable</span>
-                    {% else %}
-                      <span class="badge bg-danger">Not Reachable</span>
-                    {% endif %}
-                  </td>
-                </tr>
-              </table>
+              {% include 'dcim/inc/connection_endpoints.html' %}
             {% elif object.wireless_link %}
               <table class="table table-hover">
                 <tr>
@@ -238,7 +156,7 @@
                     </a>
                   </td>
                 </tr>
-                {% with peer_interface=object.connected_endpoint %}
+                {% with peer_interface=object.link_peers.0 %}
                   <tr>
                     <th scope="row">Device</th>
                     <td>{{ peer_interface.device|linkify }}</td>

+ 27 - 67
netbox/templates/dcim/powerfeed.html

@@ -41,8 +41,8 @@
                     <tr>
                         <th scope="row">Connected Device</th>
                         <td>
-                            {% if object.connected_endpoint %}
-                                {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
+                            {% if object.connected_endpoints %}
+                                {{ object.connected_endpoints.0.device|linkify }} ({{ object.connected_endpoints.0|linkify:"name" }})
                             {% else %}
                                 {{ ''|placeholder }}
                             {% endif %}
@@ -50,7 +50,7 @@
                     </tr>
                     <tr>
                         <th scope="row">Utilization (Allocated)</th>
-                        {% with utilization=object.connected_endpoint.get_power_draw %}
+                        {% with utilization=object.connected_endpoints.0.get_power_draw %}
                             {% if utilization %}
                                 <td>
                                     {{ utilization.allocated }}VA / {{ object.available_power }}VA
@@ -100,73 +100,33 @@
         {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
-        <div class="card">
-            <h5 class="card-header">
-                Connection
-            </h5>
-            <div class="card-body">
-            {% if object.mark_connected %}
-                <div class="text-muted">
-                  <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
-                </div>
-            {% elif object.cable %}
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">Cable</th>
-                        <td>
-                            {{ object.cable|linkify }}
-                            <a href="{% url 'dcim:powerfeed_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                            </a>
-                        </td>
-                    </tr>
-                    {% if object.connected_endpoint %}
-                        <tr>
-                            <th scope="row">Device</th>
-                            <td>{{ object.connected_endpoint.device|linkify }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Name</th>
-                            <td>{{ object.connected_endpoint|linkify:"name" }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Type</th>
-                            <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Description</th>
-                            <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                        </tr>
-                        <tr>
-                            <th scope="row">Path Status</th>
-                            <td>
-                                {% if object.path.is_active %}
-                                    <span class="badge bg-success">Reachable</span>
-                                {% else %}
-                                    <span class="badge bg-danger">Not Reachable</span>
-                                {% endif %}
-                            </td>
-                        </tr>
-                    {% endif %}
-                </table>
-            {% else %}
-                <div class="text-muted">
-                    Not connected
-                </div>
-            {% endif %}
+      <div class="card">
+        <h5 class="card-header">Connection</h5>
+        <div class="card-body">
+          {% if object.mark_connected %}
+            <div class="text-muted">
+              <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
             </div>
-            {% if not object.mark_connected and not object.cable %}
-            <div class="card-footer">
-            {% if perms.dcim.add_cable %}
-                <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
-                    <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
-                </a>
-                    {% endif %}
+          {% elif object.cable %}
+            {% include 'dcim/inc/connection_endpoints.html' %}
+          {% else %}
+            <div class="text-muted">
+              Not connected
             </div>
-            {% endif %}
+          {% endif %}
+        </div>
+        {% if not object.mark_connected and not object.cable %}
+        <div class="card-footer">
+          {% if perms.dcim.add_cable %}
+            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
+              <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
+            </a>
+          {% endif %}
         </div>
-        {% include 'inc/panels/comments.html' %}
-        {% plugin_right_page object %}
+        {% endif %}
+      </div>
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
     </div>
 </div>
 <div class="row">

+ 21 - 61
netbox/templates/dcim/poweroutlet.html

@@ -58,69 +58,29 @@
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Connection
-                </h5>
-                <div class="card-body">
-                {% if object.mark_connected %}
-                    <div class="text-muted">
-                      <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
-                    </div>
-                {% elif object.cable %}
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Cable</th>
-                            <td>
-                                {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:poweroutlet_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                    <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                </a>
-                            </td>
-                        </tr>
-                        {% if object.connected_endpoint %}
-                            <tr>
-                                <th scope="row">Device</th>
-                                <td>{{ object.connected_endpoint.device|linkify }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Name</th>
-                                <td>{{ object.connected_endpoint|linkify:"name" }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Type</th>
-                                <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Description</th>
-                                <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Path Status</th>
-                                <td>
-                                    {% if object.path.is_active %}
-                                        <span class="badge bg-success">Reachable</span>
-                                    {% else %}
-                                        <span class="badge bg-danger">Not Reachable</span>
-                                    {% endif %}
-                                </td>
-                            </tr>
-                        {% endif %}
-                    </table>
-                {% else %}
-                    <div class="text-muted">
-                        Not Connected
-                        {% if perms.dcim.add_cable %}
-                            <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
-                                <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
-                            </a>
-                        {% endif %}
-                    </div>
-                {% endif %}
+          <div class="card">
+            <h5 class="card-header">Connection</h5>
+            <div class="card-body">
+              {% if object.mark_connected %}
+                <div class="text-muted">
+                  <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
+                </div>
+              {% elif object.cable %}
+                {% include 'dcim/inc/connection_endpoints.html' %}
+              {% else %}
+                <div class="text-muted">
+                  Not Connected
+                  {% if perms.dcim.add_cable %}
+                    <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
+                      <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
+                    </a>
+                  {% endif %}
                 </div>
+              {% endif %}
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
-            {% plugin_right_page object %}
+          </div>
+          {% include 'dcim/inc/panels/inventory_items.html' %}
+          {% plugin_right_page object %}
         </div>
     </div>
     <div class="row mb-3">

+ 31 - 71
netbox/templates/dcim/powerport.html

@@ -58,79 +58,39 @@
             {% plugin_left_page object %}
         </div>
         <div class="col col-md-6">
-            <div class="card">
-                <h5 class="card-header">
-                    Connection
-                </h5>
-                <div class="card-body">
-                {% if object.mark_connected %}
-                    <div class="text-muted">
-                      <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
-                    </div>
-                {% elif object.cable %}
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">Cable</th>
-                            <td>
-                                {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:powerport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
-                                    <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                </a>
-                            </td>
-                        </tr>
-                        {% if object.connected_endpoint %}
-                            <tr>
-                                <th scope="row">Device</th>
-                                <td>{{ object.connected_endpoint.device|linkify }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Name</th>
-                                <td>{{ object.connected_endpoint|linkify:"name" }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Type</th>
-                                <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Description</th>
-                                <td>{{ object.connected_endpoint.description|placeholder }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">Path Status</th>
-                                <td>
-                                    {% if object.path.is_active %}
-                                        <span class="badge bg-success">Reachable</span>
-                                    {% else %}
-                                        <span class="badge bg-danger">Not Reachable</span>
-                                    {% endif %}
-                                </td>
-                            </tr>
-                        {% endif %}
-                    </table>
-                {% else %}
-                    <div class="text-muted">
-                        Not Connected
-                        {% if perms.dcim.add_cable %}
-                            <span class="dropdown float-end">
-                                <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                                    <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
-                                </button>
-                                <ul class="dropdown-menu dropdown-menu-end">
-                                    <li>
-                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
-                                    </li>
-                                    <li>
-                                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
-                                    </li>
-                                </ul>
-                            </span>
-                        {% endif %}
-                    </div>
-                {% endif %}
+          <div class="card">
+            <h5 class="card-header">Connection</h5>
+            <div class="card-body">
+              {% if object.mark_connected %}
+                <div class="text-muted">
+                  <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
+                </div>
+              {% elif object.cable %}
+                {% include 'dcim/inc/connection_endpoints.html' %}
+              {% else %}
+                <div class="text-muted">
+                  Not Connected
+                  {% if perms.dcim.add_cable %}
+                    <span class="dropdown float-end">
+                      <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+                        <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
+                      </button>
+                      <ul class="dropdown-menu dropdown-menu-end">
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Outlet</a>
+                        </li>
+                        <li>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-link">Power Feed</a>
+                        </li>
+                      </ul>
+                    </span>
+                  {% endif %}
                 </div>
+              {% endif %}
             </div>
-            {% include 'dcim/inc/panels/inventory_items.html' %}
-            {% plugin_right_page object %}
+          </div>
+          {% include 'dcim/inc/panels/inventory_items.html' %}
+          {% plugin_right_page object %}
         </div>
     </div>
     <div class="row">

+ 3 - 0
netbox/templates/tenancy/contactassignment_edit.html

@@ -3,6 +3,9 @@
 {% load form_helpers %}
 
 {% block form %}
+  {% for field in form.hidden_fields %}
+    {{ field }}
+  {% endfor %}
   <div class="field-group my-5">
     <div class="row mb-2">
       <h5 class="offset-sm-3">Contact Assignment</h5>

+ 2 - 2
netbox/templates/virtualization/cluster.html

@@ -21,7 +21,7 @@
                 </tr>
                 <tr>
                     <th scope="row">Group</th>
-                    <td>{{ object.group|linkify }}</td>
+                    <td>{{ object.group|linkify|placeholder }}</td>
                 </tr>
                 <tr>
                     <th scope="row">Tenant</th>
@@ -34,7 +34,7 @@
                 </tr>
                 <tr>
                     <th scope="row">Site</th>
-                    <td>{{ object.site|linkify }}</td>
+                    <td>{{ object.site|linkify|placeholder }}</td>
                 </tr>
                 <tr>
                     <th scope="row">Virtual Machines</th>

+ 3 - 1
netbox/tenancy/forms/models.py

@@ -119,8 +119,10 @@ class ContactAssignmentForm(BootstrapMixin, forms.ModelForm):
     class Meta:
         model = ContactAssignment
         fields = (
-            'group', 'contact', 'role', 'priority',
+            'content_type', 'object_id', 'group', 'contact', 'role', 'priority',
         )
         widgets = {
+            'content_type': forms.HiddenInput(),
+            'object_id': forms.HiddenInput(),
             'priority': StaticSelect(),
         }

+ 2 - 2
netbox/tenancy/models/contacts.py

@@ -163,8 +163,8 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel):
 
     def __str__(self):
         if self.priority:
-            return f"{self.contact} ({self.get_priority_display()})"
-        return str(self.contact)
+            return f"{self.contact} ({self.get_priority_display()}) -> {self.object}"
+        return str(f"{self.contact} -> {self.object}")
 
     def get_absolute_url(self):
         return reverse('tenancy:contact', args=[self.contact.pk])

+ 1 - 1
netbox/tenancy/tables/tenants.py

@@ -42,7 +42,7 @@ class TenantTable(NetBoxTable):
         linkify_item=True
     )
     tags = columns.TagColumn(
-        url_name='tenancy:tenant_list'
+        url_name='tenancy:contact_list'
     )
 
     class Meta(NetBoxTable.Meta):

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

@@ -73,9 +73,9 @@ def humanize_megabytes(mb):
     """
     if not mb:
         return ''
-    if mb >= 1048576:
+    if not mb % 1048576:  # 1024^2
         return f'{int(mb / 1048576)} TB'
-    if mb >= 1024:
+    if not mb % 1024:
         return f'{int(mb / 1024)} GB'
     return f'{mb} MB'
 

+ 5 - 7
netbox/virtualization/models.py

@@ -347,14 +347,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
             })
 
         # Validate site for cluster & device
-        if self.cluster and self.cluster.site != self.site:
+        if self.cluster and self.site and self.cluster.site != self.site:
             raise ValidationError({
-                'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).'
-            })
-        if self.device and self.device.site != self.site:
-            raise ValidationError({
-                'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).'
+                'cluster': f'The selected cluster ({self.cluster}) is not assigned to this site ({self.site}).'
             })
+        elif self.cluster:
+            self.site = self.cluster.site
 
         # Validate assigned cluster device
         if self.device and not self.cluster:
@@ -363,7 +361,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
             })
         if self.device and self.device not in self.cluster.devices.all():
             raise ValidationError({
-                'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).'
+                'device': f'The selected device ({self.device}) is not assigned to this cluster ({self.cluster}).'
             })
 
         # Validate primary IP addresses

+ 4 - 3
netbox/virtualization/tests/test_models.py

@@ -68,6 +68,7 @@ class VirtualMachineTestCase(TestCase):
         with self.assertRaises(ValidationError):
             VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
 
-        # VM with cluster site but no direct site should fail
-        with self.assertRaises(ValidationError):
-            VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean()
+        # VM with cluster site but no direct site should have its site set automatically
+        vm = VirtualMachine(name='vm1', site=None, cluster=clusters[0])
+        vm.full_clean()
+        self.assertEqual(vm.site, sites[0])

+ 9 - 0
pyproject.toml

@@ -11,3 +11,12 @@ profile = "black"
 
 [tool.pylint]
 max-line-length = 120
+
+[tool.pyright]
+include = ["netbox"]
+exclude = [
+    "**/node_modules",
+    "**/__pycache__",
+]
+reportMissingImports = true
+reportMissingTypeStubs = false

+ 9 - 9
requirements.txt

@@ -1,10 +1,10 @@
 bleach==5.0.1
-Django==4.0.7
+Django==4.0.8
 django-cors-headers==3.13.0
-django-debug-toolbar==3.6.0
+django-debug-toolbar==3.7.0
 django-filter==22.1
 django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.13.4
+django-mptt==0.14
 django-pglocks==1.0.4
 django-prometheus==2.2.0
 django-redis==5.2.0
@@ -13,24 +13,24 @@ django-rq==2.5.1
 django-tables2==2.4.1
 django-taggit==3.0.0
 django-timezone-field==5.0
-djangorestframework==3.13.1
-drf-yasg[validation]==1.21.3
+djangorestframework==3.14.0
+drf-yasg[validation]==1.21.4
 graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
-Markdown==3.4.1
-mkdocs-material==8.5.1
+Markdown==3.3.7
+mkdocs-material==8.5.6
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.2.0
 psycopg2-binary==2.9.3
 PyYAML==6.0
-sentry-sdk==1.9.8
+sentry-sdk==1.9.10
 social-auth-app-django==5.0.0
 social-auth-core==4.3.0
 svgwrite==1.4.3
 tablib==3.2.1
-tzdata==2022.2
+tzdata==2022.4
 
 # Workaround for #7401
 jsonschema==3.2.0

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