Merge branch 'main' into feature

This commit is contained in:
Jeremy Stretch
2025-11-25 15:25:53 -05:00
80 changed files with 12026 additions and 7046 deletions

View File

@@ -29,6 +29,6 @@ class ConfigTemplateSerializer(
fields = [
'id', 'url', 'display_url', 'display', 'name', 'description', 'environment_params', 'template_code',
'mime_type', 'file_name', 'file_extension', 'as_attachment', 'data_source', 'data_path', 'data_file',
'data_synced', 'owner', 'tags', 'created', 'last_updated',
'auto_sync_enabled', 'data_synced', 'owner', 'tags', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -276,6 +276,14 @@ class ScriptViewSet(ModelViewSet):
_ignore_model_permissions = True
lookup_value_regex = '[^/]+' # Allow dots
def initial(self, request, *args, **kwargs):
super().initial(request, *args, **kwargs)
# Restrict the view's QuerySet to allow only the permitted objects
if request.user.is_authenticated:
action = 'run' if request.method == 'POST' else 'view'
self.queryset = self.queryset.restrict(request.user, action)
def _get_script(self, pk):
# If pk is numeric, retrieve script by ID
if pk.isnumeric():
@@ -299,10 +307,12 @@ class ScriptViewSet(ModelViewSet):
"""
Run a Script identified by its numeric PK or module & name and return the pending Job as the result
"""
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run scripts.")
script = self._get_script(pk)
if not request.user.has_perm('extras.run_script', obj=script):
raise PermissionDenied("This user does not have permission to run this script.")
input_serializer = serializers.ScriptInputSerializer(
data=request.data,
context={'script': script}

View File

@@ -209,7 +209,10 @@ class ObjectCountsWidget(DashboardWidget):
url = get_action_url(model, action='list')
except NoReverseMatch:
url = None
qs = model.objects.restrict(request.user, 'view')
try:
qs = model.objects.restrict(request.user, 'view')
except AttributeError:
qs = model.objects.all()
# Apply any specified filters
if url and (filters := self.config.get('filters')):
params = dict_to_querydict(filters)

View File

@@ -134,11 +134,18 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non
# Enqueue a Job to record the script's execution
from extras.jobs import ScriptJob
params = {
"instance": event_rule.action_object,
"name": script.name,
"user": user,
"data": event_data
}
if snapshots:
params["snapshots"] = snapshots
if request:
params["request"] = copy_safe_request(request)
ScriptJob.enqueue(
instance=event_rule.action_object,
name=script.name,
user=user,
data=event_data
**params
)
# Notification groups

View File

@@ -392,8 +392,12 @@ class ConfigTemplateBulkEditForm(ChangelogMessageMixin, OwnerMixin, BulkEditForm
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension')
auto_sync_enabled = forms.NullBooleanField(
label=_('Auto sync enabled'),
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension', 'auto_sync_enabled',)
class ImageAttachmentBulkEditForm(ChangelogMessageMixin, BulkEditForm):

View File

@@ -5,7 +5,7 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from core.models import DataFile, DataSource, ObjectType
from extras.choices import *
from extras.models import *
from netbox.events import get_event_type_choices
@@ -160,14 +160,41 @@ class ConfigContextProfileImportForm(PrimaryModelImportForm):
class ConfigTemplateImportForm(OwnerCSVMixin, CSVModelForm):
data_source = CSVModelChoiceField(
label=_('Data source'),
queryset=DataSource.objects.all(),
required=False,
to_field_name='name',
help_text=_('Data source which provides the data file')
)
data_file = CSVModelChoiceField(
label=_('Data file'),
queryset=DataFile.objects.all(),
required=False,
to_field_name='path',
help_text=_('Data file containing the template code')
)
auto_sync_enabled = forms.BooleanField(
required=False,
label=_('Auto sync enabled'),
help_text=_("Enable automatic synchronization of template content when the data file is updated")
)
class Meta:
model = ConfigTemplate
fields = (
'name', 'description', 'template_code', 'environment_params', 'mime_type', 'file_name', 'file_extension',
'as_attachment', 'owner', 'tags',
'name', 'description', 'template_code', 'data_source', 'data_file', 'auto_sync_enabled',
'environment_params', 'mime_type', 'file_name', 'file_extension', 'as_attachment', 'owner', 'tags',
)
def clean(self):
super().clean()
# Make sure template_code is None when it's not included in the uploaded data
if not self.data.get('template_code') and not self.data.get('data_file'):
raise forms.ValidationError(_("Must specify either local content or a data file"))
return self.cleaned_data['template_code']
class SavedFilterImportForm(OwnerCSVMixin, CSVModelForm):
object_types = CSVMultipleContentTypeField(

View File

@@ -43,17 +43,20 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
model = CustomField
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet(
'type', 'related_object_type_id', 'group_name', 'weight', 'required', 'unique', 'choice_set_id',
name=_('Attributes')
),
FieldSet('object_type_id', 'type', 'group_name', 'weight', 'required', 'unique', name=_('Attributes')),
FieldSet('choice_set_id', 'related_object_type_id', name=_('Type Options')),
FieldSet('ui_visible', 'ui_editable', 'is_cloneable', name=_('Behavior')),
FieldSet('validation_minimum', 'validation_maximum', 'validation_regex', name=_('Validation')),
)
related_object_type_id = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.with_feature('custom_fields'),
required=False,
label=_('Related object type')
label=_('Object types'),
)
related_object_type_id = ContentTypeMultipleChoiceField(
queryset=ObjectType.objects.public(),
required=False,
label=_('Related object type'),
)
type = forms.MultipleChoiceField(
choices=CustomFieldTypeChoices,
@@ -147,12 +150,12 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
model = CustomLink
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
FieldSet('object_type_id', 'enabled', 'new_window', 'weight', name=_('Attributes')),
)
object_type = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.with_feature('custom_links'),
required=False
required=False,
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
@@ -251,12 +254,12 @@ class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
model = SavedFilter
fieldsets = (
FieldSet('q', 'filter_id'),
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
FieldSet('object_type_id', 'enabled', 'shared', 'weight', name=_('Attributes')),
)
object_type = ContentTypeMultipleChoiceField(
object_type_id = ContentTypeMultipleChoiceField(
label=_('Object types'),
queryset=ObjectType.objects.public(),
required=False
required=False,
)
enabled = forms.NullBooleanField(
label=_('Enabled'),
@@ -521,7 +524,7 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
model = ConfigTemplate
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
FieldSet('data_source_id', 'data_file_id', 'auto_sync_enabled', name=_('Data')),
FieldSet('mime_type', 'file_name', 'file_extension', 'as_attachment', name=_('Rendering'))
)
data_source_id = DynamicModelMultipleChoiceField(
@@ -537,6 +540,13 @@ class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
'source_id': '$data_source_id'
}
)
auto_sync_enabled = forms.NullBooleanField(
label=_('Auto sync enabled'),
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
tag = TagFilterField(ConfigTemplate)
mime_type = forms.CharField(
required=False,

View File

@@ -668,6 +668,10 @@ class ConfigTemplateTable(NetBoxTable):
orderable=False,
verbose_name=_('Synced')
)
auto_sync_enabled = columns.BooleanColumn(
verbose_name=_('Auto Sync Enabled'),
orderable=False,
)
mime_type = tables.Column(
verbose_name=_('MIME Type')
)

View File

@@ -1,4 +1,6 @@
from django import template
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
register = template.Library()
@@ -8,4 +10,16 @@ register = template.Library()
def render_widget(context, widget):
request = context['request']
return widget.render(request)
try:
return widget.render(request)
except Exception as e:
message1 = _('An error was encountered when attempting to render this widget:')
message2 = _('Please try reconfiguring the widget, or remove it from your dashboard.')
return mark_safe(f"""
<p>
<span class="text-danger"><i class="mdi mdi-alert"></i></span>
{message1}
</p>
<p class="font-monospace ps-3">{e}</p>
<p>{message2}</p>
""")

View File

@@ -936,18 +936,13 @@ class ScriptTest(APITestCase):
def setUp(self):
super().setUp()
self.add_permissions('extras.view_script')
# Monkey-patch the Script model to return our TestScriptClass above
Script.python_class = self.python_class
def test_get_script(self):
module = ScriptModule.objects.get(
file_root=ManagedFileRootPathChoices.SCRIPTS,
file_path='script.py',
)
script = module.scripts.all().first()
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
response = self.client.get(url, **self.header)
response = self.client.get(self.url, **self.header)
self.assertEqual(response.data['name'], self.TestScriptClass.Meta.name)
self.assertEqual(response.data['vars']['var1'], 'StringVar')