Compare commits

..

1 Commits

Author SHA1 Message Date
Martin Hauser
ae1203c07b feat(extras): Add AVIF support for image attachments
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
Extends allowed image file formats to include AVIF for better modern
format support. Introduces a constants mapping for image formats to
centralize file type definitions. Updates form widgets and utilities
to leverage the new constants, enabling more flexible and consistent
image handling.

Fixes #21039
2026-01-17 10:48:43 +01:00
6 changed files with 90 additions and 100 deletions

View File

@@ -18,17 +18,7 @@ They can also be used as a mechanism for validating the integrity of data within
Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish. Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
!!! danger "Only install trusted scripts" !!! danger "Only install trusted scripts"
Custom scripts have unrestricted access to change anything in the database and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data. Custom scripts have unrestricted access to change anything in the databse and are inherently unsafe and should only be installed and run from trusted sources. You should also review and set permissions for who can run scripts if the script can modify any data.
!!! tip "Permissions for Custom Scripts"
A user can be granted permissions on all Custom Scripts via the "Managed File" object-level permission. To further restrict a user to only be able to access certain scripts, create an additional permission on the "Script" object type, with appropriate queryset-style constraints matching fields available on Script. For example:
```json
{
"name__in": [
"MyScript"
]
}
```
## Writing Custom Scripts ## Writing Custom Scripts

View File

@@ -4,6 +4,17 @@ from extras.choices import LogLevelChoices
# Custom fields # Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', []) CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
# ImageAttachment
IMAGE_ATTACHMENT_IMAGE_FORMATS = {
'avif': 'image/avif',
'bmp': 'image/bmp',
'gif': 'image/gif',
'jpeg': 'image/jpeg',
'jpg': 'image/jpeg',
'png': 'image/png',
'webp': 'image/webp',
}
# Template Export # Template Export
DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8' DEFAULT_MIME_TYPE = 'text/plain; charset=utf-8'

View File

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
from core.forms.mixins import SyncedDataMixin from core.forms.mixins import SyncedDataMixin
from core.models import ObjectType from core.models import ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from extras.constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from netbox.events import get_event_type_choices from netbox.events import get_event_type_choices
@@ -784,8 +785,11 @@ class ImageAttachmentForm(forms.ModelForm):
fields = [ fields = [
'image', 'name', 'description', 'image', 'name', 'description',
] ]
help_texts = { # Explicitly set 'image/avif' to support AVIF selection in Firefox
'name': _("If no name is specified, the file name will be used.") widgets = {
'image': forms.ClearableFileInput(
attrs={'accept': ','.join(sorted(set(IMAGE_ATTACHMENT_IMAGE_FORMATS.values())))}
),
} }

View File

@@ -10,6 +10,7 @@ from taggit.managers import _TaggableManager
from netbox.context import current_request from netbox.context import current_request
from .constants import IMAGE_ATTACHMENT_IMAGE_FORMATS
from .validators import CustomValidator from .validators import CustomValidator
__all__ = ( __all__ = (
@@ -78,7 +79,7 @@ def image_upload(instance, filename):
""" """
upload_dir = 'image-attachments' upload_dir = 'image-attachments'
default_filename = 'unnamed' default_filename = 'unnamed'
allowed_img_extensions = ('bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp') allowed_img_extensions = IMAGE_ATTACHMENT_IMAGE_FORMATS.keys()
# 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('\\', '/')

View File

@@ -24,11 +24,9 @@ from extras.utils import SharedObjectViewMixin
from netbox.object_actions import * from netbox.object_actions import *
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from users.models import ObjectPermission
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page from utilities.htmx import htmx_partial, htmx_maybe_redirect_current_page
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import qs_filter_from_constraints
from utilities.query import count_related from utilities.query import count_related
from utilities.querydict import normalize_querydict from utilities.querydict import normalize_querydict
from utilities.request import copy_safe_request from utilities.request import copy_safe_request
@@ -1443,24 +1441,12 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script' return 'extras.view_script'
def get(self, request): def get(self, request):
# Permissions for the Scripts page are given via the "Managed File" object permission. To further restrict
# users to access only specified scripts, create permissions on the "Script" object with appropriate
# queryset-style constraints matching fields available on Script.
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related( script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
'data_source', 'data_file', 'jobs' 'data_source', 'data_file', 'jobs'
) )
script_ct = ContentType.objects.get_for_model(Script)
script_permissions = qs_filter_from_constraints(
ObjectPermission.objects.filter(
users=self.request.user, object_types=script_ct
).values_list("constraints", flat=True)
)
available_scripts = Script.objects.filter(script_permissions, module__in=script_modules)
context = { context = {
'model': ScriptModule, 'model': ScriptModule,
'script_modules': script_modules, 'script_modules': script_modules,
'available_scripts': available_scripts,
} }
# Use partial template for dashboard widgets # Use partial template for dashboard widgets

View File

@@ -38,83 +38,81 @@
</thead> </thead>
<tbody> <tbody>
{% for script in scripts %} {% for script in scripts %}
{% if script in available_scripts %} {% with last_job=script.get_latest_jobs|first %}
{% with last_job=script.get_latest_jobs|first %} <tr>
<tr> <td>
<td> {% if script.is_executable %}
{% if script.is_executable %} <a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
{% else %}
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
<span class="text-danger">
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
</span>
{% endif %}
</td>
<td>{{ script.python_class.description|markdown|placeholder }}</td>
{% if last_job %}
<td>
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
</td>
<td>
{% badge last_job.get_status_display last_job.get_status_color %}
</td>
{% else %} {% else %}
<td class="text-muted">{% trans "Never" %}</td> <a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
<td>{{ ''|placeholder }}</td> <span class="text-danger">
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
</span>
{% endif %} {% endif %}
</td>
<td>{{ script.python_class.description|markdown|placeholder }}</td>
{% if last_job %}
<td> <td>
{% if request.user|can_run:script and script.is_executable %} <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
<div class="float-end d-print-none">
<form action="{% url 'extras:script' script.pk %}" method="post">
{% if script.python_class.commit_default %}
<input type="checkbox" name="_commit" hidden checked>
{% endif %}
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary{% if embedded %} btn-sm{% endif %}">
{% if last_job %}
<i class="mdi mdi-replay"></i> {% if not embedded %}{% trans "Run Again" %}{% endif %}
{% else %}
<i class="mdi mdi-play"></i> {% if not embedded %}{% trans "Run Script" %}{% endif %}
{% endif %}
</button>
</form>
</div>
{% endif %}
</td> </td>
</tr> <td>
{% if last_job and not embedded %} {% badge last_job.get_status_display last_job.get_status_color %}
{% for test_name, data in last_job.data.tests.items %} </td>
<tr> {% else %}
<td colspan="4" class="method"> <td class="text-muted">{% trans "Never" %}</td>
<span class="ps-3">{{ test_name }}</span> <td>{{ ''|placeholder }}</td>
</td>
<td class="text-end text-nowrap script-stats">
<span class="badge text-bg-success">{{ data.success }}</span>
<span class="badge text-bg-info">{{ data.info }}</span>
<span class="badge text-bg-warning">{{ data.warning }}</span>
<span class="badge text-bg-danger">{{ data.failure }}</span>
</td>
</tr>
{% endfor %}
{% elif last_job and not last_job.data.log and not embedded %}
{# legacy #}
{% for method, stats in last_job.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endif %} {% endif %}
{% endwith %} <td>
{% endif %} {% if request.user|can_run:script and script.is_executable %}
<div class="float-end d-print-none">
<form action="{% url 'extras:script' script.pk %}" method="post">
{% if script.python_class.commit_default %}
<input type="checkbox" name="_commit" hidden checked>
{% endif %}
{% csrf_token %}
<button type="submit" name="_run" class="btn btn-primary{% if embedded %} btn-sm{% endif %}">
{% if last_job %}
<i class="mdi mdi-replay"></i> {% if not embedded %}{% trans "Run Again" %}{% endif %}
{% else %}
<i class="mdi mdi-play"></i> {% if not embedded %}{% trans "Run Script" %}{% endif %}
{% endif %}
</button>
</form>
</div>
{% endif %}
</td>
</tr>
{% if last_job and not embedded %}
{% for test_name, data in last_job.data.tests.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ test_name }}</span>
</td>
<td class="text-end text-nowrap script-stats">
<span class="badge text-bg-success">{{ data.success }}</span>
<span class="badge text-bg-info">{{ data.info }}</span>
<span class="badge text-bg-warning">{{ data.warning }}</span>
<span class="badge text-bg-danger">{{ data.failure }}</span>
</td>
</tr>
{% endfor %}
{% elif last_job and not last_job.data.log and not embedded %}
{# legacy #}
{% for method, stats in last_job.data.items %}
<tr>
<td colspan="4" class="method">
<span class="ps-3">{{ method }}</span>
</td>
<td class="text-end text-nowrap report-stats">
<span class="badge bg-success">{{ stats.success }}</span>
<span class="badge bg-info">{{ stats.info }}</span>
<span class="badge bg-warning">{{ stats.warning }}</span>
<span class="badge bg-danger">{{ stats.failure }}</span>
</td>
</tr>
{% endfor %}
{% endif %}
{% endwith %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>