Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2024-11-21 14:00:57 -05:00
commit f08e36e538
68 changed files with 5406 additions and 4794 deletions

View File

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

View File

@ -39,7 +39,7 @@ body:
attributes: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.1.5 placeholder: v4.1.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -7,6 +7,9 @@ contact_links:
- name: ❓ Discussion - name: ❓ Discussion
url: https://github.com/netbox-community/netbox/discussions url: https://github.com/netbox-community/netbox/discussions
about: "If you're just looking for help, try starting a discussion instead." about: "If you're just looking for help, try starting a discussion instead."
- name: 👔 Professional Support
url: https://netboxlabs.com/netbox-enterprise/
about: "Professional support is available for NetBox Enterprise or Cloud."
- name: 🌎 Correct a Translation - name: 🌎 Correct a Translation
url: https://explore.transifex.com/netbox-community/netbox/ url: https://explore.transifex.com/netbox-community/netbox/
about: "Spot an incorrect translation? You can propose a fix on Transifex." about: "Spot an incorrect translation? You can propose a fix on Transifex."

View File

@ -15,6 +15,11 @@ on:
permissions: permissions:
contents: read contents: read
# Add concurrency group to control job running
concurrency:
group: ${{ github.event_name }}-${{ github.ref }}-${{ github.actor }}
cancel-in-progress: true
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest

12
.tx/config Executable file
View File

@ -0,0 +1,12 @@
[main]
host = https://app.transifex.com
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
source_file = netbox/translations/en/LC_MESSAGES/django.po
type = PO
minimum_perc = 0
resource_name = django.po
replace_edited_strings = false
keep_translations = false

View File

@ -42,7 +42,7 @@ django-rich
# Django integration for RQ (Reqis queuing) # Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md # https://github.com/rq/django-rq/blob/master/CHANGELOG.md
django-rq<3.0 django-rq
# Abstraction models for rendering and paginating HTML tables # Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
@ -118,7 +118,7 @@ requests
# rq # rq
# https://github.com/rq/rq/blob/master/CHANGES.md # https://github.com/rq/rq/blob/master/CHANGES.md
rq<2.0 rq
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md

View File

@ -329,6 +329,7 @@
"100base-tx", "100base-tx",
"100base-t1", "100base-t1",
"1000base-t", "1000base-t",
"1000base-lx",
"1000base-tx", "1000base-tx",
"2.5gbase-t", "2.5gbase-t",
"5gbase-t", "5gbase-t",

View File

@ -90,7 +90,20 @@ This will automatically update the schema file at `contrib/generated_schema.json
### Update & Compile Translations ### Update & Compile Translations
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this. Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
```no-highlight
tx pull
```
Then, compile these portable (`.po`) files for use in the application:
```no-highlight
./manage.py compilemessages
```
!!! tip
Consult the translation documentation for more detail on [updating translated strings](./translations.md#updating-translated-strings) if you've not set up the Transifex client already.
### Update Version and Changelog ### Update Version and Changelog

View File

@ -16,26 +16,31 @@ To update the English `.po` file from which all translations are derived, use th
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically. Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
!!! note
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml).
## Updating Translated Strings ## Updating Translated Strings
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md). Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request. Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request.
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right. To download translated strings automatically, you'll need to:
![Transifex manual sync](../media/development/transifex_sync.png) 1. Install the [Transifex CLI client](https://github.com/transifex/cli)
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files. Once you have the client set up, run the following command:
!!! tip ```no-highlight
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added. TX_TOKEN=$TOKEN tx pull
```
![Transifex pull request](../media/development/transifex_pull_request.png) This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command: Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
```nohighlight ```no-highlight
./manage.py compilemessages ./manage.py compilemessages
``` ```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

View File

@ -1,6 +1,6 @@
# IKE Policies # IKE Policies
An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md). An [Internet Key Exchange (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
## Fields ## Fields

View File

@ -1,5 +1,37 @@
# NetBox v4.1 # NetBox v4.1
## v4.1.7 (FUTURE)
### Enhancements
* [#15239](https://github.com/netbox-community/netbox/issues/15239) - Enable adding/removing individual VLANs while bulk editing device interfaces
* [#17871](https://github.com/netbox-community/netbox/issues/17871) - Enable the assignment/removal of virtualization cluster via device bulk edit
* [#17934](https://github.com/netbox-community/netbox/issues/17934) - Add 1000Base-LX interface type
* [#18007](https://github.com/netbox-community/netbox/issues/18007) - Hide sensitive parameters under data source view (even for privileged users)
### Bug Fixes
* [#17459](https://github.com/netbox-community/netbox/issues/17459) - Correct help text on `name` field of module type component templates
* [#17901](https://github.com/netbox-community/netbox/issues/17901) - Ensure GraphiQL UI resources are served locally
* [#17921](https://github.com/netbox-community/netbox/issues/17921) - Fix scheduling of recurring custom scripts
* [#17923](https://github.com/netbox-community/netbox/issues/17923) - Fix the execution of custom scripts via REST API & management command
* [#17963](https://github.com/netbox-community/netbox/issues/17963) - Fix selection of all listed objects during bulk edit
* [#17969](https://github.com/netbox-community/netbox/issues/17969) - Fix system info export when a config revision exists
* [#17972](https://github.com/netbox-community/netbox/issues/17972) - Force evaluation of `LOGIN_REQUIRED` when requesting static media
* [#17986](https://github.com/netbox-community/netbox/issues/17986) - Correct labels for virtual machine & virtual disk size properties
* [#18037](https://github.com/netbox-community/netbox/issues/18037) - Fix validation of maximum VLAN ID value when defining VLAN groups
* [#18038](https://github.com/netbox-community/netbox/issues/18038) - The `to_grams()` utility function should always return an integer value
---
## v4.1.6 (2024-10-31)
### Bug Fixes
* [#17700](https://github.com/netbox-community/netbox/issues/17700) - Fix warning when no scripts are found within a script module
* [#17884](https://github.com/netbox-community/netbox/issues/17884) - Fix translation support for certain tab headings
* [#17885](https://github.com/netbox-community/netbox/issues/17885) - Fix regression preventing custom scripts from executing
## v4.1.5 (2024-10-28) ## v4.1.5 (2024-10-28)
### Enhancements ### Enhancements

View File

@ -9,6 +9,7 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.models import ObjectType from core.models import ObjectType
@ -158,7 +159,11 @@ class Job(models.Model):
job = queue.fetch_job(str(self.job_id)) job = queue.fetch_job(str(self.job_id))
if job: if job:
job.cancel() try:
job.cancel()
except InvalidJobOperation:
# Job may raise this exception from get_status() if missing from Redis
pass
def start(self): def start(self):
""" """

View File

@ -308,6 +308,7 @@ class BackgroundTaskTestCase(TestCase):
worker = get_worker('default') worker = get_worker('default')
job = queue.enqueue(self.dummy_job_default) job = queue.enqueue(self.dummy_job_default)
worker.prepare_job_execution(job) worker.prepare_job_execution(job)
worker.prepare_execution(job)
self.assertEqual(job.get_status(), JobStatus.STARTED) self.assertEqual(job.get_status(), JobStatus.STARTED)
@ -345,3 +346,32 @@ class BackgroundTaskTestCase(TestCase):
self.assertIn(str(worker1.name), str(response.content)) self.assertIn(str(worker1.name), str(response.content))
self.assertIn('Birth', str(response.content)) self.assertIn('Birth', str(response.content))
self.assertIn('Total working time', str(response.content)) self.assertIn('Total working time', str(response.content))
class SystemTestCase(TestCase):
def setUp(self):
super().setUp()
self.user.is_staff = True
self.user.save()
def test_system_view_default(self):
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)
def test_system_view_with_config_revision(self):
ConfigRevision.objects.create()
# Test UI render
response = self.client.get(reverse('core:system'))
self.assertEqual(response.status_code, 200)
# Test export
response = self.client.get(f"{reverse('core:system')}?export=true")
self.assertEqual(response.status_code, 200)

View File

@ -642,11 +642,7 @@ class SystemView(UserPassesTestMixin, View):
} }
# Configuration # Configuration
try: config = get_config()
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = get_config()
# Raw data export # Raw data export
if 'export' in request.GET: if 'export' in request.GET:

View File

@ -871,6 +871,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_100ME_T1 = '100base-t1' TYPE_100ME_T1 = '100base-t1'
TYPE_100ME_SFP = '100base-x-sfp' TYPE_100ME_SFP = '100base-x-sfp'
TYPE_1GE_FIXED = '1000base-t' TYPE_1GE_FIXED = '1000base-t'
TYPE_1GE_LX_FIXED = '1000base-lx'
TYPE_1GE_TX_FIXED = '1000base-tx' TYPE_1GE_TX_FIXED = '1000base-tx'
TYPE_1GE_GBIC = '1000base-x-gbic' TYPE_1GE_GBIC = '1000base-x-gbic'
TYPE_1GE_SFP = '1000base-x-sfp' TYPE_1GE_SFP = '1000base-x-sfp'
@ -1033,6 +1034,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'), (TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'), (TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'), (TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'), (TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'), (TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'), (TYPE_5GE_FIXED, '5GBASE-T (5GE)'),

View File

@ -14,10 +14,11 @@ from tenancy.models import Tenant
from users.models import User from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from wireless.models import WirelessLAN, WirelessLANGroup from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices from wireless.choices import WirelessRoleChoices
from wireless.models import WirelessLAN, WirelessLANGroup
__all__ = ( __all__ = (
'CableBulkEditForm', 'CableBulkEditForm',
@ -723,6 +724,14 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
queryset=ConfigTemplate.objects.all(), queryset=ConfigTemplate.objects.all(),
required=False required=False
) )
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
query_params={
'site_id': ['$site', 'null']
},
)
comments = CommentField() comments = CommentField()
model = Device model = Device
@ -731,9 +740,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('site', 'location', name=_('Location')), FieldSet('site', 'location', name=_('Location')),
FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')), FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')),
FieldSet('config_template', name=_('Configuration')), FieldSet('config_template', name=_('Configuration')),
FieldSet('cluster', name=_('Virtualization')),
) )
nullable_fields = ( nullable_fields = (
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments', 'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'cluster', 'comments',
) )
@ -1406,18 +1416,25 @@ class InterfaceBulkEditForm(
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
label=_('Parent'), label=_('Parent'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False,
query_params={
'virtual_chassis_member_id': '$device',
}
) )
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
label=_('Bridge'), label=_('Bridge'),
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False required=False,
query_params={
'virtual_chassis_member_id': '$device',
}
) )
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
query_params={ query_params={
'type': 'lag', 'type': 'lag',
'virtual_chassis_member_id': '$device',
}, },
label=_('LAG') label=_('LAG')
) )
@ -1474,6 +1491,7 @@ class InterfaceBulkEditForm(
required=False, required=False,
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
}, },
label=_('Untagged VLAN') label=_('Untagged VLAN')
) )
@ -1482,9 +1500,28 @@ class InterfaceBulkEditForm(
required=False, required=False,
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
}, },
label=_('Tagged VLANs') label=_('Tagged VLANs')
) )
add_tagged_vlans = DynamicModelMultipleChoiceField(
label=_('Add tagged VLANs'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
},
)
remove_tagged_vlans = DynamicModelMultipleChoiceField(
label=_('Remove tagged VLANs'),
queryset=VLAN.objects.all(),
required=False,
query_params={
'group_id': '$vlan_group',
'available_on_device': '$device',
}
)
vrf = DynamicModelChoiceField( vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
@ -1511,7 +1548,13 @@ class InterfaceBulkEditForm(
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')), FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
FieldSet('poe_mode', 'poe_type', name=_('PoE')), FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')), FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', name=_('802.1Q Switching')), FieldSet('mode', 'vlan_group', 'untagged_vlan', name=_('802.1Q Switching')),
FieldSet(
TabbedGroups(
FieldSet('tagged_vlans', name=_('Assignment')),
FieldSet('add_tagged_vlans', 'remove_tagged_vlans', name=_('Add/Remove')),
),
),
FieldSet( FieldSet(
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
name=_('Wireless') name=_('Wireless')
@ -1525,19 +1568,7 @@ class InterfaceBulkEditForm(
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.device_id: if not self.device_id:
device = Device.objects.filter(pk=self.device_id).first()
# Restrict parent/bridge/LAG interface assignment by device
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk)
# Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
else:
# See #4523 # See #4523
if 'pk' in self.initial: if 'pk' in self.initial:
site = None site = None
@ -1561,6 +1592,13 @@ class InterfaceBulkEditForm(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
) )
self.fields['add_tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['remove_tagged_vlans'].widget.add_query_param(
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
)
self.fields['parent'].choices = () self.fields['parent'].choices = ()
self.fields['parent'].widget.attrs['disabled'] = True self.fields['parent'].widget.attrs['disabled'] = True
self.fields['bridge'].choices = () self.fields['bridge'].choices = ()

View File

@ -918,6 +918,13 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
if self.instance.pk: if self.instance.pk:
self.fields['module_type'].disabled = True self.fields['module_type'].disabled = True
# Components attached to a module need to present this standardized substitution help text.
self.fields['name'].help_text = _(
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
"supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
"automatically replaced with the position value when creating a new module."
)
class ConsolePortTemplateForm(ModularComponentTemplateForm): class ConsolePortTemplateForm(ModularComponentTemplateForm):
fieldsets = ( fieldsets = (

View File

@ -243,14 +243,6 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
class Meta(model_forms.InterfaceForm.Meta): class Meta(model_forms.InterfaceForm.Meta):
exclude = ('name', 'label') exclude = ('name', 'label')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'module' in self.fields:
self.fields['name'].help_text += _(
"The string <code>{module}</code> will be replaced with the position of the assigned module, if any."
)
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(

View File

@ -35,7 +35,7 @@ from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import * from .models import *
CABLE_TERMINATION_TYPES = { CABLE_TERMINATION_TYPES = {
@ -2792,6 +2792,16 @@ class InterfaceBulkEditView(generic.BulkEditView):
table = tables.InterfaceTable table = tables.InterfaceTable
form = forms.InterfaceBulkEditForm form = forms.InterfaceBulkEditForm
def post_save_operations(self, form, obj):
super().post_save_operations(form, obj)
# Add/remove tagged VLANs
if obj.mode == InterfaceModeChoices.MODE_TAGGED:
if form.cleaned_data.get('add_tagged_vlans', None):
obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans'])
if form.cleaned_data.get('remove_tagged_vlans', None):
obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans'])
@register_model_view(Interface, 'bulk_rename', path='rename', detail=False) @register_model_view(Interface, 'bulk_rename', path='rename', detail=False)
class InterfaceBulkRenameView(generic.BulkRenameView): class InterfaceBulkRenameView(generic.BulkRenameView):

View File

@ -22,9 +22,7 @@ class ScriptJob(JobRunner):
""" """
class Meta: class Meta:
# An explicit job name is not set because it doesn't make sense in this context. Currently, there's no scenario name = 'Run Script'
# where jobs other than this one are used. Therefore, it is hidden, resulting in a cleaner job table overview.
name = ''
def run_script(self, script, request, data, commit): def run_script(self, script, request, data, commit):
""" """

View File

@ -1250,7 +1250,6 @@ class ScriptView(BaseScriptView):
request=copy_safe_request(request), request=copy_safe_request(request),
job_timeout=script.python_class.job_timeout, job_timeout=script.python_class.job_timeout,
commit=form.cleaned_data.pop('_commit'), commit=form.cleaned_data.pop('_commit'),
name=script.name
) )
return redirect('extras:script_result', job_pk=job.pk) return redirect('extras:script_result', job_pk=job.pk)

View File

@ -96,16 +96,32 @@ class VLANGroup(OrganizationalModel):
raise ValidationError(_("Cannot set scope_id without scope_type.")) raise ValidationError(_("Cannot set scope_id without scope_type."))
# Validate VID ranges # Validate VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
for vid_range in self.vid_ranges: for vid_range in self.vid_ranges:
if vid_range.lower > vid_range.upper: lower_vid = vid_range.lower if vid_range.lower_inc else vid_range.lower + 1
upper_vid = vid_range.upper if vid_range.upper_inc else vid_range.upper - 1
if lower_vid < VLAN_VID_MIN:
raise ValidationError({
'vid_ranges': _("Starting VLAN ID in range ({value}) cannot be less than {minimum}").format(
value=lower_vid, minimum=VLAN_VID_MIN
)
})
if upper_vid > VLAN_VID_MAX:
raise ValidationError({
'vid_ranges': _("Ending VLAN ID in range ({value}) cannot exceed {maximum}").format(
value=upper_vid, maximum=VLAN_VID_MAX
)
})
if lower_vid > upper_vid:
raise ValidationError({ raise ValidationError({
'vid_ranges': _( 'vid_ranges': _(
"Maximum child VID must be greater than or equal to minimum child VID ({value})" "Ending VLAN ID in range must be greater than or equal to the starting VLAN ID ({range})"
).format(value=vid_range) ).format(range=f'{lower_vid}-{upper_vid}')
}) })
# Check for overlapping VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
self._total_vlan_ids = 0 self._total_vlan_ids = 0
for vid_range in self.vid_ranges: for vid_range in self.vid_ranges:

View File

@ -14,7 +14,6 @@ class NetBoxGraphQLView(GraphQLView):
""" """
Extends strawberry's GraphQLView to support DRF's token-based authentication. Extends strawberry's GraphQLView to support DRF's token-based authentication.
""" """
graphiql_template = 'graphiql.html'
@csrf_exempt @csrf_exempt
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):

View File

@ -91,6 +91,7 @@ class JobRunner(ABC):
kwargs["job_timeout"] = job.object.python_class.job_timeout kwargs["job_timeout"] = job.object.python_class.job_timeout
cls.enqueue( cls.enqueue(
instance=job.object, instance=job.object,
name=job.name,
user=job.user, user=job.user,
schedule_at=new_scheduled_time, schedule_at=new_scheduled_time,
interval=job.interval, interval=job.interval,

View File

@ -1,7 +1,7 @@
import urllib.parse import urllib.parse
from django.urls import reverse from django.urls import reverse
from django.test import override_settings from django.test import Client, override_settings
from dcim.models import Site from dcim.models import Site
from netbox.constants import EMPTY_TABLE_TEXT from netbox.constants import EMPTY_TABLE_TEXT
@ -74,3 +74,21 @@ class SearchViewTestCase(TestCase):
self.assertHttpStatus(response, 200) self.assertHttpStatus(response, 200)
content = str(response.content) content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content) self.assertIn(EMPTY_TABLE_TEXT, content)
class MediaViewTestCase(TestCase):
def test_media_login_required(self):
url = reverse('media', kwargs={'path': 'foo.txt'})
response = Client().get(url)
# Unauthenticated request should redirect to login page
self.assertHttpStatus(response, 302)
@override_settings(LOGIN_REQUIRED=False)
def test_media_login_not_required(self):
url = reverse('media', kwargs={'path': 'foo.txt'})
response = Client().get(url)
# Unauthenticated request should return a 404 (not found)
self.assertHttpStatus(response, 404)

View File

@ -2,7 +2,6 @@ from django.conf import settings
from django.conf.urls import include from django.conf.urls import include
from django.urls import path from django.urls import path
from django.views.decorators.cache import cache_page from django.views.decorators.cache import cache_page
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView from account.views import LoginView, LogoutView
@ -10,7 +9,7 @@ from netbox.api.views import APIRootView, StatusView
from netbox.graphql.schema import schema from netbox.graphql.schema import schema
from netbox.graphql.views import NetBoxGraphQLView from netbox.graphql.views import NetBoxGraphQLView
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx
_patterns = [ _patterns = [
@ -69,7 +68,7 @@ _patterns = [
path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'), path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
# Serving static media in Django to pipe it through LoginRequiredMiddleware # Serving static media in Django to pipe it through LoginRequiredMiddleware
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}), path('media/<path:path>', MediaView.as_view(), name='media'),
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'), path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
# Plugins # Plugins

View File

@ -541,6 +541,17 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'change') return get_permission_for_model(self.queryset.model, 'change')
def post_save_operations(self, form, obj):
"""
This method is called for each object in _update_objects. Override to perform additional object-level
operations that are specific to a particular ModelForm.
"""
# Add/remove tags
if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags'])
if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags'])
def _update_objects(self, form, request): def _update_objects(self, form, request):
custom_fields = getattr(form, 'custom_fields', {}) custom_fields = getattr(form, 'custom_fields', {})
standard_fields = [ standard_fields = [
@ -612,11 +623,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
elif form.cleaned_data[name]: elif form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name]) getattr(obj, name).set(form.cleaned_data[name])
# Add/remove tags self.post_save_operations(form, obj)
if form.cleaned_data.get('add_tags', None):
obj.tags.add(*form.cleaned_data['add_tags'])
if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags'])
# Rebuild the tree for MPTT models # Rebuild the tree for MPTT models
if issubclass(self.queryset.model, MPTTModel): if issubclass(self.queryset.model, MPTTModel):
@ -691,7 +698,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
logger.debug("Form validation failed") logger.debug("Form validation failed")
else: else:
form = self.form(request.POST, initial=initial_data) form = self.form(initial=initial_data)
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)
# Retrieve objects being edited # Retrieve objects being edited

View File

@ -8,6 +8,7 @@ from django.core.cache import cache
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import View from django.views.generic import View
from django.views.static import serve
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from packaging import version from packaging import version
@ -23,6 +24,7 @@ from utilities.views import ConditionalLoginRequiredMixin
__all__ = ( __all__ = (
'HomeView', 'HomeView',
'MediaView',
'SearchView', 'SearchView',
) )
@ -115,3 +117,11 @@ class SearchView(ConditionalLoginRequiredMixin, View):
'form': form, 'form': form,
'table': table, 'table': table,
}) })
class MediaView(ConditionalLoginRequiredMixin, View):
"""
Wrap Django's serve() view to enforce LOGIN_REQUIRED for static media.
"""
def get(self, request, path):
return serve(request, path, document_root=settings.MEDIA_ROOT)

View File

@ -30,7 +30,7 @@
"gridstack": "10.3.1", "gridstack": "10.3.1",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"query-string": "9.1.1", "query-string": "9.1.1",
"sass": "1.80.4", "sass": "1.80.5",
"tom-select": "2.3.1", "tom-select": "2.3.1",
"typeface-inter": "3.18.1", "typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13" "typeface-roboto-mono": "1.1.13"

View File

@ -2656,10 +2656,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0" es-errors "^1.3.0"
is-regex "^1.1.4" is-regex "^1.1.4"
sass@1.80.4: sass@1.80.5:
version "1.80.4" version "1.80.5"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.4.tgz#bc0418fd796cad2f1a1309d8b4d7fe44b7027de0" resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.5.tgz#0ba965223d44df22497f2966b498cf5c453fae8f"
integrity sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w== integrity sha512-TQd2aoQl/+zsxRMEDSxVdpPIqeq9UFc6pr7PzkugiTx3VYCFPUaa3P4RrBQsqok4PO200Vkz0vXQBNlg7W907g==
dependencies: dependencies:
"@parcel/watcher" "^2.4.1" "@parcel/watcher" "^2.4.1"
chokidar "^4.0.0" chokidar "^4.0.0"

View File

@ -1,3 +1,3 @@
version: "4.1.5" version: "4.1.7"
edition: "Community" edition: "Community"
published: "2024-10-28" published: "2024-11-21"

View File

@ -87,7 +87,7 @@
{% for name, field in backend.parameters.items %} {% for name, field in backend.parameters.items %}
<tr> <tr>
<th scope="row">{{ field.label }}</th> <th scope="row">{{ field.label }}</th>
{% if name in backend.sensitive_parameters and not perms.core.change_datasource %} {% if name in backend.sensitive_parameters %}
<td>********</td> <td>********</td>
{% else %} {% else %}
<td>{{ object.parameters|get_key:name|placeholder }}</td> <td>{{ object.parameters|get_key:name|placeholder }}</td>

View File

@ -1,15 +1,8 @@
{% load static %}
{% comment %} {% comment %}
This template derives from the strawberry-graphql project: This template derives from the strawberry-graphql project:
https://github.com/strawberry-graphql/strawberry/blob/main/strawberry/static/graphiql.html https://github.com/strawberry-graphql/strawberry/blob/main/strawberry/static/graphiql.html
{% endcomment %} {% endcomment %}
<!--
The request to this GraphQL server provided the header "Accept: text/html"
and as a result has been presented GraphiQL - an in-browser IDE for
exploring GraphQL.
If you wish to receive JSON, provide the header "Accept: application/json" or
add "&raw" to the end of the URL within a browser.
-->
{% load static %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
@ -112,10 +105,7 @@ add "&raw" to the end of the URL within a browser.
headers["x-csrftoken"] = csrfToken; headers["x-csrftoken"] = csrfToken;
} }
const subscriptionsEnabled = JSON.parse("{{ SUBSCRIPTION_ENABLED }}"); const subscriptionUrl = httpUrlToWebSockeUrl(fetchURL);
const subscriptionUrl = subscriptionsEnabled
? httpUrlToWebSockeUrl(fetchURL)
: null;
const fetcher = GraphiQL.createFetcher({ const fetcher = GraphiQL.createFetcher({
url: fetchURL, url: fetchURL,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -11,9 +11,9 @@ __all__ = (
) )
def to_grams(weight, unit): def to_grams(weight, unit) -> int:
""" """
Convert the given weight to kilograms. Convert the given weight to integer grams.
""" """
try: try:
if weight < 0: if weight < 0:
@ -22,13 +22,13 @@ def to_grams(weight, unit):
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight)) raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
if unit == WeightUnitChoices.UNIT_KILOGRAM: if unit == WeightUnitChoices.UNIT_KILOGRAM:
return weight * 1000 return int(weight * 1000)
if unit == WeightUnitChoices.UNIT_GRAM: if unit == WeightUnitChoices.UNIT_GRAM:
return weight return int(weight)
if unit == WeightUnitChoices.UNIT_POUND: if unit == WeightUnitChoices.UNIT_POUND:
return weight * Decimal(453.592) return int(weight * Decimal(453.592))
if unit == WeightUnitChoices.UNIT_OUNCE: if unit == WeightUnitChoices.UNIT_OUNCE:
return weight * Decimal(28.3495) return int(weight * Decimal(28.3495))
raise ValueError( raise ValueError(
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format( _("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
unit=unit, unit=unit,

View File

@ -153,7 +153,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
) )
disk = forms.IntegerField( disk = forms.IntegerField(
required=False, required=False,
label=_('Disk (GB)') label=_('Disk (MB)')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),
@ -313,7 +313,7 @@ class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
) )
size = forms.IntegerField( size = forms.IntegerField(
required=False, required=False,
label=_('Size (GB)') label=_('Size (MB)')
) )
description = forms.CharField( description = forms.CharField(
label=_('Description'), label=_('Description'),

View File

@ -253,7 +253,7 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
label=_('Virtual machine') label=_('Virtual machine')
) )
size = forms.IntegerField( size = forms.IntegerField(
label=_('Size (GB)'), label=_('Size (MB)'),
required=False, required=False,
min_value=1 min_value=1
) )

View File

@ -1,5 +1,5 @@
Django==5.1.2 Django==5.1.2
django-cors-headers==4.5.0 django-cors-headers==4.6.0
django-debug-toolbar==4.4.6 django-debug-toolbar==4.4.6
django-filter==24.3 django-filter==24.3
django-htmx==1.21.0 django-htmx==1.21.0
@ -8,31 +8,31 @@ django-mptt==0.16.0
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.3.1 django-prometheus==2.3.1
django-redis==5.4.0 django-redis==5.4.0
django-rich==1.12.0 django-rich==1.13.0
django-rq==2.10.2 django-rq==3.0
django-taggit==6.1.0 django-taggit==6.1.0
django-tables2==2.7.0 django-tables2==2.7.0
django-timezone-field==7.0 django-timezone-field==7.0
djangorestframework==3.15.2 djangorestframework==3.15.2
drf-spectacular==0.27.2 drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.7.1 drf-spectacular-sidecar==2024.11.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==23.0.0 gunicorn==23.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.7 Markdown==3.7
mkdocs-material==9.5.42 mkdocs-material==9.5.45
mkdocstrings[python-legacy]==0.26.2 mkdocstrings[python-legacy]==0.27.0
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.18 nh3==0.2.18
Pillow==11.0.0 Pillow==11.0.0
psycopg[c,pool]==3.2.3 psycopg[c,pool]==3.2.3
PyYAML==6.0.2 PyYAML==6.0.2
requests==2.32.3 requests==2.32.3
rq==1.16.2 rq==2.0
social-auth-app-django==5.4.2 social-auth-app-django==5.4.2
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.247.0 strawberry-graphql==0.251.0
strawberry-graphql-django==0.49.1 strawberry-graphql-django==0.50.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.7.0 tablib==3.7.0
tzdata==2024.2 tzdata==2024.2