2
0
Эх сурвалжийг харах

Merge pull request #10570 from netbox-community/develop

Release v3.3.5
Jeremy Stretch 3 жил өмнө
parent
commit
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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.3.4
+      placeholder: v3.3.5
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

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

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

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

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

@@ -4,6 +4,11 @@ name: 'Lock threads'
 on:
 on:
   schedule:
   schedule:
     - cron: '0 3 * * *'
     - cron: '0 3 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+  pull-requests: write
 
 
 jobs:
 jobs:
   lock:
   lock:
@@ -11,7 +16,6 @@ jobs:
     steps:
     steps:
       - uses: dessant/lock-threads@v3
       - uses: dessant/lock-threads@v3
         with:
         with:
-          github-token: ${{ github.token }}
           issue-inactive-days: 90
           issue-inactive-days: 90
           pr-inactive-days: 30
           pr-inactive-days: 30
           issue-lock-reason: 'resolved'
           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)
 # close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
 name: 'Close stale issues/PRs'
 name: 'Close stale issues/PRs'
+
 on:
 on:
   schedule:
   schedule:
     - cron: '0 4 * * *'
     - cron: '0 4 * * *'
+  workflow_dispatch:
+
+permissions:
+  issues: write
+  pull-requests: write
 
 
 jobs:
 jobs:
   stale:
   stale:
+
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/stale@v5
+      - uses: actions/stale@v6
         with:
         with:
           close-issue-message: >
           close-issue-message: >
             This issue has been automatically closed due to lack of activity. In an
             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)
 # Django wrapper for Graphene (GraphQL support)
 # https://github.com/graphql-python/graphene-django
 # https://github.com/graphql-python/graphene-django
-graphene_django
+graphene_django<3.0
 
 
 # WSGI HTTP server
 # WSGI HTTP server
 # https://gunicorn.org/
 # https://gunicorn.org/
@@ -80,7 +80,8 @@ Jinja2
 
 
 # Simple markup language for rendering HTML
 # Simple markup language for rendering HTML
 # https://github.com/Python-Markdown/markdown
 # https://github.com/Python-Markdown/markdown
-Markdown
+# mkdocs currently requires Markdown v3.3
+Markdown<3.4
 
 
 # File inclusion plugin for Python-Markdown
 # File inclusion plugin for Python-Markdown
 # https://github.com/cmacmackin/markdown-include
 # https://github.com/cmacmackin/markdown-include

+ 2 - 2
docs/_theme/main.html

@@ -2,8 +2,8 @@
 
 
 {% block site_meta %}
 {% block site_meta %}
   {{ super() }}
   {{ 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">
     <meta name="robots" content="noindex">
   {% endif %}
   {% endif %}
 {% endblock %}
 {% 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
 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
 ```python
 HTTP_PROXIES = {
 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.
 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
 ## Variable Reference
 
 
 ### Default Options
 ### 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.
 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
 ## 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:
 `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
 # 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)
 ## v3.3.4 (2022-09-16)
 
 
 ### Bug Fixes
 ### Bug Fixes

+ 0 - 1
mkdocs.yml

@@ -38,7 +38,6 @@ plugins:
             show_root_toc_entry: false
             show_root_toc_entry: false
             show_source: false
             show_source: false
 extra:
 extra:
-  readthedocs: !ENV READTHEDOCS
   social:
   social:
     - icon: fontawesome/brands/github
     - icon: fontawesome/brands/github
       link: https://github.com/netbox-community/netbox
       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',
             'front_image', 'rear_image', 'comments', 'tags',
         ]
         ]
         widgets = {
         widgets = {
+            'airflow': StaticSelect(),
             'subdevice_role': StaticSelect(),
             'subdevice_role': StaticSelect(),
             'front_image': ClearableFileInput(attrs={
             'front_image': ClearableFileInput(attrs={
                 'accept': DEVICETYPE_IMAGE_FORMATS
                 'accept': DEVICETYPE_IMAGE_FORMATS
@@ -678,6 +679,7 @@ class ModuleForm(NetBoxModelForm):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
         if self.instance.pk:
         if self.instance.pk:
+            self.fields['device'].disabled = True
             self.fields['replicate_components'].initial = False
             self.fields['replicate_components'].initial = False
             self.fields['replicate_components'].disabled = True
             self.fields['replicate_components'].disabled = True
             self.fields['adopt_components'].initial = False
             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):
     def get_absolute_url(self):
         return reverse('dcim:module', args=[self.pk])
         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):
     def save(self, *args, **kwargs):
         is_new = self.pk is None
         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):
     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
         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.conf import settings
 from django.core.exceptions import FieldError
 from django.core.exceptions import FieldError
 from django.db.models import Q
 from django.db.models import Q
+from django.template.defaultfilters import floatformat
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.http import urlencode
 from django.utils.http import urlencode
 
 
@@ -41,7 +42,7 @@ def get_device_description(device):
         device.device_role,
         device.device_role,
         device.device_type.manufacturer.name,
         device.device_type.manufacturer.name,
         device.device_type.model,
         device.device_type.model,
-        device.device_type.u_height,
+        floatformat(device.device_type.u_height),
         device.asset_tag or '',
         device.asset_tag or '',
         device.serial or ''
         device.serial or ''
     )
     )

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

@@ -85,6 +85,9 @@ class DeviceTypeTable(NetBoxTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:devicetype_list'
         url_name='dcim:devicetype_list'
     )
     )
+    u_height = columns.TemplateColumn(
+        template_code='{{ value|floatformat }}'
+    )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):
         model = DeviceType
         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 2'),
             ModuleBay(device=devices[0], name='Module Bay 3'),
             ModuleBay(device=devices[0], name='Module Bay 3'),
             ModuleBay(device=devices[0], name='Module Bay 4'),
             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 1'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[1], name='Module Bay 3'),
             ModuleBay(device=devices[1], name='Module Bay 3'),
             ModuleBay(device=devices[1], name='Module Bay 4'),
             ModuleBay(device=devices[1], name='Module Bay 4'),
+            ModuleBay(device=devices[1], name='Module Bay 5'),
         )
         )
         ModuleBay.objects.bulk_create(module_bays)
         ModuleBay.objects.bulk_create(module_bays)
 
 
@@ -1795,7 +1797,7 @@ class ModuleTestCase(
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
-            'device': devices[1].pk,
+            'device': devices[0].pk,
             'module_bay': module_bays[3].pk,
             'module_bay': module_bays[3].pk,
             'module_type': module_types[0].pk,
             'module_type': module_types[0].pk,
             'serial': 'A',
             'serial': 'A',
@@ -1867,7 +1869,6 @@ class ModuleTestCase(
         self.assertIsNone(interface.module)
         self.assertIsNone(interface.module)
 
 
         # Create a module with adopted components
         # Create a module with adopted components
-        form_data['module_bay'] = ModuleBay.objects.filter(device=device).first()
         form_data['module_type'] = module_type
         form_data['module_type'] = module_type
         form_data['replicate_components'] = False
         form_data['replicate_components'] = False
         form_data['adopt_components'] = True
         form_data['adopt_components'] = True

+ 3 - 2
netbox/dcim/views.py

@@ -355,7 +355,7 @@ class SiteView(generic.ObjectView):
 
 
         nonracked_devices = Device.objects.filter(
         nonracked_devices = Device.objects.filter(
             site=instance,
             site=instance,
-            position__isnull=True,
+            rack__isnull=True,
             parent_bay__isnull=True
             parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
 
 
@@ -450,7 +450,7 @@ class LocationView(generic.ObjectView):
 
 
         nonracked_devices = Device.objects.filter(
         nonracked_devices = Device.objects.filter(
             location=instance,
             location=instance,
-            position__isnull=True,
+            rack__isnull=True,
             parent_bay__isnull=True
             parent_bay__isnull=True
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
         ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
 
 
@@ -1616,6 +1616,7 @@ class DeviceView(generic.ObjectView):
         return {
         return {
             'services': services,
             'services': services,
             'vc_members': vc_members,
             '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)
         return ContentType.objects.get_for_model(self.model)
 
 
     def _get_custom_fields(self, content_type):
     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):
     def _get_form_field(self, customfield):
         return customfield.to_form_field()
         return customfield.to_form_field()
@@ -50,13 +52,6 @@ class CustomFieldsMixin:
             field_name = f'cf_{customfield.name}'
             field_name = f'cf_{customfield.name}'
             self.fields[field_name] = self._get_form_field(customfield)
             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
             # Annotate the field in the list of CustomField form fields
             self.custom_fields[field_name] = customfield
             self.custom_fields[field_name] = customfield
             if customfield.group_name not in self.custom_field_groups:
             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 model.objects.filter(pk__in=value)
         return 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.
         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.
         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_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.
         for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
         """
         """
         initial = self.default if set_initial else None
         initial = self.default if set_initial else None
@@ -398,6 +399,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLogge
         if self.description:
         if self.description:
             field.help_text = escape(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
         return field
 
 
     def to_filter(self, lookup_expr=None):
     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):
     def get_absolute_url(self):
         return reverse('extras:journalentry', args=[self.pk])
         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):
     def get_kind_color(self):
         return JournalEntryKindChoices.colors.get(self.kind)
         return JournalEntryKindChoices.colors.get(self.kind)
 
 

+ 24 - 20
netbox/ipam/querysets.py

@@ -81,30 +81,34 @@ class VLANQuerySet(RestrictedQuerySet):
 
 
         # Find all relevant VLANGroups
         # Find all relevant VLANGroups
         q = Q()
         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(
                 q |= Q(
                     scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'),
                     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(
                 q |= Q(
                     scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'),
                     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)
         vlan_groups = VLANGroup.objects.filter(q)
 
 
         # Return all applicable VLANs
         # 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__scope_id__isnull=True, site__isnull=True) |  # Global group VLANs
             Q(group__isnull=True, site__isnull=True)  # Global 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)
         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.contrib.contenttypes.models import ContentType
 from django.db.models import Q
 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.forms.customfields import CustomFieldsMixin
 from extras.models import CustomField, Tag
 from extras.models import CustomField, Tag
 from utilities.forms import BootstrapMixin, CSVModelForm
 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)
     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):
     def _get_form_field(self, customfield):
         return customfield.to_form_field(for_csv_import=True)
         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):
     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(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
             Q(type=CustomFieldTypeChoices.TYPE_JSON)
             Q(type=CustomFieldTypeChoices.TYPE_JSON)
         )
         )
 
 
     def _get_form_field(self, customfield):
     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,
     CustomLinksMixin,
     CustomValidationMixin,
     CustomValidationMixin,
     ExportTemplatesMixin,
     ExportTemplatesMixin,
-    JournalingMixin,
     TagsMixin,
     TagsMixin,
     WebhooksMixin
     WebhooksMixin
 ):
 ):
@@ -51,7 +50,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model)
         abstract = True
         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.
     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
 # Environment setup
 #
 #
 
 
-VERSION = '3.3.4'
+VERSION = '3.3.5'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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 {
 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');
   const contentContainer = document.querySelector<HTMLElement>('.content-container');
   if (contentContainer !== null) {
   if (contentContainer !== null) {
     // Focus the content container for accessible navigation.
     // Focus the content container for accessible navigation.

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

@@ -54,80 +54,40 @@
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-md-6">
         <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>
                 </div>
+              {% endif %}
             </div>
             </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>
     </div>
     <div class="row">
     <div class="row">

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

@@ -54,82 +54,40 @@
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-md-6">
         <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>
                     </div>
-                {% endif %}
+                  {% endif %}
                 </div>
                 </div>
+              {% endif %}
             </div>
             </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>
     </div>
     <div class="row">
     <div class="row">

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

@@ -7,7 +7,7 @@
 
 
 {% block content %}
 {% block content %}
     <div class="row">
     <div class="row">
-        <div class="col col-md-6">
+        <div class="col col-12 col-xl-6">
             <div class="card">
             <div class="card">
                 <h5 class="card-header">
                 <h5 class="card-header">
                     Device
                     Device
@@ -66,7 +66,7 @@
                                     {% with object.parent_bay.device as parent %}
                                     {% with object.parent_bay.device as parent %}
                                         {{ parent|linkify }} / {{ object.parent_bay }}
                                         {{ parent|linkify }} / {{ object.parent_bay }}
                                         {% if parent.position %}
                                         {% if parent.position %}
-                                            (U{{ parent.position }} / {{ parent.get_face_display }})
+                                            (U{{ parent.position|floatformat }} / {{ parent.get_face_display }})
                                         {% endif %}
                                         {% endif %}
                                     {% endwith %}
                                     {% endwith %}
                                 {% elif object.rack and object.position %}
                                 {% elif object.rack and object.position %}
@@ -90,7 +90,7 @@
                         <tr>
                         <tr>
                             <th scope="row">Device Type</th>
                             <th scope="row">Device Type</th>
                             <td>
                             <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>
                             </td>
                         </tr>
                         </tr>
                         <tr>
                         <tr>
@@ -153,7 +153,7 @@
             {% include 'inc/panels/comments.html' %}
             {% include 'inc/panels/comments.html' %}
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
-        <div class="col col-md-6">
+        <div class="col col-12 col-xl-6">
             <div class="card">
             <div class="card">
                 <h5 class="card-header">Management</h5>
                 <h5 class="card-header">Management</h5>
                 <div class="card-body">
                 <div class="card-body">
@@ -286,6 +286,22 @@
             </div>
             </div>
             {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/image_attachments.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 %}
             {% plugin_right_page object %}
         </div>
         </div>
     </div>
     </div>

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

@@ -29,7 +29,7 @@
                         </tr>
                         </tr>
                         <tr>
                         <tr>
                             <td>Height (U)</td>
                             <td>Height (U)</td>
-                            <td>{{ object.u_height }}</td>
+                            <td>{{ object.u_height|floatformat }}</td>
                         </tr>
                         </tr>
                         <tr>
                         <tr>
                             <td>Full Depth</td>
                             <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
                 <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as Connected
               </div>
               </div>
             {% elif object.cable %}
             {% 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 %}
             {% elif object.wireless_link %}
               <table class="table table-hover">
               <table class="table table-hover">
                 <tr>
                 <tr>
@@ -238,7 +156,7 @@
                     </a>
                     </a>
                   </td>
                   </td>
                 </tr>
                 </tr>
-                {% with peer_interface=object.connected_endpoint %}
+                {% with peer_interface=object.link_peers.0 %}
                   <tr>
                   <tr>
                     <th scope="row">Device</th>
                     <th scope="row">Device</th>
                     <td>{{ peer_interface.device|linkify }}</td>
                     <td>{{ peer_interface.device|linkify }}</td>

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

@@ -41,8 +41,8 @@
                     <tr>
                     <tr>
                         <th scope="row">Connected Device</th>
                         <th scope="row">Connected Device</th>
                         <td>
                         <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 %}
                             {% else %}
                                 {{ ''|placeholder }}
                                 {{ ''|placeholder }}
                             {% endif %}
                             {% endif %}
@@ -50,7 +50,7 @@
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <th scope="row">Utilization (Allocated)</th>
                         <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 %}
                             {% if utilization %}
                                 <td>
                                 <td>
                                     {{ utilization.allocated }}VA / {{ object.available_power }}VA
                                     {{ utilization.allocated }}VA / {{ object.available_power }}VA
@@ -100,73 +100,33 @@
         {% plugin_left_page object %}
         {% plugin_left_page object %}
     </div>
     </div>
     <div class="col col-md-6">
     <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>
             </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>
             </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>
         </div>
-        {% include 'inc/panels/comments.html' %}
-        {% plugin_right_page object %}
+        {% endif %}
+      </div>
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
     </div>
     </div>
 </div>
 </div>
 <div class="row">
 <div class="row">

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

@@ -58,69 +58,29 @@
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-md-6">
         <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>
                 </div>
+              {% endif %}
             </div>
             </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>
     </div>
     <div class="row mb-3">
     <div class="row mb-3">

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

@@ -58,79 +58,39 @@
             {% plugin_left_page object %}
             {% plugin_left_page object %}
         </div>
         </div>
         <div class="col col-md-6">
         <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>
                 </div>
+              {% endif %}
             </div>
             </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>
     </div>
     <div class="row">
     <div class="row">

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

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

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

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

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

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

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

@@ -163,8 +163,8 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel):
 
 
     def __str__(self):
     def __str__(self):
         if self.priority:
         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):
     def get_absolute_url(self):
         return reverse('tenancy:contact', args=[self.contact.pk])
         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
         linkify_item=True
     )
     )
     tags = columns.TagColumn(
     tags = columns.TagColumn(
-        url_name='tenancy:tenant_list'
+        url_name='tenancy:contact_list'
     )
     )
 
 
     class Meta(NetBoxTable.Meta):
     class Meta(NetBoxTable.Meta):

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

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

+ 5 - 7
netbox/virtualization/models.py

@@ -347,14 +347,12 @@ class VirtualMachine(NetBoxModel, ConfigContextModel):
             })
             })
 
 
         # Validate site for cluster & device
         # 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({
             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
         # Validate assigned cluster device
         if self.device and not self.cluster:
         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():
         if self.device and self.device not in self.cluster.devices.all():
             raise ValidationError({
             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
         # Validate primary IP addresses

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

@@ -68,6 +68,7 @@ class VirtualMachineTestCase(TestCase):
         with self.assertRaises(ValidationError):
         with self.assertRaises(ValidationError):
             VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean()
             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]
 [tool.pylint]
 max-line-length = 120
 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
 bleach==5.0.1
-Django==4.0.7
+Django==4.0.8
 django-cors-headers==3.13.0
 django-cors-headers==3.13.0
-django-debug-toolbar==3.6.0
+django-debug-toolbar==3.7.0
 django-filter==22.1
 django-filter==22.1
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
-django-mptt==0.13.4
+django-mptt==0.14
 django-pglocks==1.0.4
 django-pglocks==1.0.4
 django-prometheus==2.2.0
 django-prometheus==2.2.0
 django-redis==5.2.0
 django-redis==5.2.0
@@ -13,24 +13,24 @@ django-rq==2.5.1
 django-tables2==2.4.1
 django-tables2==2.4.1
 django-taggit==3.0.0
 django-taggit==3.0.0
 django-timezone-field==5.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
 graphene-django==2.15.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 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
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.2.0
 Pillow==9.2.0
 psycopg2-binary==2.9.3
 psycopg2-binary==2.9.3
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.9.8
+sentry-sdk==1.9.10
 social-auth-app-django==5.0.0
 social-auth-app-django==5.0.0
 social-auth-core==4.3.0
 social-auth-core==4.3.0
 svgwrite==1.4.3
 svgwrite==1.4.3
 tablib==3.2.1
 tablib==3.2.1
-tzdata==2022.2
+tzdata==2022.4
 
 
 # Workaround for #7401
 # Workaround for #7401
 jsonschema==3.2.0
 jsonschema==3.2.0

Энэ ялгаанд хэт олон файл өөрчлөгдсөн тул зарим файлыг харуулаагүй болно