Compare commits

..

4 Commits

Author SHA1 Message Date
Martin Hauser
acc7c4af9d feat(extras): Add AVIF support for image attachments
Extends allowed image file formats to include AVIF for better modern
format support. Updates form widgets to explicitly support AVIF in
Firefox with the `accept` attribute.

Fixes #21039
2026-01-15 21:21:25 +01:00
Martin Hauser
1c46215cd5 feat(extras): Allow updates to data_source and data_file via API
Adds support for PATCHing ConfigContext and ConfigContextProfile with
integer IDs for `data_source` and `data_file`.
Adds regression tests to validate assignment and API functionality.

Fixes #20933
2026-01-15 14:37:16 -05:00
Martin Hauser
0ddc5805c4 fix(core): Use gettext_lazy in data.py
Replace `gettext()` with `gettext_lazy()` to avoid locale-dependent
model serialization (and false-positive pending migration warnings).
Also make a missing `ValidationError` message translatable and
format-safe.

Fixes #21175
2026-01-15 12:47:05 -05:00
github-actions
c1bbc026e2 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-15 05:05:36 +00:00
12 changed files with 123 additions and 48 deletions

View File

@@ -44,3 +44,4 @@ class DataFileSerializer(NetBoxModelSerializer):
'id', 'url', 'display_url', 'display', 'source', 'path', 'last_updated', 'size', 'hash',
]
brief_fields = ('id', 'url', 'display', 'path')
read_only_fields = ['path', 'last_updated', 'size', 'hash']

View File

@@ -12,7 +12,7 @@ from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
from netbox.models import PrimaryModel
@@ -128,7 +128,9 @@ class DataSource(JobsMixin, PrimaryModel):
# Ensure URL scheme matches selected type
if self.backend_class.is_local and self.url_scheme not in ('file', ''):
raise ValidationError({
'source_url': "URLs for local sources must start with file:// (or specify no scheme)"
'source_url': _("URLs for local sources must start with {scheme} (or specify no scheme)").format(
scheme='file://'
)
})
def save(self, *args, **kwargs):

View File

@@ -733,10 +733,9 @@ class ModuleForm(ModuleCommonForm, PrimaryModelForm):
)
module_bay = DynamicModelChoiceField(
label=_('Module bay'),
queryset=ModuleBay.objects.order_by('name'),
queryset=ModuleBay.objects.all(),
query_params={
'device_id': '$device',
'ordering': 'name',
'device_id': '$device'
},
context={
'disabled': 'installed_module',

View File

@@ -28,7 +28,7 @@ class ConfigContextProfileSerializer(PrimaryModelSerializer):
)
data_file = DataFileSerializer(
nested=True,
read_only=True
required=False
)
class Meta:
@@ -143,7 +143,7 @@ class ConfigContextSerializer(OwnerMixin, ChangeLogMessageSerializer, ValidatedM
)
data_file = DataFileSerializer(
nested=True,
read_only=True
required=False
)
class Meta:

View File

@@ -22,7 +22,7 @@ from utilities.forms.fields import (
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.rendering import FieldSet, ObjectAttribute
from utilities.forms.widgets import ChoicesWidget, HTMXSelect
from utilities.forms.widgets import ChoicesWidget, ClearableFileInput, HTMXSelect
from utilities.tables import get_table_for_model
from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -784,6 +784,10 @@ class ImageAttachmentForm(forms.ModelForm):
fields = [
'image', 'name', 'description',
]
# Explicitly set 'image/avif' to support AVIF selection in Firefox
widgets = {
'image': ClearableFileInput(attrs={'accept': 'image/*,image/avif'}),
}
help_texts = {
'name': _("If no name is specified, the file name will be used.")
}

View File

@@ -1,4 +1,5 @@
import datetime
import hashlib
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@@ -7,7 +8,7 @@ from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
from core.models import ObjectType
from core.models import DataFile, DataSource, ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.choices import *
from extras.models import *
@@ -731,6 +732,51 @@ class ConfigContextProfileTest(APIViewTestCases.APIViewTestCase):
)
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):
model = ConfigContext
@@ -812,6 +858,51 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
rendered_context = device.get_config_context()
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):
model = ConfigTemplate

View File

@@ -78,7 +78,7 @@ def image_upload(instance, filename):
"""
upload_dir = 'image-attachments'
default_filename = 'unnamed'
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
allowed_img_extensions = ('avif', 'bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp')
# Normalize Windows paths and create a Path object.
normalized_filename = str(filename).replace('\\', '/')

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -75,16 +75,10 @@ export class DynamicTomSelect extends TomSelect {
load(value: string) {
const self = this;
// Save current selection before clearing
const currentValue = self.getValue();
// Automatically clear any cached options. (Only options included
// in the API response should be present.)
self.clearOptions();
// Clear user_options to prevent the pre-selected option from being treated specially
(self as any).user_options = {};
// Populate the null option (if any) if not searching
if (self.nullOption && !value) {
self.addOption(self.nullOption);
@@ -104,29 +98,16 @@ export class DynamicTomSelect extends TomSelect {
.then(response => response.json())
.then(apiData => {
const results: Dict[] = apiData.results;
// Add options and manually set $order to ensure correct sorting
results.forEach((result, index) => {
const options: Dict[] = [];
for (const result of results) {
const option = self.getOptionFromData(result);
self.addOption(option);
// Set $order after addOption() to override any special handling of pre-selected items
const key = option[self.settings.valueField as string] as string;
if (self.options[key]) {
(self.options[key] as any).$order = index;
}
});
self.loading--;
if (self.loading === 0) {
self.wrapper.classList.remove(self.settings.loadingClass as string);
options.push(option);
}
// Restore the current selection
if (currentValue && !self.items.includes(currentValue as string)) {
self.items.push(currentValue as string);
}
self.refreshOptions(false);
return options;
})
// Pass the options to the callback function
.then(options => {
self.loadCallback(options, []);
})
.catch(() => {
self.loadCallback([], []);

View File

@@ -49,9 +49,6 @@ export function initDynamicSelects(): void {
labelField: LABEL_FIELD,
maxOptions: MAX_OPTIONS,
// Preserve API response order
sortField: '$order',
// Disable local search (search is performed on the backend)
searchField: [],

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-13 05:05+0000\n"
"POT-Creation-Date: 2026-01-15 05:05+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -12489,8 +12489,8 @@ msgstr ""
msgid "Delete Selected"
msgstr ""
#: netbox/netbox/plugins/navigation.py:55
#: netbox/netbox/plugins/navigation.py:88
#: netbox/netbox/plugins/navigation.py:53
#: netbox/netbox/plugins/navigation.py:89
msgid "Permissions must be passed as a tuple or list."
msgstr ""
@@ -12498,7 +12498,7 @@ msgstr ""
msgid "Buttons must be passed as a tuple or list."
msgstr ""
#: netbox/netbox/plugins/navigation.py:92
#: netbox/netbox/plugins/navigation.py:95
msgid "Button color must be a choice within ButtonColorChoices."
msgstr ""