scripts.py 9.1 KB

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