Skip to content

Commit 2a1d315

Browse files
authored
Fixes #20524: Enhance API script scheduling validation (#20616)
1 parent 8cc6589 commit 2a1d315

File tree

2 files changed

+104
-10
lines changed

2 files changed

+104
-10
lines changed

netbox/extras/api/serializers_/scripts.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from core.api.serializers_.jobs import JobSerializer
66
from extras.models import Script
77
from netbox.api.serializers import ValidatedModelSerializer
8+
from utilities.datetime import local_now
89

910
__all__ = (
1011
'ScriptDetailSerializer',
@@ -66,11 +67,31 @@ class ScriptInputSerializer(serializers.Serializer):
6667
interval = serializers.IntegerField(required=False, allow_null=True)
6768

6869
def validate_schedule_at(self, value):
69-
if value and not self.context['script'].python_class.scheduling_enabled:
70-
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
70+
"""
71+
Validates the specified schedule time for a script execution.
72+
"""
73+
if value:
74+
if not self.context['script'].python_class.scheduling_enabled:
75+
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
76+
if value < local_now():
77+
raise serializers.ValidationError(_('Scheduled time must be in the future.'))
7178
return value
7279

7380
def validate_interval(self, value):
81+
"""
82+
Validates the provided interval based on the script's scheduling configuration.
83+
"""
7484
if value and not self.context['script'].python_class.scheduling_enabled:
75-
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
85+
raise serializers.ValidationError(_('Scheduling is not enabled for this script.'))
7686
return value
87+
88+
def validate(self, data):
89+
"""
90+
Validates the given data and ensures the necessary fields are populated.
91+
"""
92+
# Set the schedule_at time to now if only an interval is provided
93+
# while handling the case where schedule_at is null.
94+
if data.get('interval') and not data.get('schedule_at'):
95+
data['schedule_at'] = local_now()
96+
97+
return super().validate(data)

netbox/extras/tests/test_api.py

Lines changed: 80 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from django.contrib.contenttypes.models import ContentType
44
from django.urls import reverse
55
from django.utils.timezone import make_aware, now
6+
from rest_framework import status
67

78
from core.choices import ManagedFileRootPathChoices
89
from core.events import *
@@ -858,16 +859,16 @@ def setUpTestData(cls):
858859
class ScriptTest(APITestCase):
859860

860861
class TestScriptClass(PythonClass):
861-
862862
class Meta:
863-
name = "Test script"
863+
name = 'Test script'
864+
commit = True
865+
scheduling_enabled = True
864866

865867
var1 = StringVar()
866868
var2 = IntegerVar()
867869
var3 = BooleanVar()
868870

869871
def run(self, data, commit=True):
870-
871872
self.log_info(data['var1'])
872873
self.log_success(data['var2'])
873874
self.log_failure(data['var3'])
@@ -878,14 +879,16 @@ def run(self, data, commit=True):
878879
def setUpTestData(cls):
879880
module = ScriptModule.objects.create(
880881
file_root=ManagedFileRootPathChoices.SCRIPTS,
881-
file_path='/var/tmp/script.py'
882+
file_path='script.py',
882883
)
883-
Script.objects.create(
884+
script = Script.objects.create(
884885
module=module,
885-
name="Test script",
886+
name='Test script',
886887
is_executable=True,
887888
)
889+
cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
888890

891+
@property
889892
def python_class(self):
890893
return self.TestScriptClass
891894

@@ -898,7 +901,7 @@ def setUp(self):
898901
def test_get_script(self):
899902
module = ScriptModule.objects.get(
900903
file_root=ManagedFileRootPathChoices.SCRIPTS,
901-
file_path='/var/tmp/script.py'
904+
file_path='script.py',
902905
)
903906
script = module.scripts.all().first()
904907
url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
@@ -909,6 +912,76 @@ def test_get_script(self):
909912
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
910913
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
911914

915+
def test_schedule_script_past_time_rejected(self):
916+
"""
917+
Scheduling with past schedule_at should fail.
918+
"""
919+
self.add_permissions('extras.run_script')
920+
921+
payload = {
922+
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
923+
'commit': True,
924+
'schedule_at': now() - datetime.timedelta(hours=1),
925+
}
926+
response = self.client.post(self.url, payload, format='json', **self.header)
927+
928+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
929+
self.assertIn('schedule_at', response.data)
930+
# Be tolerant of exact wording but ensure we failed on schedule_at being in the past
931+
self.assertIn('future', str(response.data['schedule_at']).lower())
932+
933+
def test_schedule_script_interval_only(self):
934+
"""
935+
Interval without schedule_at should auto-set schedule_at now.
936+
"""
937+
self.add_permissions('extras.run_script')
938+
939+
payload = {
940+
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
941+
'commit': True,
942+
'interval': 60,
943+
}
944+
response = self.client.post(self.url, payload, format='json', **self.header)
945+
946+
self.assertHttpStatus(response, status.HTTP_200_OK)
947+
# The latest job is returned in the script detail serializer under "result"
948+
self.assertIn('result', response.data)
949+
self.assertEqual(response.data['result']['interval'], 60)
950+
# Ensure a start time was autopopulated
951+
self.assertIsNotNone(response.data['result']['scheduled'])
952+
953+
def test_schedule_script_when_disabled(self):
954+
"""
955+
Scheduling should fail when script.scheduling_enabled=False.
956+
"""
957+
self.add_permissions('extras.run_script')
958+
959+
# Temporarily disable scheduling on the in-test Python class
960+
original = getattr(self.TestScriptClass.Meta, 'scheduling_enabled', True)
961+
self.TestScriptClass.Meta.scheduling_enabled = False
962+
base = {
963+
'data': {'var1': 'hello', 'var2': 1, 'var3': False},
964+
'commit': True,
965+
}
966+
# Check both schedule_at and interval paths
967+
cases = [
968+
{**base, 'schedule_at': now() + datetime.timedelta(minutes=5)},
969+
{**base, 'interval': 60},
970+
]
971+
try:
972+
for case in cases:
973+
with self.subTest(case=list(case.keys())):
974+
response = self.client.post(self.url, case, format='json', **self.header)
975+
976+
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
977+
# Error should be attached to whichever field we used
978+
key = 'schedule_at' if 'schedule_at' in case else 'interval'
979+
self.assertIn(key, response.data)
980+
self.assertIn('scheduling is not enabled', str(response.data[key]).lower())
981+
finally:
982+
# Restore the original setting for other tests
983+
self.TestScriptClass.Meta.scheduling_enabled = original
984+
912985

913986
class CreatedUpdatedFilterTest(APITestCase):
914987

0 commit comments

Comments
 (0)