diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 1a7c49682..5252adb06 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -15,7 +15,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.3.5 + placeholder: v4.3.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index c6d28fbe6..20836a5e3 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -27,7 +27,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.3.5 + placeholder: v4.3.6 validations: required: true - type: dropdown diff --git a/.github/codeql/codeql-config.yml b/.github/codeql/codeql-config.yml new file mode 100644 index 000000000..f763ef6df --- /dev/null +++ b/.github/codeql/codeql-config.yml @@ -0,0 +1,3 @@ +paths-ignore: + # Ignore compiled JS + - netbox/project-static/dist diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..899242128 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,42 @@ +name: "CodeQL" + +on: + push: + branches: [ "main", "feature" ] + pull_request: + branches: [ "main", "feature" ] + schedule: + - cron: '38 16 * * 4' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + permissions: + security-events: write + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + config-file: .github/codeql/codeql-config.yml + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index baecb910f..9c115cf14 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -95,6 +95,7 @@ "iec-60320-c8", "iec-60320-c14", "iec-60320-c16", + "iec-60320-c18", "iec-60320-c20", "iec-60320-c22", "iec-60309-p-n-e-4h", @@ -209,6 +210,7 @@ "iec-60320-c7", "iec-60320-c13", "iec-60320-c15", + "iec-60320-c17", "iec-60320-c19", "iec-60320-c21", "iec-60309-p-n-e-4h", @@ -474,6 +476,13 @@ "passive-48v-2pair", "passive-48v-4pair" ] + }, + "rf_role": { + "type": "string", + "enum": [ + "ap", + "station" + ] } } }, diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md index a217725db..d3ade67b8 100644 --- a/docs/release-notes/version-4.3.md +++ b/docs/release-notes/version-4.3.md @@ -1,5 +1,34 @@ # NetBox v4.3 +## v4.3.6 (2025-08-12) + +### Enhancements + +* [#17222](https://github.com/netbox-community/netbox/issues/17222) - Made unread notifications more visible with improved styling and positioning +* [#18843](https://github.com/netbox-community/netbox/issues/18843) - Include color name when exporting cables +* [#18873](https://github.com/netbox-community/netbox/issues/18873) - Add a request timeout parameter to the RSS feed dashboard widget +* [#19622](https://github.com/netbox-community/netbox/issues/19622) - Allow sharing GraphQL queries as links +* [#19728](https://github.com/netbox-community/netbox/issues/19728) - Added C18 power port type for audio devices +* [#19968](https://github.com/netbox-community/netbox/issues/19968) - Improve object type selection form field when editing permissions +* [#19977](https://github.com/netbox-community/netbox/issues/19977) - Improve performance when filtering device components by site, location, or rack + +### Bug Fixes + +* [#19321](https://github.com/netbox-community/netbox/issues/19321) - Reduce redundant database queries when bulk importing devices +* [#19379](https://github.com/netbox-community/netbox/issues/19379) - Support singular VLAN IDs in list when editing a VLAN group +* [#19812](https://github.com/netbox-community/netbox/issues/19812) - Implement `contains` GraphQL filter for IPAM prefixes and IP ranges +* [#19917](https://github.com/netbox-community/netbox/issues/19917) - Ensure deterministic ordering of duplicate MAC addresses +* [#19996](https://github.com/netbox-community/netbox/issues/19996) - Correct dynamic query parameters for IP Address field in Add/Edit Service form +* [#19998](https://github.com/netbox-community/netbox/issues/19998) - Fix missing changelog records for deleted tags +* [#19999](https://github.com/netbox-community/netbox/issues/19999) - Corrected excessive whitespace in script list dashboard widget +* [#20001](https://github.com/netbox-community/netbox/issues/20001) - `is_api_request()` should not evaluate a request's content type +* [#20009](https://github.com/netbox-community/netbox/issues/20009) - Ensure search parameter is escaped for export links under object list views +* [#20017](https://github.com/netbox-community/netbox/issues/20017) - Fix highlighting of changed lines in changelog data +* [#20023](https://github.com/netbox-community/netbox/issues/20023) - Add GiST index on prefixes table to vastly improve bulk deletion time +* [#20030](https://github.com/netbox-community/netbox/issues/20030) - Fix height of object list action buttons & others +* [#20033](https://github.com/netbox-community/netbox/issues/20033) - Fix `TypeError` exception when bulk deleting bookmarks +* [#20056](https://github.com/netbox-community/netbox/issues/20056) - Fixed missing RF role options in device type schema validation + ## v4.3.5 (2025-07-29) ### Enhancements @@ -16,6 +45,9 @@ * [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form * [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions +!!! note "Plugin Developer Advisory" + The fix for bug [#18900](https://github.com/netbox-community/netbox/issues/18900) now raises explicit exceptions when API endpoints attempt to paginate unordered querysets. Plugin maintainers should review their API viewsets to ensure proper queryset ordering is applied before pagination, either by using `.order_by()` on querysets or by setting `ordering` in model Meta classes. Previously silent pagination issues in plugin code will now raise `QuerySetNotOrdered` exceptions and may require updates to maintain compatibility. + ## v4.3.4 (2025-07-15) ### Enhancements diff --git a/netbox/core/signals.py b/netbox/core/signals.py index dff5571ab..46a0fe0fd 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -14,6 +14,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * from core.models import ObjectType from extras.events import enqueue_event +from extras.models import Tag from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue @@ -104,6 +105,17 @@ def handle_changed_object(sender, instance, **kwargs): # m2m_changed with objects added or removed m2m_changed = True event_type = OBJECT_UPDATED + elif kwargs.get('action') == 'post_clear': + # Handle clearing of an M2M field + if kwargs.get('model') == Tag and getattr(instance, '_prechange_snapshot', {}).get('tags'): + # Handle generation of M2M changes for Tags which have a previous value (ignoring changes where the + # prechange snapshot is empty) + m2m_changed = True + event_type = OBJECT_UPDATED + else: + # Other endpoints are unimpacted as they send post_add and post_remove + # This will impact changes that utilize clear() however so we may want to give consideration for this branch + return else: return diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 81ea3c61a..d44048d58 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -362,6 +362,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_IEC_C8 = 'iec-60320-c8' TYPE_IEC_C14 = 'iec-60320-c14' TYPE_IEC_C16 = 'iec-60320-c16' + TYPE_IEC_C18 = 'iec-60320-c18' TYPE_IEC_C20 = 'iec-60320-c20' TYPE_IEC_C22 = 'iec-60320-c22' # IEC 60309 @@ -480,6 +481,7 @@ class PowerPortTypeChoices(ChoiceSet): (TYPE_IEC_C8, 'C8'), (TYPE_IEC_C14, 'C14'), (TYPE_IEC_C16, 'C16'), + (TYPE_IEC_C18, 'C18'), (TYPE_IEC_C20, 'C20'), (TYPE_IEC_C22, 'C22'), )), @@ -617,6 +619,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_IEC_C7 = 'iec-60320-c7' TYPE_IEC_C13 = 'iec-60320-c13' TYPE_IEC_C15 = 'iec-60320-c15' + TYPE_IEC_C17 = 'iec-60320-c17' TYPE_IEC_C19 = 'iec-60320-c19' TYPE_IEC_C21 = 'iec-60320-c21' # IEC 60309 @@ -729,6 +732,7 @@ class PowerOutletTypeChoices(ChoiceSet): (TYPE_IEC_C7, 'C7'), (TYPE_IEC_C13, 'C13'), (TYPE_IEC_C15, 'C15'), + (TYPE_IEC_C17, 'C17'), (TYPE_IEC_C19, 'C19'), (TYPE_IEC_C21, 'C21'), )), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4102f58d8..7856467d1 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1548,34 +1548,34 @@ class DeviceComponentFilterSet(django_filters.FilterSet): label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__site', + field_name='_site', queryset=Site.objects.all(), label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='device__site__slug', + field_name='_site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site name (slug)'), ) location_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__location', + field_name='_location', queryset=Location.objects.all(), label=_('Location (ID)'), ) location = django_filters.ModelMultipleChoiceFilter( - field_name='device__location__slug', + field_name='_location__slug', queryset=Location.objects.all(), to_field_name='slug', label=_('Location (slug)'), ) rack_id = django_filters.ModelMultipleChoiceFilter( - field_name='device__rack', + field_name='_rack', queryset=Rack.objects.all(), label=_('Rack (ID)'), ) rack = django_filters.ModelMultipleChoiceFilter( - field_name='device__rack__name', + field_name='_rack__name', queryset=Rack.objects.all(), to_field_name='name', label=_('Rack (name)'), diff --git a/netbox/dcim/management/commands/buildschema.py b/netbox/dcim/management/commands/buildschema.py index 529a2462c..62af08edc 100644 --- a/netbox/dcim/management/commands/buildschema.py +++ b/netbox/dcim/management/commands/buildschema.py @@ -7,6 +7,7 @@ from jinja2 import FileSystemLoader, Environment from dcim.choices import * from netbox.choices import WeightUnitChoices +from wireless.choices import WirelessRoleChoices TEMPLATE_FILENAME = 'devicetype_schema.jinja2' OUTPUT_FILENAME = 'contrib/generated_schema.json' @@ -23,6 +24,7 @@ CHOICES_MAP = { 'interface_type_choices': InterfaceTypeChoices, 'interface_poe_mode_choices': InterfacePoEModeChoices, 'interface_poe_type_choices': InterfacePoETypeChoices, + 'interface_rf_role_choices': WirelessRoleChoices, 'front_port_type_choices': PortTypeChoices, 'rear_port_type_choices': PortTypeChoices, } diff --git a/netbox/dcim/migrations/0209_device_component_denorm_site_location.py b/netbox/dcim/migrations/0209_device_component_denorm_site_location.py new file mode 100644 index 000000000..e3b5d0f37 --- /dev/null +++ b/netbox/dcim/migrations/0209_device_component_denorm_site_location.py @@ -0,0 +1,287 @@ +import django.db.models.deletion +from django.db import migrations, models +from django.db.models import OuterRef, Subquery + + +def populate_denormalized_data(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + component_models = ( + apps.get_model('dcim', 'ConsolePort'), + apps.get_model('dcim', 'ConsoleServerPort'), + apps.get_model('dcim', 'PowerPort'), + apps.get_model('dcim', 'PowerOutlet'), + apps.get_model('dcim', 'Interface'), + apps.get_model('dcim', 'FrontPort'), + apps.get_model('dcim', 'RearPort'), + apps.get_model('dcim', 'DeviceBay'), + apps.get_model('dcim', 'ModuleBay'), + apps.get_model('dcim', 'InventoryItem'), + ) + + for model in component_models: + subquery = Device.objects.filter(pk=OuterRef('device_id')) + model.objects.update( + _site=Subquery(subquery.values('site_id')[:1]), + _location=Subquery(subquery.values('location_id')[:1]), + _rack=Subquery(subquery.values('rack_id')[:1]), + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0208_devicerole_uniqueness'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='consoleport', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='consoleport', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='consoleserverport', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='devicebay', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='devicebay', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='devicebay', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='frontport', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='frontport', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='frontport', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='interface', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='interface', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='interface', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='inventoryitem', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='modulebay', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='modulebay', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='modulebay', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='poweroutlet', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='powerport', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='powerport', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='powerport', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.AddField( + model_name='rearport', + name='_location', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='+', + to='dcim.location', + ), + ), + migrations.AddField( + model_name='rearport', + name='_rack', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.rack' + ), + ), + migrations.AddField( + model_name='rearport', + name='_site', + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.site' + ), + ), + migrations.RunPython(populate_denormalized_data), + ] diff --git a/netbox/dcim/migrations/0210_macaddress_ordering.py b/netbox/dcim/migrations/0210_macaddress_ordering.py new file mode 100644 index 000000000..5d719c5ad --- /dev/null +++ b/netbox/dcim/migrations/0210_macaddress_ordering.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0209_device_component_denorm_site_location'), + ] + + operations = [ + migrations.AlterModelOptions( + name='macaddress', + options={ + 'ordering': ('mac_address', 'pk'), + 'verbose_name': 'MAC address', + 'verbose_name_plural': 'MAC addresses' + }, + ), + ] diff --git a/netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py b/netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py similarity index 97% rename from netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py rename to netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py index 7114cf2a6..9a03c0b84 100644 --- a/netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py +++ b/netbox/dcim/migrations/0211_platform_manufacturer_uniqueness.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0208_devicerole_uniqueness'), + ('dcim', '0210_macaddress_ordering'), ('extras', '0129_fix_script_paths'), ] diff --git a/netbox/dcim/migrations/0210_interface_tx_power_negative.py b/netbox/dcim/migrations/0212_interface_tx_power_negative.py similarity index 90% rename from netbox/dcim/migrations/0210_interface_tx_power_negative.py rename to netbox/dcim/migrations/0212_interface_tx_power_negative.py index cf8c99f5e..4e49ad83c 100644 --- a/netbox/dcim/migrations/0210_interface_tx_power_negative.py +++ b/netbox/dcim/migrations/0212_interface_tx_power_negative.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0209_platform_manufacturer_uniqueness'), + ('dcim', '0211_platform_manufacturer_uniqueness'), ] operations = [ diff --git a/netbox/dcim/migrations/0211_platform_parent.py b/netbox/dcim/migrations/0213_platform_parent.py similarity index 96% rename from netbox/dcim/migrations/0211_platform_parent.py rename to netbox/dcim/migrations/0213_platform_parent.py index e5b5c6bc3..1a1e0f228 100644 --- a/netbox/dcim/migrations/0211_platform_parent.py +++ b/netbox/dcim/migrations/0213_platform_parent.py @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0210_interface_tx_power_negative'), + ('dcim', '0212_interface_tx_power_negative'), ] operations = [ diff --git a/netbox/dcim/migrations/0212_platform_rebuild.py b/netbox/dcim/migrations/0214_platform_rebuild.py similarity index 93% rename from netbox/dcim/migrations/0212_platform_rebuild.py rename to netbox/dcim/migrations/0214_platform_rebuild.py index b15ffd281..786703c62 100644 --- a/netbox/dcim/migrations/0212_platform_rebuild.py +++ b/netbox/dcim/migrations/0214_platform_rebuild.py @@ -18,7 +18,7 @@ def rebuild_mptt(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0211_platform_parent'), + ('dcim', '0213_platform_parent'), ] operations = [ diff --git a/netbox/dcim/migrations/0213_rackreservation_status.py b/netbox/dcim/migrations/0215_rackreservation_status.py similarity index 87% rename from netbox/dcim/migrations/0213_rackreservation_status.py rename to netbox/dcim/migrations/0215_rackreservation_status.py index 31822912c..6a762c4d0 100644 --- a/netbox/dcim/migrations/0213_rackreservation_status.py +++ b/netbox/dcim/migrations/0215_rackreservation_status.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0212_platform_rebuild'), + ('dcim', '0214_platform_rebuild'), ] operations = [ diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 32e26f982..69e07ed94 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -12,6 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node +from netbox.choices import ColorChoices from netbox.models import ChangeLoggedModel, PrimaryModel from utilities.conversion import to_meters from utilities.exceptions import AbortRequest @@ -156,6 +157,15 @@ class Cable(PrimaryModel): self._terminations_modified = True self._b_terminations = value + @property + def color_name(self): + color_name = "" + for hex_code, label in ColorChoices.CHOICES: + if hex_code.lower() == self.color.lower(): + color_name = str(label) + + return color_name + def clean(self): super().clean() diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index e1cd5f491..f1e460d77 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -65,6 +65,29 @@ class ComponentModel(NetBoxModel): blank=True ) + # Denormalized references replicated from the parent Device + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + _rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True, + ) + class Meta: abstract = True ordering = ('device', 'name') @@ -100,6 +123,14 @@ class ComponentModel(NetBoxModel): "device": _("Components cannot be moved to a different device.") }) + def save(self, *args, **kwargs): + # Save denormalized references + self._site = self.device.site + self._location = self.device.location + self._rack = self.device.rack + + super().save(*args, **kwargs) + @property def parent_object(self): return self.device diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ab4aeb128..cfebc4a9c 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -9,7 +9,7 @@ from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import F, ProtectedError +from django.db.models import F, ProtectedError, prefetch_related_objects from django.db.models.functions import Lower from django.db.models.signals import post_save from django.urls import reverse @@ -28,6 +28,7 @@ from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel from netbox.models.mixins import WeightMixin from netbox.models.features import ContactsMixin, ImageAttachmentsMixin from utilities.fields import ColorField, CounterCacheField +from utilities.prefetch import get_prefetchable_fields from utilities.tracking import TrackingModelMixin from .device_components import * from .mixins import RenderConfigMixin @@ -957,7 +958,10 @@ class Device( if cf_defaults := CustomField.objects.get_defaults_for_model(model): for component in components: component.custom_field_data = cf_defaults - model.objects.bulk_create(components) + components = model.objects.bulk_create(components) + # Prefetch related objects to minimize queries needed during post_save + prefetch_fields = get_prefetchable_fields(model) + prefetch_related_objects(components, *prefetch_fields) # Manually send the post_save signal for each of the newly created components for component in components: post_save.send( @@ -1305,7 +1309,7 @@ class MACAddress(PrimaryModel): ) class Meta: - ordering = ('mac_address',) + ordering = ('mac_address', 'pk',) verbose_name = _('MAC address') verbose_name_plural = _('MAC addresses') diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 6c213d64c..c7d3533fb 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -3,13 +3,28 @@ import logging from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import CableEndChoices, LinkStatusChoices +from dcim.choices import CableEndChoices, LinkStatusChoices from .models import ( - Cable, CablePath, CableTermination, Device, FrontPort, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis, + Cable, CablePath, CableTermination, ConsolePort, ConsoleServerPort, Device, DeviceBay, FrontPort, Interface, + InventoryItem, ModuleBay, PathEndpoint, PowerOutlet, PowerPanel, PowerPort, Rack, RearPort, Location, + VirtualChassis, ) from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths +COMPONENT_MODELS = ( + ConsolePort, + ConsoleServerPort, + DeviceBay, + FrontPort, + Interface, + InventoryItem, + ModuleBay, + PowerOutlet, + PowerPort, + RearPort, +) + # # Location/rack/device assignment @@ -39,6 +54,20 @@ def handle_rack_site_change(instance, created, **kwargs): Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location) +@receiver(post_save, sender=Device) +def handle_device_site_change(instance, created, **kwargs): + """ + Update child components to update the parent Site, Location, and Rack when a Device is saved. + """ + if not created: + for model in COMPONENT_MODELS: + model.objects.filter(device=instance).update( + _site=instance.site, + _location=instance.location, + _rack=instance.rack, + ) + + # # Virtual chassis # diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index e12545fd2..321eb79f5 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -113,6 +113,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): order_by=('_abs_length') ) color = columns.ColorColumn() + color_name = tables.Column( + verbose_name=_('Color Name'), + orderable=False + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:cable_list' @@ -123,7 +127,7 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): fields = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', - 'length', 'description', 'comments', 'tags', 'created', 'last_updated', + 'color_name', 'length', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 357de1abc..6e67ba83e 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -3438,9 +3438,36 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF ConsoleServerPort.objects.bulk_create(console_server_ports) console_ports = ( - ConsolePort(device=devices[0], module=modules[0], name='Console Port 1', label='A', description='First'), - ConsolePort(device=devices[1], module=modules[1], name='Console Port 2', label='B', description='Second'), - ConsolePort(device=devices[2], module=modules[2], name='Console Port 3', label='C', description='Third'), + ConsolePort( + device=devices[0], + module=modules[0], + name='Console Port 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + ConsolePort( + device=devices[1], + module=modules[1], + name='Console Port 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + ConsolePort( + device=devices[2], + module=modules[2], + name='Console Port 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), ) ConsolePort.objects.bulk_create(console_ports) @@ -3652,13 +3679,34 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL console_server_ports = ( ConsoleServerPort( - device=devices[0], module=modules[0], name='Console Server Port 1', label='A', description='First' + device=devices[0], + module=modules[0], + name='Console Server Port 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), ConsoleServerPort( - device=devices[1], module=modules[1], name='Console Server Port 2', label='B', description='Second' + device=devices[1], + module=modules[1], + name='Console Server Port 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), ConsoleServerPort( - device=devices[2], module=modules[2], name='Console Server Port 3', label='C', description='Third' + device=devices[2], + module=modules[2], + name='Console Server Port 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) ConsoleServerPort.objects.bulk_create(console_server_ports) @@ -3878,6 +3926,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=100, allocated_draw=50, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), PowerPort( device=devices[1], @@ -3887,6 +3938,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=200, allocated_draw=100, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), PowerPort( device=devices[2], @@ -3896,6 +3950,9 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil maximum_draw=300, allocated_draw=150, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) PowerPort.objects.bulk_create(power_ports) @@ -4124,6 +4181,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='First', color='ff0000', status=PowerOutletStatusChoices.STATUS_ENABLED, + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), PowerOutlet( device=devices[1], @@ -4134,6 +4194,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='Second', color='00ff00', status=PowerOutletStatusChoices.STATUS_DISABLED, + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), PowerOutlet( device=devices[2], @@ -4144,6 +4207,9 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF description='Third', color='0000ff', status=PowerOutletStatusChoices.STATUS_FAULTY, + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -4452,13 +4518,19 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, vlan_translation_policy=vlan_translation_policies[0], + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), Interface( device=devices[1], module=modules[1], name='VC Chassis Interface', type=InterfaceTypeChoices.TYPE_1GE_SFP, - enabled=True + enabled=True, + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), Interface( device=devices[2], @@ -4477,6 +4549,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF, vlan_translation_policy=vlan_translation_policies[0], + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), Interface( device=devices[3], @@ -4495,6 +4570,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT, vlan_translation_policy=vlan_translation_policies[1], + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), Interface( device=devices[4], @@ -4511,6 +4589,9 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mode=InterfaceModeChoices.MODE_Q_IN_Q, qinq_svlan=vlans[0], vlan_translation_policy=vlan_translation_policies[1], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4521,7 +4602,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mgmt_only=True, tx_power=40, mode=InterfaceModeChoices.MODE_Q_IN_Q, - qinq_svlan=vlans[1] + qinq_svlan=vlans[1], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4532,7 +4616,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil mgmt_only=False, tx_power=40, mode=InterfaceModeChoices.MODE_Q_IN_Q, - qinq_svlan=vlans[2] + qinq_svlan=vlans[2], + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4541,7 +4628,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, - rf_channel_width=22 + rf_channel_width=22, + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), Interface( device=devices[4], @@ -4550,7 +4640,10 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, - rf_channel_width=20 + rf_channel_width=20, + _site=devices[4].site, + _location=devices[4].location, + _rack=devices[4].rack, ), ) Interface.objects.bulk_create(interfaces) @@ -4977,6 +5070,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[0], rear_port_position=1, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), FrontPort( device=devices[1], @@ -4988,6 +5084,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[1], rear_port_position=2, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), FrontPort( device=devices[2], @@ -4999,6 +5098,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil rear_port=rear_ports[2], rear_port_position=3, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, ), FrontPort( device=devices[3], @@ -5007,6 +5109,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), FrontPort( device=devices[3], @@ -5015,6 +5120,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), FrontPort( device=devices[3], @@ -5023,6 +5131,9 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), ) FrontPort.objects.bulk_create(front_ports) @@ -5239,6 +5350,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_RED, positions=1, description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, ), RearPort( device=devices[1], @@ -5249,6 +5363,9 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_GREEN, positions=2, description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, ), RearPort( device=devices[2], @@ -5259,10 +5376,40 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt color=ColorChoices.COLOR_BLUE, positions=3, description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 4', + label='D', + type=PortTypeChoices.TYPE_FC, + positions=4, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 5', + label='E', + type=PortTypeChoices.TYPE_FC, + positions=5, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, + ), + RearPort( + device=devices[3], + name='Rear Port 6', + label='F', + type=PortTypeChoices.TYPE_FC, + positions=6, + _site=devices[3].site, + _location=devices[3].location, + _rack=devices[3].rack, ), - RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4), - RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5), - RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6), ) RearPort.objects.bulk_create(rear_ports) @@ -5621,9 +5768,33 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil Device.objects.bulk_create(devices) device_bays = ( - DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), - DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), - DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), + DeviceBay( + device=devices[0], + name='Device Bay 1', + label='A', + description='First', + _site=devices[0].site, + _location=devices[0].location, + _rack=devices[0].rack, + ), + DeviceBay( + device=devices[1], + name='Device Bay 2', + label='B', + description='Second', + _site=devices[1].site, + _location=devices[1].location, + _rack=devices[1].rack, + ), + DeviceBay( + device=devices[2], + name='Device Bay 3', + label='C', + description='Third', + _site=devices[2].site, + _location=devices[2].location, + _rack=devices[2].rack, + ), ) DeviceBay.objects.bulk_create(device_bays) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 2497e9c80..f88490ad2 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -307,6 +307,7 @@ class RSSFeedWidget(DashboardWidget): default_config = { 'max_entries': 10, 'cache_timeout': 3600, # seconds + 'request_timeout': 3, # seconds 'requires_internet': True, } description = _('Embed an RSS feed from an external website.') @@ -333,6 +334,12 @@ class RSSFeedWidget(DashboardWidget): max_value=86400, # 24 hours help_text=_('How long to stored the cached content (in seconds)') ) + request_timeout = forms.IntegerField( + min_value=1, + max_value=60, + required=False, + help_text=_('Timeout value for fetching the feed (in seconds)') + ) def render(self, request): return render_to_string(self.template_name, { @@ -364,7 +371,7 @@ class RSSFeedWidget(DashboardWidget): url=self.config['feed_url'], headers={'User-Agent': f'NetBox/{settings.RELEASE.version}'}, proxies=resolve_proxies(url=self.config['feed_url'], context={'client': self}), - timeout=3 + timeout=self.config.get('request_timeout', 3), ) response.raise_for_status() except requests.exceptions.RequestException as e: diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index d687aa821..be4c44d63 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -872,6 +872,9 @@ class Bookmark(models.Model): return str(self.object) return super().__str__() + def get_absolute_url(self): + return reverse('account:bookmarks') + def clean(self): super().clean() diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index fd3ce5453..ac3f5b23a 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -807,3 +807,21 @@ class NotificationTestCase( def test_list_objects_with_constrained_permission(self): return + + +class ScriptListViewTest(TestCase): + user_permissions = ['extras.view_script'] + + def test_script_list_embedded_parameter(self): + """Test that ScriptListView accepts embedded parameter without error""" + url = reverse('extras:script_list') + + # Test normal request + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'extras/script_list.html') + + # Test embedded request + response = self.client.get(url, {'embedded': 'true'}) + self.assertEqual(response.status_code, 200) + self.assertTemplateUsed(response, 'extras/inc/script_list_content.html') diff --git a/netbox/extras/views.py b/netbox/extras/views.py index cf197b55e..d52cecba9 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1317,11 +1317,18 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View): script_modules = ScriptModule.objects.restrict(request.user).prefetch_related( 'data_source', 'data_file', 'jobs' ) - - return render(request, 'extras/script_list.html', { + context = { 'model': ScriptModule, 'script_modules': script_modules, - }) + } + + # Use partial template for dashboard widgets + template_name = 'extras/script_list.html' + if request.GET.get('embedded'): + template_name = 'extras/inc/script_list_content.html' + context['embedded'] = True + + return render(request, template_name, context) class BaseScriptView(generic.ObjectView): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index aaf7fe7d3..1b4a3d596 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -22,7 +22,7 @@ from utilities.forms.utils import get_field_value from utilities.forms.widgets import DatePicker, HTMXSelect from django.utils.safestring import mark_safe from utilities.templatetags.builtins.filters import bettertitle -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine __all__ = ( 'AggregateForm', @@ -792,10 +792,6 @@ class ServiceForm(NetBoxModelForm): queryset=IPAddress.objects.all(), required=False, label=_('IP Addresses'), - query_params={ - 'device_id': '$device', - 'virtual_machine_id': '$virtual_machine', - } ) comments = CommentField() @@ -824,10 +820,22 @@ class ServiceForm(NetBoxModelForm): super().__init__(*args, **kwargs) - if (parent_object_type_id := get_field_value(self, 'parent_object_type')): + if parent_object_type_id := get_field_value(self, 'parent_object_type'): try: parent_type = ContentType.objects.get(pk=parent_object_type_id) model = parent_type.model_class() + if model == Device: + self.fields['ipaddresses'].widget.add_query_params({ + 'device_id': '$parent', + }) + elif model == VirtualMachine: + self.fields['ipaddresses'].widget.add_query_params({ + 'virtual_machine_id': '$parent', + }) + elif model == FHRPGroup: + self.fields['ipaddresses'].widget.add_query_params({ + 'fhrpgroup_id': '$parent', + }) self.fields['parent'].queryset = model.objects.all() self.fields['parent'].widget.attrs['selector'] = model._meta.label_lower self.fields['parent'].disabled = False diff --git a/netbox/ipam/graphql/filters.py b/netbox/ipam/graphql/filters.py index c66b8d483..35ddd47e4 100644 --- a/netbox/ipam/graphql/filters.py +++ b/netbox/ipam/graphql/filters.py @@ -222,6 +222,19 @@ class IPRangeFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMi return Q() return q + @strawberry_django.filter_field() + def contains(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + net = netaddr.IPNetwork(subnet.strip()) + q |= Q( + start_address__host__inet__lte=str(netaddr.IPAddress(net.first)), + end_address__host__inet__gte=str(netaddr.IPAddress(net.last)), + ) + return q + @strawberry_django.filter_type(models.Prefix, lookups=True) class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin): @@ -238,6 +251,16 @@ class PrefixFilter(ContactFilterMixin, ScopedFilterMixin, TenancyFilterMixin, Pr is_pool: FilterLookup[bool] | None = strawberry_django.filter_field() mark_utilized: FilterLookup[bool] | None = strawberry_django.filter_field() + @strawberry_django.filter_field() + def contains(self, value: list[str], prefix) -> Q: + if not value: + return Q() + q = Q() + for subnet in value: + query = str(netaddr.IPNetwork(subnet.strip()).cidr) + q |= Q(prefix__net_contains=query) + return q + @strawberry_django.filter_type(models.RIR, lookups=True) class RIRFilter(OrganizationalModelFilterMixin): diff --git a/netbox/ipam/migrations/0082_add_prefix_network_containment_indexes.py b/netbox/ipam/migrations/0082_add_prefix_network_containment_indexes.py new file mode 100644 index 000000000..abae5dccd --- /dev/null +++ b/netbox/ipam/migrations/0082_add_prefix_network_containment_indexes.py @@ -0,0 +1,20 @@ +from django.contrib.postgres.indexes import GistIndex +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0210_macaddress_ordering'), + ('extras', '0129_fix_script_paths'), + ('ipam', '0081_remove_service_device_virtual_machine_add_parent_gfk_index'), + ('tenancy', '0020_remove_contactgroupmembership'), + ] + + operations = [ + migrations.AddIndex( + model_name='prefix', + index=GistIndex(fields=['prefix'], name='ipam_prefix_gist_idx', opclasses=['inet_ops']), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 73c3310dc..cef979d3f 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,6 +1,7 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.indexes import GistIndex from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -281,6 +282,13 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique verbose_name = _('prefix') verbose_name_plural = _('prefixes') + indexes = [ + GistIndex( + fields=['prefix'], + name='ipam_prefix_gist_idx', + opclasses=['inet_ops'], + ), + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 84722512b..f480c2085 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -237,7 +237,11 @@ class ActionsColumn(tables.Column): :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the direct button link and icon (default: True) """ - attrs = {'td': {'class': 'text-end text-nowrap noprint'}} + attrs = { + 'td': { + 'class': 'text-end text-nowrap noprint p-1' + } + } empty_values = () actions = { 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 91d3ea25c..4777dffa2 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index ed5971014..f5bab6893 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 295f03aca..bd0d75fe8 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/buttons/moveOptions.ts b/netbox/project-static/src/buttons/moveOptions.ts index fee36d609..a98bf732a 100644 --- a/netbox/project-static/src/buttons/moveOptions.ts +++ b/netbox/project-static/src/buttons/moveOptions.ts @@ -1,5 +1,20 @@ import { getElements } from '../util'; +/** + * Move selected options from one select element to another. + * + * @param source Select Element + * @param target Select Element + */ +function moveOption(source: HTMLSelectElement, target: HTMLSelectElement): void { + for (const option of Array.from(source.options)) { + if (option.selected) { + target.appendChild(option.cloneNode(true)); + option.remove(); + } + } +} + /** * Move selected options of a select element up in order. * @@ -39,23 +54,35 @@ function moveOptionDown(element: HTMLSelectElement): void { } /** - * Initialize move up/down buttons. + * Initialize select/move buttons. */ export function initMoveButtons(): void { - for (const button of getElements('#move-option-up')) { + // Move selected option(s) between lists + for (const button of getElements('.move-option')) { + const source = button.getAttribute('data-source'); const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionUp(select)); - } + const source_select = document.getElementById(`id_${source}`) as HTMLSelectElement; + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (source_select !== null && target_select !== null) { + button.addEventListener('click', () => moveOption(source_select, target_select)); } } - for (const button of getElements('#move-option-down')) { + + // Move selected option(s) up in current list + for (const button of getElements('.move-option-up')) { const target = button.getAttribute('data-target'); - if (target !== null) { - for (const select of getElements(`#${target}`)) { - button.addEventListener('click', () => moveOptionDown(select)); - } + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionUp(target_select)); + } + } + + // Move selected option(s) down in current list + for (const button of getElements('.move-option-down')) { + const target = button.getAttribute('data-target'); + const target_select = document.getElementById(`id_${target}`) as HTMLSelectElement; + if (target_select !== null) { + button.addEventListener('click', () => moveOptionDown(target_select)); } } } diff --git a/netbox/project-static/src/search.ts b/netbox/project-static/src/search.ts index 1295527cf..827986928 100644 --- a/netbox/project-static/src/search.ts +++ b/netbox/project-static/src/search.ts @@ -38,7 +38,9 @@ function handleQuickSearchParams(event: Event): void { if (quickSearchParameters != null) { const link = document.getElementById('export_current_view') as HTMLLinkElement; - const search_parameter = `q=${quickSearchParameters.value}`; + const params = new URLSearchParams(); + params.set('q', quickSearchParameters.value); + const search_parameter = params.toString(); const linkUpdated = link?.href + '&' + search_parameter; link.setAttribute('href', linkUpdated); } diff --git a/netbox/project-static/styles/custom/_code.scss b/netbox/project-static/styles/custom/_code.scss index 1be563b21..0e6869efa 100644 --- a/netbox/project-static/styles/custom/_code.scss +++ b/netbox/project-static/styles/custom/_code.scss @@ -8,6 +8,8 @@ pre.change-data { display: block; padding-right: $spacer; padding-left: $spacer; + width: 100%; + min-width: fit-content; &.added { background-color: $green; diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index ba9923943..55fc5a858 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -845,78 +845,78 @@ "@types/estree" "*" "@typescript-eslint/eslint-plugin@^8.37.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz#6e5220d16f2691ab6d983c1737dd5b36e17641b7" - integrity sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA== + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.1.tgz#28dffcb5272d20afe250bfeec3173263db5528a0" + integrity sha512-yYegZ5n3Yr6eOcqgj2nJH8cH/ZZgF+l0YIdKILSDjYFRjgYQMgv/lRjV5Z7Up04b9VYUondt8EPMqg7kTWgJ2g== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.38.0" - "@typescript-eslint/type-utils" "8.38.0" - "@typescript-eslint/utils" "8.38.0" - "@typescript-eslint/visitor-keys" "8.38.0" + "@typescript-eslint/scope-manager" "8.39.1" + "@typescript-eslint/type-utils" "8.39.1" + "@typescript-eslint/utils" "8.39.1" + "@typescript-eslint/visitor-keys" "8.39.1" graphemer "^1.4.0" ignore "^7.0.0" natural-compare "^1.4.0" ts-api-utils "^2.1.0" "@typescript-eslint/parser@^8.37.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.38.0.tgz#6723a5ea881e1777956b1045cba30be5ea838293" - integrity sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ== + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.39.1.tgz#7f8f9ecfc7e172d67e42c366fa198e42324e5d50" + integrity sha512-pUXGCuHnnKw6PyYq93lLRiZm3vjuslIy7tus1lIQTYVK9bL8XBgJnCWm8a0KcTtHC84Yya1Q6rtll+duSMj0dg== dependencies: - "@typescript-eslint/scope-manager" "8.38.0" - "@typescript-eslint/types" "8.38.0" - "@typescript-eslint/typescript-estree" "8.38.0" - "@typescript-eslint/visitor-keys" "8.38.0" + "@typescript-eslint/scope-manager" "8.39.1" + "@typescript-eslint/types" "8.39.1" + "@typescript-eslint/typescript-estree" "8.39.1" + "@typescript-eslint/visitor-keys" "8.39.1" debug "^4.3.4" -"@typescript-eslint/project-service@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.38.0.tgz#4900771f943163027fd7d2020a062892056b5e2f" - integrity sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg== +"@typescript-eslint/project-service@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.39.1.tgz#63525878d488ebf27c485f295e83434a1398f52d" + integrity sha512-8fZxek3ONTwBu9ptw5nCKqZOSkXshZB7uAxuFF0J/wTMkKydjXCzqqga7MlFMpHi9DoG4BadhmTkITBcg8Aybw== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.38.0" - "@typescript-eslint/types" "^8.38.0" + "@typescript-eslint/tsconfig-utils" "^8.39.1" + "@typescript-eslint/types" "^8.39.1" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz#5a0efcb5c9cf6e4121b58f87972f567c69529226" - integrity sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ== +"@typescript-eslint/scope-manager@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.1.tgz#1253fe3e1f2f33f08a3e438a05b5dd7faf9fbca6" + integrity sha512-RkBKGBrjgskFGWuyUGz/EtD8AF/GW49S21J8dvMzpJitOF1slLEbbHnNEtAHtnDAnx8qDEdRrULRnWVx27wGBw== dependencies: - "@typescript-eslint/types" "8.38.0" - "@typescript-eslint/visitor-keys" "8.38.0" + "@typescript-eslint/types" "8.39.1" + "@typescript-eslint/visitor-keys" "8.39.1" -"@typescript-eslint/tsconfig-utils@8.38.0", "@typescript-eslint/tsconfig-utils@^8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz#6de4ce224a779601a8df667db56527255c42c4d0" - integrity sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ== +"@typescript-eslint/tsconfig-utils@8.39.1", "@typescript-eslint/tsconfig-utils@^8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.1.tgz#17f13b4ad481e7bec7c249ee1854078645b34b12" + integrity sha512-ePUPGVtTMR8XMU2Hee8kD0Pu4NDE1CN9Q1sxGSGd/mbOtGZDM7pnhXNJnzW63zk/q+Z54zVzj44HtwXln5CvHA== -"@typescript-eslint/type-utils@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz#a56cd84765fa6ec135fe252b5db61e304403a85b" - integrity sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg== +"@typescript-eslint/type-utils@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.39.1.tgz#642f9fb96173649e2928fea0375b1d74d31906c2" + integrity sha512-gu9/ahyatyAdQbKeHnhT4R+y3YLtqqHyvkfDxaBYk97EcbfChSJXyaJnIL3ygUv7OuZatePHmQvuH5ru0lnVeA== dependencies: - "@typescript-eslint/types" "8.38.0" - "@typescript-eslint/typescript-estree" "8.38.0" - "@typescript-eslint/utils" "8.38.0" + "@typescript-eslint/types" "8.39.1" + "@typescript-eslint/typescript-estree" "8.39.1" + "@typescript-eslint/utils" "8.39.1" debug "^4.3.4" ts-api-utils "^2.1.0" -"@typescript-eslint/types@8.38.0", "@typescript-eslint/types@^8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.38.0.tgz#297351c994976b93c82ac0f0e206c8143aa82529" - integrity sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw== +"@typescript-eslint/types@8.39.1", "@typescript-eslint/types@^8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.1.tgz#f0ab996c8ab2c3b046bbf86bb1990b03529869a1" + integrity sha512-7sPDKQQp+S11laqTrhHqeAbsCfMkwJMrV7oTDvtDds4mEofJYir414bYKUEb8YPUm9QL3U+8f6L6YExSoAGdQw== -"@typescript-eslint/typescript-estree@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz#82262199eb6778bba28a319e25ad05b1158957df" - integrity sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ== +"@typescript-eslint/typescript-estree@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.1.tgz#8825d3ea7ea2144c577859ae489eec24ef7318a5" + integrity sha512-EKkpcPuIux48dddVDXyQBlKdeTPMmALqBUbEk38McWv0qVEZwOpVJBi7ugK5qVNgeuYjGNQxrrnoM/5+TI/BPw== dependencies: - "@typescript-eslint/project-service" "8.38.0" - "@typescript-eslint/tsconfig-utils" "8.38.0" - "@typescript-eslint/types" "8.38.0" - "@typescript-eslint/visitor-keys" "8.38.0" + "@typescript-eslint/project-service" "8.39.1" + "@typescript-eslint/tsconfig-utils" "8.39.1" + "@typescript-eslint/types" "8.39.1" + "@typescript-eslint/visitor-keys" "8.39.1" debug "^4.3.4" fast-glob "^3.3.2" is-glob "^4.0.3" @@ -924,22 +924,22 @@ semver "^7.6.0" ts-api-utils "^2.1.0" -"@typescript-eslint/utils@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.38.0.tgz#5f10159899d30eb92ba70e642ca6f754bddbf15a" - integrity sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg== +"@typescript-eslint/utils@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.39.1.tgz#58a834f89f93b786ada2cd14d77fa63c3c8f408b" + integrity sha512-VF5tZ2XnUSTuiqZFXCZfZs1cgkdd3O/sSYmdo2EpSyDlC86UM/8YytTmKnehOW3TGAlivqTDT6bS87B/GQ/jyg== dependencies: "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/scope-manager" "8.38.0" - "@typescript-eslint/types" "8.38.0" - "@typescript-eslint/typescript-estree" "8.38.0" + "@typescript-eslint/scope-manager" "8.39.1" + "@typescript-eslint/types" "8.39.1" + "@typescript-eslint/typescript-estree" "8.39.1" -"@typescript-eslint/visitor-keys@8.38.0": - version "8.38.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz#a9765a527b082cb8fc60fd8a16e47c7ad5b60ea5" - integrity sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g== +"@typescript-eslint/visitor-keys@8.39.1": + version "8.39.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.1.tgz#a467742a98f2fa3c03d7bed4979dc0db3850a77a" + integrity sha512-W8FQi6kEh2e8zVhQ0eeRnxdvIoOkAp/CPAahcNio6nO9dsIwb9b34z90KOlheoyuVf6LSOEdjlkxSkapNEc+4A== dependencies: - "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/types" "8.39.1" eslint-visitor-keys "^4.2.1" "@ungap/structured-clone@^1.2.0": @@ -1742,9 +1742,9 @@ eslint-plugin-import@^2.32.0: tsconfig-paths "^3.15.0" eslint-plugin-prettier@^5.5.1: - version "5.5.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz#1f88e9220a72ac8be171eec5f9d4e4d529b5f4a0" - integrity sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w== + version "5.5.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz#9d61c4ea11de5af704d4edf108c82ccfa7f2e61c" + integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg== dependencies: prettier-linter-helpers "^1.0.0" synckit "^0.11.7" diff --git a/netbox/release.yaml b/netbox/release.yaml index 0115419cd..3b973dc45 100644 --- a/netbox/release.yaml +++ b/netbox/release.yaml @@ -1,3 +1,3 @@ -version: "4.3.5" +version: "4.3.6" edition: "Community" -published: "2025-07-29" +published: "2025-08-12" diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 49c23e885..365f006ab 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -4,22 +4,22 @@

{% blocktrans %}Termination{% endblocktrans %} {{ side }} -
+
{% if not termination and perms.circuits.add_circuittermination %} - + {% trans "Add" %} {% endif %} {% if termination and perms.circuits.change_circuittermination %} - + {% trans "Edit" %} - + {% trans "Swap" %} {% endif %} {% if termination and perms.circuits.delete_circuittermination %} - + {% trans "Delete" %} {% endif %} diff --git a/netbox/templates/circuits/inc/circuit_termination_fields.html b/netbox/templates/circuits/inc/circuit_termination_fields.html index ea5bab7ae..efe30e28e 100644 --- a/netbox/templates/circuits/inc/circuit_termination_fields.html +++ b/netbox/templates/circuits/inc/circuit_termination_fields.html @@ -29,16 +29,16 @@ {{ peer|linkify }}{% if not forloop.last %},{% endif %} {% endfor %}
- + {% trans "Trace" %} {% if perms.dcim.change_cable %} - + {% trans "Edit" %} {% endif %} {% if perms.dcim.delete_cable %} - + {% trans "Disconnect" %} {% endif %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 163ccbc8e..9f4d23e60 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -77,7 +77,7 @@ {% trans "Cable" %} {{ object.cable|linkify }} - + diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index ae7a59d20..a94dd69e6 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -23,7 +23,7 @@ {{ term.device|linkify }} {{ term|linkify }} - + {% if not forloop.last %}
{% endif %} @@ -45,7 +45,7 @@ {% for term in terminations %} {{ term|linkify }} - + {% if not forloop.last %}
{% endif %} @@ -63,7 +63,7 @@ {% for term in terminations %} {{ term.circuit|linkify }} ({{ term }}) - + {% if not forloop.last %}
{% endif %} diff --git a/netbox/templates/dcim/inc/connection_endpoints.html b/netbox/templates/dcim/inc/connection_endpoints.html index bc1da5489..6223e70c9 100644 --- a/netbox/templates/dcim/inc/connection_endpoints.html +++ b/netbox/templates/dcim/inc/connection_endpoints.html @@ -4,7 +4,7 @@ {% trans "Cable" %} {{ object.cable|linkify }} - + diff --git a/netbox/templates/dcim/inc/panels/inventory_items.html b/netbox/templates/dcim/inc/panels/inventory_items.html index aea5eae16..ff49d1a53 100644 --- a/netbox/templates/dcim/inc/panels/inventory_items.html +++ b/netbox/templates/dcim/inc/panels/inventory_items.html @@ -29,12 +29,12 @@ {{ item.role|linkify|placeholder }} {% if perms.dcim.change_inventoryitem %} - + {% endif %} {% if perms.ipam.delete_inventoryitem %} - + {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 134c4e364..f6e34e355 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -208,7 +208,7 @@ {% trans "Wireless Link" %} {{ object.wireless_link|linkify }} - + diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 8aa0a5dfe..3fed4307b 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -73,7 +73,7 @@ {% trans "Cable" %} {{ object.cable|linkify }} - + diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index e6b4d1823..ac5d87ef2 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -57,7 +57,7 @@ {% trans "Output" %} {% if job.completed %} diff --git a/netbox/templates/extras/inc/script_list_content.html b/netbox/templates/extras/inc/script_list_content.html new file mode 100644 index 000000000..4bdb1bf16 --- /dev/null +++ b/netbox/templates/extras/inc/script_list_content.html @@ -0,0 +1,139 @@ +{% load buttons %} +{% load helpers %} +{% load perms %} +{% load i18n %} + +{# Core script list content - used by both full page and embedded views #} +{% for module in script_modules %} + {% include 'inc/sync_warning.html' with object=module %} +
+ {% if not embedded %} +

+ {{ module }} +
+ {% if perms.extras.edit_scriptmodule %} + + {% trans "Edit" %} + + {% endif %} + {% if perms.extras.delete_scriptmodule %} + + {% trans "Delete" %} + + {% endif %} +
+

+ {% endif %} + {% with scripts=module.ordered_scripts %} + {% if scripts %} + + + + + + + + + + + + {% for script in scripts %} + {% with last_job=script.get_latest_jobs|first %} + + + + {% if last_job %} + + + {% else %} + + + {% endif %} + + + {% if last_job and not embedded %} + {% for test_name, data in last_job.data.tests.items %} + + + + + {% endfor %} + {% elif last_job and not last_job.data.log and not embedded %} + {# legacy #} + {% for method, stats in last_job.data.items %} + + + + + {% endfor %} + {% endif %} + {% endwith %} + {% endfor %} + +
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
+ {% if script.is_executable %} + {{ script.python_class.name }} + {% else %} + {{ script.python_class.name }} + + + + {% endif %} + {{ script.python_class.description|markdown|placeholder }} + {{ last_job.created|isodatetime }} + + {% badge last_job.get_status_display last_job.get_status_color %} + {% trans "Never" %}{{ ''|placeholder }} + {% if request.user|can_run:script and script.is_executable %} +
+
+ {% if script.python_class.commit_default %} + + {% endif %} + {% csrf_token %} + +
+
+ {% endif %} +
+ {{ test_name }} + + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
+ {% else %} +
+ +
+ {% endif %} + {% endwith %} +
+{% empty %} + +{% endfor %} diff --git a/netbox/templates/extras/object_render_config.html b/netbox/templates/extras/object_render_config.html index 10d6d4aef..aec25cd64 100644 --- a/netbox/templates/extras/object_render_config.html +++ b/netbox/templates/extras/object_render_config.html @@ -54,11 +54,11 @@

{% trans "Rendered Config" %} -
- {% copy_content "rendered_config" %} - +
+ {% trans "Download" %} + {% copy_content "rendered_config" %}

{{ rendered_config }}
diff --git a/netbox/templates/extras/schema/devicetype_schema.jinja2 b/netbox/templates/extras/schema/devicetype_schema.jinja2 index 19cacab69..a8a7a7dda 100644 --- a/netbox/templates/extras/schema/devicetype_schema.jinja2 +++ b/netbox/templates/extras/schema/devicetype_schema.jinja2 @@ -70,6 +70,10 @@ "poe_type": { "type": "string", "enum": {{ interface_poe_type_choices }} + }, + "rf_role": { + "type": "string", + "enum": {{ interface_rf_role_choices }} } } }, diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 57269aa53..4f7652c1f 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -19,135 +19,5 @@ {% endblock controls %} {% block content %} - {% for module in script_modules %} - {% include 'inc/sync_warning.html' with object=module %} -
-

- {{ module }} -
- {% if perms.extras.edit_scriptmodule %} - - {% trans "Edit" %} - - {% endif %} - {% if perms.extras.delete_scriptmodule %} - - {% trans "Delete" %} - - {% endif %} -
-

- {% with scripts=module.ordered_scripts %} - {% if scripts %} - - - - - - - - - - - - {% for script in scripts %} - {% with last_job=script.get_latest_jobs|first %} - - - - {% if last_job %} - - - {% else %} - - - {% endif %} - - - {% if last_job %} - {% for test_name, data in last_job.data.tests.items %} - - - - - {% endfor %} - {% elif not last_job.data.log %} - {# legacy #} - {% for method, stats in last_job.data.items %} - - - - - {% endfor %} - {% endif %} - {% endwith %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
- {% if script.is_executable %} - {{ script.python_class.name }} - {% else %} - {{ script.python_class.name }} - - - - {% endif %} - {{ script.python_class.description|markdown|placeholder }} - {{ last_job.created|isodatetime }} - - {% badge last_job.get_status_display last_job.get_status_color %} - {% trans "Never" %}{{ ''|placeholder }} - {% if request.user|can_run:script and script.is_executable %} -
-
- {% if script.python_class.commit_default %} - - {% endif %} - {% csrf_token %} - -
-
- {% endif %} -
- {{ test_name }} - - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
- {% else %} -
- -
- {% endif %} - {% endwith %} -
- {% empty %} - - {% endfor %} + {% include 'extras/inc/script_list_content.html' with embedded=False %} {% endblock content %} diff --git a/netbox/templates/extras/tableconfig_edit.html b/netbox/templates/extras/tableconfig_edit.html index df1e67082..31057c298 100644 --- a/netbox/templates/extras/tableconfig_edit.html +++ b/netbox/templates/extras/tableconfig_edit.html @@ -36,10 +36,10 @@
{{ form.columns }} - + {% trans "Move Up" %} - + {% trans "Move Down" %}
diff --git a/netbox/templates/graphql/graphiql.html b/netbox/templates/graphql/graphiql.html index f50648f1f..7bcc7ddc7 100644 --- a/netbox/templates/graphql/graphiql.html +++ b/netbox/templates/graphql/graphiql.html @@ -88,6 +88,21 @@ # `; + let sharedQuery; + const hashArgs = new URLSearchParams(window.location.hash.substring(1)); + if (hashArgs.has('query')) { + sharedQuery = hashArgs.get('query'); + // reset url to not motivate copying of stale URL + hashArgs.delete('query'); + let remainingHash = ""; + if (hashArgs.size !== 0) { + remainingHash = `#${hashArgs.toString()}`; + } + history.pushState("", document.title, + window.location.pathname + window.location.search + remainingHash + ); + } + const fetchURL = window.location.href; function httpUrlToWebSockeUrl(url) { @@ -123,6 +138,8 @@ defaultEditorToolsVisibility: true, plugins: [explorerPlugin], inputValueDeprecation: true, + defaultQuery: EXAMPLE_QUERY, + query: sharedQuery, }), ); diff --git a/netbox/templates/htmx/notifications.html b/netbox/templates/htmx/notifications.html index e03e5afc9..2154d56ba 100644 --- a/netbox/templates/htmx/notifications.html +++ b/netbox/templates/htmx/notifications.html @@ -15,14 +15,14 @@
{{ notification.event }} {{ notification.created|timesince }} {% trans "ago" %}
{% empty %} -