mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-16 16:52:17 -06:00
Compare commits
2 Commits
21039-avif
...
20383-rack
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e8023899f | ||
|
|
601a7092e0 |
@@ -44,4 +44,3 @@ class DataFileSerializer(NetBoxModelSerializer):
|
|||||||
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
|
||||||
]
|
]
|
||||||
brief_fields = ('id', 'url', 'display', 'path')
|
brief_fields = ('id', 'url', 'display', 'path')
|
||||||
read_only_fields = ['path', 'last_updated', 'size', 'hash']
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
|
|||||||
from django.db import models
|
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_lazy as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||||
from netbox.models import PrimaryModel
|
from netbox.models import PrimaryModel
|
||||||
@@ -128,9 +128,7 @@ class DataSource(JobsMixin, PrimaryModel):
|
|||||||
# Ensure URL scheme matches selected type
|
# Ensure URL scheme matches selected type
|
||||||
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
|
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
|
||||||
scheme='file://'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
|||||||
@@ -722,6 +722,9 @@ class DeviceForm(TenancyForm, PrimaryModelForm):
|
|||||||
if position:
|
if position:
|
||||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||||
|
|
||||||
|
# Clear face field when rack is cleared
|
||||||
|
self.fields['face'].widget.attrs['ts-clear-field'] = 'rack'
|
||||||
|
|
||||||
|
|
||||||
class ModuleForm(ModuleCommonForm, PrimaryModelForm):
|
class ModuleForm(ModuleCommonForm, PrimaryModelForm):
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
|
|||||||
)
|
)
|
||||||
data_file = DataFileSerializer(
|
data_file = DataFileSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False
|
read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
|
|||||||
)
|
)
|
||||||
data_file = DataFileSerializer(
|
data_file = DataFileSerializer(
|
||||||
nested=True,
|
nested=True,
|
||||||
required=False
|
read_only=True
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ from utilities.forms.fields import (
|
|||||||
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||||
)
|
)
|
||||||
from utilities.forms.rendering import FieldSet, ObjectAttribute
|
from utilities.forms.rendering import FieldSet, ObjectAttribute
|
||||||
from utilities.forms.widgets import ChoicesWidget, ClearableFileInput, HTMXSelect
|
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
|
||||||
from utilities.tables import get_table_for_model
|
from utilities.tables import get_table_for_model
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
@@ -784,10 +784,6 @@ class ImageAttachmentForm(forms.ModelForm):
|
|||||||
fields = [
|
fields = [
|
||||||
'image', 'name', 'description',
|
'image', 'name', 'description',
|
||||||
]
|
]
|
||||||
# Explicitly set 'image/avif' to support AVIF selection in Firefox
|
|
||||||
widgets = {
|
|
||||||
'image': ClearableFileInput(attrs={'accept': 'image/*,image/avif'}),
|
|
||||||
}
|
|
||||||
help_texts = {
|
help_texts = {
|
||||||
'name': _("If no name is specified, the file name will be used.")
|
'name': _("If no name is specified, the file name will be used.")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import hashlib
|
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
@@ -8,7 +7,7 @@ from rest_framework import status
|
|||||||
|
|
||||||
from core.choices import ManagedFileRootPathChoices
|
from core.choices import ManagedFileRootPathChoices
|
||||||
from core.events import *
|
from core.events import *
|
||||||
from core.models import DataFile, DataSource, ObjectType
|
from core.models import ObjectType
|
||||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
@@ -732,51 +731,6 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
|
|||||||
)
|
)
|
||||||
ConfigContextProfile.objects.bulk_create(profiles)
|
ConfigContextProfile.objects.bulk_create(profiles)
|
||||||
|
|
||||||
def test_update_data_source_and_data_file(self):
|
|
||||||
"""
|
|
||||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
|
||||||
|
|
||||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
|
||||||
"""
|
|
||||||
self.add_permissions(
|
|
||||||
'core.view_datafile',
|
|
||||||
'core.view_datasource',
|
|
||||||
'extras.view_configcontextprofile',
|
|
||||||
'extras.change_configcontextprofile',
|
|
||||||
)
|
|
||||||
config_context_profile = ConfigContextProfile.objects.first()
|
|
||||||
|
|
||||||
# Create a data source and file
|
|
||||||
datasource = DataSource.objects.create(
|
|
||||||
name='Data Source 1',
|
|
||||||
type='local',
|
|
||||||
source_url='file:///tmp/netbox-datasource/',
|
|
||||||
)
|
|
||||||
# Generate a valid dummy YAML file
|
|
||||||
file_data = b'profile: configcontext\n'
|
|
||||||
datafile = DataFile.objects.create(
|
|
||||||
source=datasource,
|
|
||||||
path='dir1/file1.yml',
|
|
||||||
last_updated=now(),
|
|
||||||
size=len(file_data),
|
|
||||||
hash=hashlib.sha256(file_data).hexdigest(),
|
|
||||||
data=file_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = self._get_detail_url(config_context_profile)
|
|
||||||
payload = {
|
|
||||||
'data_source': datasource.pk,
|
|
||||||
'data_file': datafile.pk,
|
|
||||||
}
|
|
||||||
response = self.client.patch(url, payload, format='json', **self.header)
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
config_context_profile.refresh_from_db()
|
|
||||||
self.assertEqual(config_context_profile.data_source_id, datasource.pk)
|
|
||||||
self.assertEqual(config_context_profile.data_file_id, datafile.pk)
|
|
||||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
|
||||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ConfigContext
|
model = ConfigContext
|
||||||
@@ -858,51 +812,6 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
|||||||
rendered_context = device.get_config_context()
|
rendered_context = device.get_config_context()
|
||||||
self.assertEqual(rendered_context['bar'], 456)
|
self.assertEqual(rendered_context['bar'], 456)
|
||||||
|
|
||||||
def test_update_data_source_and_data_file(self):
|
|
||||||
"""
|
|
||||||
Regression test: Ensure data_source and data_file can be assigned via the API.
|
|
||||||
|
|
||||||
This specifically covers PATCHing a ConfigContext with integer IDs for both fields.
|
|
||||||
"""
|
|
||||||
self.add_permissions(
|
|
||||||
'core.view_datafile',
|
|
||||||
'core.view_datasource',
|
|
||||||
'extras.view_configcontext',
|
|
||||||
'extras.change_configcontext',
|
|
||||||
)
|
|
||||||
config_context = ConfigContext.objects.first()
|
|
||||||
|
|
||||||
# Create a data source and file
|
|
||||||
datasource = DataSource.objects.create(
|
|
||||||
name='Data Source 1',
|
|
||||||
type='local',
|
|
||||||
source_url='file:///tmp/netbox-datasource/',
|
|
||||||
)
|
|
||||||
# Generate a valid dummy YAML file
|
|
||||||
file_data = b'context: config\n'
|
|
||||||
datafile = DataFile.objects.create(
|
|
||||||
source=datasource,
|
|
||||||
path='dir1/file1.yml',
|
|
||||||
last_updated=now(),
|
|
||||||
size=len(file_data),
|
|
||||||
hash=hashlib.sha256(file_data).hexdigest(),
|
|
||||||
data=file_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
url = self._get_detail_url(config_context)
|
|
||||||
payload = {
|
|
||||||
'data_source': datasource.pk,
|
|
||||||
'data_file': datafile.pk,
|
|
||||||
}
|
|
||||||
response = self.client.patch(url, payload, format='json', **self.header)
|
|
||||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
|
||||||
|
|
||||||
config_context.refresh_from_db()
|
|
||||||
self.assertEqual(config_context.data_source_id, datasource.pk)
|
|
||||||
self.assertEqual(config_context.data_file_id, datafile.pk)
|
|
||||||
self.assertEqual(response.data['data_source']['id'], datasource.pk)
|
|
||||||
self.assertEqual(response.data['data_file']['id'], datafile.pk)
|
|
||||||
|
|
||||||
|
|
||||||
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||||
model = ConfigTemplate
|
model = ConfigTemplate
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def image_upload(instance, filename):
|
|||||||
"""
|
"""
|
||||||
upload_dir = 'image-attachments'
|
upload_dir = 'image-attachments'
|
||||||
default_filename = 'unnamed'
|
default_filename = 'unnamed'
|
||||||
allowed_img_extensions = ('avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
|
||||||
|
|
||||||
# Normalize Windows paths and create a Path object.
|
# Normalize Windows paths and create a Path object.
|
||||||
normalized_filename = str(filename).replace('\\', '/')
|
normalized_filename = str(filename).replace('\\', '/')
|
||||||
|
|||||||
8
netbox/project-static/dist/netbox.js
vendored
8
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
|||||||
|
import TomSelect from 'tom-select';
|
||||||
import { getElements } from '../util';
|
import { getElements } from '../util';
|
||||||
|
|
||||||
function handleFormSubmit(): void {
|
function handleFormSubmit(): void {
|
||||||
@@ -8,6 +9,37 @@ function handleFormSubmit(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize clear-field dependencies.
|
||||||
|
* When a field with ts-clear-field attribute's parent field is cleared, this field will also be cleared.
|
||||||
|
*/
|
||||||
|
function initClearFieldDependencies(): void {
|
||||||
|
// Find all fields with ts-clear-field attribute
|
||||||
|
for (const field of getElements<HTMLSelectElement>('[ts-clear-field]')) {
|
||||||
|
const parentFieldName = field.getAttribute('ts-clear-field');
|
||||||
|
if (!parentFieldName) continue;
|
||||||
|
|
||||||
|
// Find the parent field
|
||||||
|
const parentField = document.querySelector<HTMLSelectElement>(`[name="${parentFieldName}"]`);
|
||||||
|
if (!parentField) continue;
|
||||||
|
|
||||||
|
// Listen for changes on the parent field
|
||||||
|
parentField.addEventListener('change', () => {
|
||||||
|
// If parent field is cleared, also clear this dependent field
|
||||||
|
if (!parentField.value || parentField.value === '') {
|
||||||
|
// Check if this field uses TomSelect
|
||||||
|
const tomselect = (field as HTMLSelectElement & { tomselect?: TomSelect }).tomselect;
|
||||||
|
if (tomselect) {
|
||||||
|
tomselect.clear();
|
||||||
|
} else {
|
||||||
|
// Regular select field
|
||||||
|
field.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Attach event listeners to each form's submit/reset buttons.
|
* Attach event listeners to each form's submit/reset buttons.
|
||||||
*/
|
*/
|
||||||
@@ -28,4 +60,7 @@ export function initFormElements(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize clear-field dependencies
|
||||||
|
initClearFieldDependencies();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-01-15 05:05+0000\n"
|
"POT-Creation-Date: 2026-01-13 05:05+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -12489,8 +12489,8 @@ msgstr ""
|
|||||||
msgid "Delete Selected"
|
msgid "Delete Selected"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: netbox/netbox/plugins/navigation.py:53
|
#: netbox/netbox/plugins/navigation.py:55
|
||||||
#: netbox/netbox/plugins/navigation.py:89
|
#: netbox/netbox/plugins/navigation.py:88
|
||||||
msgid "Permissions must be passed as a tuple or list."
|
msgid "Permissions must be passed as a tuple or list."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -12498,7 +12498,7 @@ msgstr ""
|
|||||||
msgid "Buttons must be passed as a tuple or list."
|
msgid "Buttons must be passed as a tuple or list."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: netbox/netbox/plugins/navigation.py:95
|
#: netbox/netbox/plugins/navigation.py:92
|
||||||
msgid "Button color must be a choice within ButtonColorChoices."
|
msgid "Button color must be a choice within ButtonColorChoices."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user