Coverage for extras/api/serializers_/scripts.py: 43%
136 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 18:35 +0000
1import logging
3from django.core.files.storage import storages
4from django.db import IntegrityError, router, transaction
5from django.utils.translation import gettext_lazy as _
6from drf_spectacular.utils import extend_schema_field
7from rest_framework import serializers
9from core.api.serializers_.jobs import JobSerializer
10from core.choices import JobNotificationChoices, ManagedFileRootPathChoices
11from extras.models import Script, ScriptModule
12from extras.utils import validate_script_content
13from netbox.api.serializers import ValidatedModelSerializer
14from utilities.datetime import local_now
16logger = logging.getLogger(__name__)
18__all__ = (
19 'ScriptDetailSerializer',
20 'ScriptInputSerializer',
21 'ScriptModuleSerializer',
22 'ScriptSerializer',
23)
26class ScriptModuleSerializer(ValidatedModelSerializer):
27 file = serializers.FileField(write_only=True)
28 file_path = serializers.CharField(read_only=True)
30 class Meta:
31 model = ScriptModule
32 fields = ['id', 'display', 'file_path', 'file', 'created', 'last_updated']
33 brief_fields = ('id', 'display')
35 def validate(self, data):
36 # ScriptModule.save() sets file_root; inject it here so full_clean() succeeds.
37 # Pop 'file' before model instantiation — ScriptModule has no such field.
38 file = data.pop('file', None)
39 data['file_root'] = ManagedFileRootPathChoices.SCRIPTS
41 if self.instance is None: 41 ↛ 47line 41 didn't jump to line 47 because the condition on line 41 was always true
42 # Reject duplicates before writing to storage so a failed upload can't touch the existing file
43 if file is not None and ScriptModule.objects.filter( 43 ↛ 46line 43 didn't jump to line 46 because the condition on line 43 was never true
44 file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file.name
45 ).exists():
46 raise serializers.ValidationError(_("A script module with this file name already exists."))
47 elif file is None:
48 # Replacing a module's content requires a file upload, even for a partial update
49 raise serializers.ValidationError({'file': _("This field is required.")})
50 elif file.name != self.instance.file_path:
51 raise serializers.ValidationError({
52 'file': _(
53 "The uploaded file name must match the existing file path ({path})."
54 ).format(path=self.instance.file_path)
55 })
57 data = super().validate(data)
58 data.pop('file_root', None)
59 if file is not None: 59 ↛ 70line 59 didn't jump to line 70 because the condition on line 59 was always true
60 # Validate that the uploaded script can be loaded as a Python module
61 content = file.read()
62 file.seek(0)
63 try:
64 validate_script_content(content, file.name)
65 except Exception as e:
66 raise serializers.ValidationError(
67 _("Error loading script: {error}").format(error=e)
68 )
69 data['file'] = file
70 return data
72 def create(self, validated_data):
73 file = validated_data.pop('file')
74 storage = storages.create_storage(storages.backends["scripts"])
75 validated_data['file_path'] = storage.save(file.name, file)
76 created = False
77 try:
78 instance = super().create(validated_data)
79 created = True
80 return instance
81 except IntegrityError as e:
82 if 'file_path' in str(e):
83 raise serializers.ValidationError(
84 _("A script module with this file name already exists.")
85 )
86 raise
87 finally:
88 # Don't delete a path another ScriptModule still references (e.g. a concurrent upload won the race)
89 file_path = validated_data.get('file_path')
90 if not created and file_path and not ScriptModule.objects.filter(
91 file_root=ManagedFileRootPathChoices.SCRIPTS, file_path=file_path
92 ).exists():
93 try:
94 storage.delete(file_path)
95 except Exception:
96 logger.warning(f"Failed to delete orphaned script file '{file_path}' from storage.")
98 def update(self, instance, validated_data):
99 file = validated_data.pop('file')
100 storage = storages.create_storage(storages.backends["scripts"])
102 # Overwrite the existing file in place, keeping file_path stable
103 file.seek(0)
104 saved_path = storage.save(instance.file_path, file)
105 if saved_path != instance.file_path:
106 # The backend saved under an alternate name instead of overwriting; drop the orphan and reject
107 try:
108 storage.delete(saved_path)
109 except Exception:
110 logger.warning(f"Failed to delete orphaned script file '{saved_path}' from storage.")
111 raise serializers.ValidationError({
112 'file': _(
113 "The scripts storage backend did not overwrite the existing file. Ensure the "
114 "backend is configured to allow overwrites."
115 )
116 })
118 # Discard any cached class discovery so save() re-syncs from the new content
119 instance.__dict__.pop('module_scripts', None)
120 instance.last_updated = local_now()
121 # Keep Script row sync all-or-nothing; the storage write above cannot join the transaction
122 with transaction.atomic(using=router.db_for_write(ScriptModule)):
123 instance.save()
125 return instance
128class ScriptSerializer(ValidatedModelSerializer):
129 description = serializers.SerializerMethodField(read_only=True)
130 vars = serializers.SerializerMethodField(read_only=True)
131 result = JobSerializer(nested=True, read_only=True)
133 class Meta:
134 model = Script
135 fields = [
136 'id', 'url', 'display_url', 'module', 'name', 'description', 'vars', 'result', 'display', 'is_executable',
137 ]
138 brief_fields = ('id', 'url', 'display', 'name', 'description')
140 @extend_schema_field(serializers.JSONField(allow_null=True))
141 def get_vars(self, obj):
142 if obj.python_class:
143 return {
144 k: v.__class__.__name__ for k, v in obj.python_class()._get_vars().items()
145 }
146 return {}
148 @extend_schema_field(serializers.CharField())
149 def get_display(self, obj):
150 return f'{obj.name} ({obj.module})'
152 @extend_schema_field(serializers.CharField(allow_null=True))
153 def get_description(self, obj):
154 if obj.python_class:
155 return obj.python_class().description
156 return None
159class ScriptDetailSerializer(ScriptSerializer):
160 result = serializers.SerializerMethodField(read_only=True)
162 @extend_schema_field(JobSerializer())
163 def get_result(self, obj):
164 job = obj.jobs.all().order_by('-created').first()
165 context = {
166 'request': self.context['request']
167 }
168 data = JobSerializer(job, context=context).data
169 return data
172class ScriptInputSerializer(serializers.Serializer):
173 data = serializers.JSONField()
174 commit = serializers.BooleanField()
175 schedule_at = serializers.DateTimeField(required=False, allow_null=True)
176 interval = serializers.IntegerField(required=False, allow_null=True)
177 notifications = serializers.ChoiceField(
178 choices=JobNotificationChoices,
179 required=False,
180 default=JobNotificationChoices.NOTIFICATION_ALWAYS,
181 )
183 def __init__(self, *args, **kwargs):
184 super().__init__(*args, **kwargs)
186 # Default to script's Meta.notifications_default if set
187 script = self.context.get('script')
188 if script and script.python_class: 188 ↛ 189line 188 didn't jump to line 189 because the condition on line 188 was never true
189 self.fields['notifications'].default = script.python_class.notifications_default
191 def validate_data(self, value):
192 """
193 Validates that the script input is an object mapping variable names to values.
194 """
195 if not isinstance(value, dict):
196 raise serializers.ValidationError(
197 _('Invalid data payload; expected an object mapping variable names to values.')
198 )
199 return value
201 def validate_schedule_at(self, value):
202 """
203 Validates the specified schedule time for a script execution.
204 """
205 if value:
206 if not self.context['script'].python_class.scheduling_enabled:
207 raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
208 if value < local_now():
209 raise serializers.ValidationError(_('Scheduled time must be in the future.'))
210 return value
212 def validate_interval(self, value):
213 """
214 Validates the provided interval based on the script's scheduling configuration.
215 """
216 if value and not self.context['script'].python_class.scheduling_enabled:
217 raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
218 return value
220 def validate(self, data):
221 """
222 Validates the given data and ensures the necessary fields are populated.
223 """
224 # Set the schedule_at time to now if only an interval is provided
225 # while handling the case where schedule_at is null.
226 if data.get('interval') and not data.get('schedule_at'):
227 data['schedule_at'] = local_now()
229 return super().validate(data)