Coverage for extras/models/scripts.py: 48%
114 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 inspect
2import logging
3from functools import cached_property
5from django.contrib.contenttypes.fields import GenericRelation
6from django.contrib.contenttypes.models import ContentType
7from django.db import models, router, transaction
8from django.db.models import Q
9from django.urls import reverse
10from django.utils.translation import gettext_lazy as _
12from core.choices import ManagedFileRootPathChoices
13from core.models import ManagedFile
14from extras.utils import is_script
15from netbox.models.features import EventRulesMixin, JobsMixin, batch_delete_jobs
16from utilities.querysets import RestrictedQuerySet
18from .mixins import PythonModuleMixin
20__all__ = (
21 'Script',
22 'ScriptModule',
23)
25logger = logging.getLogger('netbox.data_backends')
28class Script(EventRulesMixin, JobsMixin):
29 name = models.CharField(
30 verbose_name=_('name'),
31 max_length=79, # Maximum length for a Python class name
32 editable=False,
33 )
34 module = models.ForeignKey(
35 to='extras.ScriptModule',
36 on_delete=models.CASCADE,
37 related_name='scripts',
38 editable=False
39 )
40 is_executable = models.BooleanField(
41 default=True,
42 verbose_name=_('is executable'),
43 editable=False
44 )
45 events = GenericRelation(
46 'extras.EventRule',
47 content_type_field='action_object_type',
48 object_id_field='action_object_id'
49 )
51 def __str__(self):
52 return self.name
54 objects = RestrictedQuerySet.as_manager()
56 class Meta:
57 ordering = ('module', 'name')
58 constraints = (
59 models.UniqueConstraint(
60 fields=('name', 'module'),
61 name='extras_script_unique_name_module'
62 ),
63 )
64 indexes = (
65 models.Index(fields=('module', 'name')), # Default ordering
66 )
67 verbose_name = _('script')
68 verbose_name_plural = _('scripts')
70 def get_absolute_url(self):
71 return reverse('extras:script', args=[self.pk])
73 @property
74 def result(self):
75 return self.jobs.all().order_by('-created').first()
77 @cached_property
78 def python_class(self):
79 return self.module.module_scripts.get(self.name)
81 def delete(self, soft_delete=False, **kwargs):
82 if soft_delete and self.jobs.exists():
83 self.is_executable = False
84 self.save()
85 else:
86 super().delete(**kwargs)
87 self.id = None
90class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
92 def get_queryset(self):
93 return super().get_queryset().filter(
94 Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS))
97class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
98 """
99 Proxy model for script module files.
100 """
101 objects = ScriptModuleManager()
102 error = None
104 event_rules = GenericRelation(
105 to='extras.EventRule',
106 content_type_field='action_object_type',
107 object_id_field='action_object_id',
108 for_concrete_model=False
109 )
111 class Meta:
112 proxy = True
113 ordering = ('file_root', 'file_path')
114 verbose_name = _('script module')
115 verbose_name_plural = _('script modules')
117 def get_absolute_url(self):
118 return reverse('extras:script_list')
120 def __str__(self):
121 return self.python_name
123 def delete(self, using=None, *args, **kwargs):
124 # Job is imported here rather than at module level to avoid a circular import
125 # (core.models.jobs -> core.signals -> extras.events -> extras.models -> this module).
126 from core.models import Job
128 # Deleting a ScriptModule cascades (via the Script.module FK) to its child Scripts, and
129 # Django's collector would materialize every one of those Scripts' Jobs to delete them.
130 # A module's scripts can accumulate thousands of jobs, exhausting memory. Batch-delete
131 # the child Scripts' jobs up front, in a single queryset (no per-script loop), before
132 # delegating to the cascade. The transaction rolls the job deletions back if the parent
133 # delete fails; note it does not cover ManagedFile.delete removing the file from disk,
134 # which happens before the DB delete and is not transactional. See #22812.
135 using = using or router.db_for_write(self.__class__, instance=self)
136 with transaction.atomic(using=using):
137 script_type = ContentType.objects.get_for_model(Script, for_concrete_model=False)
138 child_jobs = Job.objects.using(using).filter(
139 object_type=script_type,
140 object_id__in=self.scripts.values_list('pk', flat=True),
141 )
142 batch_delete_jobs(child_jobs)
143 return super().delete(using, *args, **kwargs)
144 delete.alters_data = True
146 @property
147 def ordered_scripts(self):
148 script_objects = {s.name: s for s in self.scripts.all()}
149 ordered = [
150 script_objects.pop(sc) for sc in self.module_scripts.keys() if sc in script_objects
151 ]
152 ordered.extend(script_objects.values())
153 return ordered
155 @cached_property
156 def module_scripts(self):
158 def _get_name(cls):
159 # For child objects in submodules use the full import path w/o the root module as the name
160 return cls.full_name.split(".", maxsplit=1)[1]
162 try:
163 module = self.get_module()
164 except Exception as e:
165 self.error = e
166 logger.error(f"Failed to load script: {self.python_name} error: {e}")
167 module = None
169 scripts = {}
170 ordered = getattr(module, 'script_order', [])
172 for cls in ordered:
173 scripts[_get_name(cls)] = cls
174 for name, cls in inspect.getmembers(module, is_script):
175 if cls not in ordered:
176 scripts[_get_name(cls)] = cls
178 return scripts
180 def sync_classes(self):
181 """
182 Syncs the file-based module to the database, adding and removing individual Script objects
183 in the database as needed.
184 """
185 if self.id:
186 db_classes = {
187 script.name: script for script in self.scripts.all()
188 }
189 else:
190 db_classes = {}
192 db_classes_set = set(db_classes.keys())
193 module_classes_set = set(self.module_scripts.keys())
195 # remove any existing db classes if they are no longer in the file
196 removed = db_classes_set - module_classes_set
197 for name in removed:
198 db_classes[name].delete(soft_delete=True)
200 added = module_classes_set - db_classes_set
201 for name in added:
202 Script.objects.create(
203 module=self,
204 name=name,
205 is_executable=True,
206 )
207 sync_classes.alters_data = True
209 def sync_data(self):
210 super().sync_data()
211 sync_data.alters_data = True
213 def save(self, *args, **kwargs):
214 self.file_root = ManagedFileRootPathChoices.SCRIPTS
215 super().save(*args, **kwargs)
217 # Sync script classes after the module has been saved. This is the
218 # single intended synchronization path for ScriptModule saves.
219 self.sync_classes()