diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 11b7e9aff..907ad6cf7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index bc00a3921..3cd9bc4ee 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67f5028cd..d75f98fbc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,7 @@ name: CI on: [push, pull_request] +permissions: + contents: read jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index 9df4bc441..6019cef5d 100644 --- a/.github/workflows/lock.yml +++ b/.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' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 57666417a..ab259af2a 100644 --- a/.github/workflows/stale.yml +++ b/.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 diff --git a/base_requirements.txt b/base_requirements.txt index 363f97b31..cc8695d6c 100644 --- a/base_requirements.txt +++ b/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 diff --git a/docs/_theme/main.html b/docs/_theme/main.html index 4dfc4e14e..3ff44b9cb 100644 --- a/docs/_theme/main.html +++ b/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/' %} {% endif %} {% endblock %} diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 21607e566..93f8fa902 100644 --- a/docs/configuration/system.md +++ b/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 = { diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 230b003c6..e5d5a1ef5 100644 --- a/docs/customization/custom-scripts.md +++ b/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 diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index 98db9e0bb..a2f3b8ce9 100644 --- a/docs/plugins/development/index.md +++ b/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: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index a51089c70..daf542022 100644 --- a/docs/release-notes/version-3.3.md +++ b/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 diff --git a/mkdocs.yml b/mkdocs.yml index 530c6d52e..a10fd6e67 100644 --- a/mkdocs.yml +++ b/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 diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 5728e7f2d..1f1c869a5 100644 --- a/netbox/dcim/forms/models.py +++ b/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 diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ccf4613bf..4c542341e 100644 --- a/netbox/dcim/models/devices.py +++ b/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 diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 3872bc4fe..9a847acc9 100644 --- a/netbox/dcim/svg/cables.py +++ b/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 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 573fc966c..6c57e6023 100644 --- a/netbox/dcim/svg/racks.py +++ b/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 '' ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 3ed4d8c08..ec71245f7 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/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 diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 50b36e36d..db3495521 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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 diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index aee0cb384..5930d6b2d 100644 --- a/netbox/dcim/views.py +++ b/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}' } diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index 7574f4f2b..40d068450 100644 --- a/netbox/extras/forms/customfields.py +++ b/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 += '
' - self.fields[field_name].help_text += ' ' \ - '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: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 43c4f9671..d52d73848 100644 --- a/netbox/extras/models/customfields.py +++ b/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 = '
' if field.help_text else '' + field.help_text += f'{prepend} Field is set to read-only.' + return field def to_filter(self, lookup_expr=None): diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 0df34c146..1bcc91d62 100644 --- a/netbox/extras/models/models.py +++ b/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) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index 7edac2eff..9f4463f61 100644 --- a/netbox/ipam/querysets.py +++ b/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('dcim', 'region'), - scope_id__in=vm.cluster.site.region.get_ancestors(include_self=True) - ) - if vm.cluster.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_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=vm.cluster.site_id + scope_id=site.pk ) - 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 - ) + if site.region: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'region'), + scope_id__in=site.region.get_ancestors(include_self=True) + ) + if site.group: + q |= Q( + scope_type=ContentType.objects.get_by_natural_key('dcim', 'sitegroup'), + scope_id__in=site.group.get_ancestors(include_self=True) + ) 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) diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 2676e4cde..2cbc67971 100644 --- a/netbox/netbox/forms/base.py +++ b/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) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index aefb733b4..1385dd585 100644 --- a/netbox/netbox/models/__init__.py +++ b/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. """ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index e24ac6492..03e7eacc0 100644 --- a/netbox/netbox/settings.py +++ b/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() diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 6d0aa1535..fe7a7e569 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6c04b9849..ff7b5a41d 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/src/netbox.ts b/netbox/project-static/src/netbox.ts index c178a2dbd..9bf23410d 100644 --- a/netbox/project-static/src/netbox.ts +++ b/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('.content-container'); if (contentContainer !== null) { // Focus the content container for accessible navigation. diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 39ffbf552..ad4f15c9d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -54,80 +54,40 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} - Marked as connected - {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - {% endif %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} + Marked as connected + {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + {% endif %}
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 642e758a3..a543cd5ff 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -54,82 +54,40 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} - Marked as connected - {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device - {{ object.connected_endpoint.device|linkify }} -
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} + Marked as connected + {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + - {% endif %} + {% endif %}
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 6cc859749..d800658a5 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -7,7 +7,7 @@ {% block content %}
-
+
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 @@ Device Type - {{ 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) @@ -153,7 +153,7 @@ {% include 'inc/panels/comments.html' %} {% plugin_left_page object %}
-
+
Management
@@ -286,6 +286,22 @@
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} + {% if object.rack and object.position %} +
+
+
+

Front

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %} +
+
+
+
+

Rear

+ {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %} +
+
+
+ {% endif %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index bb3ec9d2e..1fde72d27 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -29,7 +29,7 @@ Height (U) - {{ object.u_height }} + {{ object.u_height|floatformat }} Full Depth diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html deleted file mode 100644 index c7fa7918a..000000000 --- a/netbox/templates/dcim/inc/cabletermination.html +++ /dev/null @@ -1,14 +0,0 @@ - - {% if termination.parent_object.provider %} - - - {{ termination.parent_object.provider }} - {{ termination.parent_object }} - - {% else %} - {{ termination.parent_object|linkify }} - {% endif %} - - - {{ termination|linkify }} - diff --git a/netbox/templates/dcim/inc/connection_endpoints.html b/netbox/templates/dcim/inc/connection_endpoints.html new file mode 100644 index 000000000..fb994a492 --- /dev/null +++ b/netbox/templates/dcim/inc/connection_endpoints.html @@ -0,0 +1,36 @@ + + + + + + + + + + + + + +
Cable + {{ object.cable|linkify }} + + + +
Path Status + {% if object.path.is_complete and object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
Path Endpoints + {% for endpoint in object.connected_endpoints %} + {% if endpoint.parent_object %} + {{ endpoint.parent_object|linkify }} + + {% endif %} + {{ endpoint|linkify }} + {% if not forloop.last %}
{% endif %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 1216f3e88..887433d7b 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -144,89 +144,7 @@ Marked as Connected
{% elif object.cable %} - - {% if object.connected_endpoint.device %} - - - - {% endif %} - - - - - {% if object.connected_endpoint.device %} - {% with iface=object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% endwith %} - {% elif object.connected_endpoint.circuit %} - {% with ct=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - -
- {% if object.connected_endpoint.enabled %} - Enabled - {% else %} - Disabled - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ iface.device|linkify }}
Name{{ iface|linkify:"name" }}
Type{{ iface.get_type_display }}
LAG{{ iface.lag|linkify|placeholder }}
Description{{ iface.description|placeholder }}
MTU{{ iface.mtu|placeholder }}
MAC Address{{ iface.mac_address|placeholder }}
802.1Q Mode{{ iface.get_mode_display }}
Provider{{ ct.circuit.provider|linkify }}
Circuit{{ ct.circuit|linkify }}
Side{{ ct.term_side }}
Path Status - {% if object.path.is_complete and object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
+ {% include 'dcim/inc/connection_endpoints.html' %} {% elif object.wireless_link %} @@ -238,7 +156,7 @@ - {% with peer_interface=object.connected_endpoint %} + {% with peer_interface=object.link_peers.0 %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 584454df8..54ac96bab 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -41,8 +41,8 @@ - {% with utilization=object.connected_endpoint.get_power_draw %} + {% with utilization=object.connected_endpoints.0.get_power_draw %} {% if utilization %} - + @@ -34,7 +34,7 @@ - + diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 021e36a5b..eabcb1d0f 100644 --- a/netbox/tenancy/forms/models.py +++ b/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(), } diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 41881f853..1dba814a6 100644 --- a/netbox/tenancy/models/contacts.py +++ b/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]) diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py index 8f18423be..f18f1db09 100644 --- a/netbox/tenancy/tables/tenants.py +++ b/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): diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 67ed553b2..462b37feb 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/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' diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index abad57f88..b07e51790 100644 --- a/netbox/virtualization/models.py +++ b/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 diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index df5816efa..e916486b0 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/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]) diff --git a/pyproject.toml b/pyproject.toml index 6d579b737..177b44d86 100644 --- a/pyproject.toml +++ b/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 diff --git a/requirements.txt b/requirements.txt index f868c4f0d..ead32eeae 100644 --- a/requirements.txt +++ b/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
Device {{ peer_interface.device|linkify }}
Connected Device - {% 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 @@
Utilization (Allocated) {{ utilization.allocated }}VA / {{ object.available_power }}VA @@ -100,73 +100,33 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not connected -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as connected
- {% if not object.mark_connected and not object.cable %} - - {% include 'inc/panels/comments.html' %} - {% plugin_right_page object %} + {% if not object.mark_connected and not object.cable %} + + {% endif %} +
+ {% include 'inc/panels/comments.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 26f4a07f8..fb6de8ddb 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -58,69 +58,29 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as Connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - Connect - - {% endif %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as Connected
+ {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + Connect + + {% endif %} +
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index c5eccbf14..c552c2398 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -58,79 +58,39 @@ {% plugin_left_page object %}
-
-
- Connection -
-
- {% if object.mark_connected %} -
- Marked as Connected -
- {% elif object.cable %} - - - - - - {% if object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - {% endif %} -
Cable - {{ object.cable|linkify }} - - - -
Device{{ object.connected_endpoint.device|linkify }}
Name{{ object.connected_endpoint|linkify:"name" }}
Type{{ object.connected_endpoint.get_type_display|placeholder }}
Description{{ object.connected_endpoint.description|placeholder }}
Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
- {% else %} -
- Not Connected - {% if perms.dcim.add_cable %} - - - - - {% endif %} -
- {% endif %} +
+
Connection
+
+ {% if object.mark_connected %} +
+ Marked as Connected
+ {% elif object.cable %} + {% include 'dcim/inc/connection_endpoints.html' %} + {% else %} +
+ Not Connected + {% if perms.dcim.add_cable %} + + + + + {% endif %} +
+ {% endif %}
- {% include 'dcim/inc/panels/inventory_items.html' %} - {% plugin_right_page object %} +
+ {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
diff --git a/netbox/templates/tenancy/contactassignment_edit.html b/netbox/templates/tenancy/contactassignment_edit.html index 4d1747e72..d904deead 100644 --- a/netbox/templates/tenancy/contactassignment_edit.html +++ b/netbox/templates/tenancy/contactassignment_edit.html @@ -3,6 +3,9 @@ {% load form_helpers %} {% block form %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %}
Contact Assignment
diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 8acbb61f4..bf7c8a69a 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -21,7 +21,7 @@
Group{{ object.group|linkify }}{{ object.group|linkify|placeholder }}
Tenant
Site{{ object.site|linkify }}{{ object.site|linkify|placeholder }}
Virtual Machines