Skip to content

Commit d2a694a

Browse files
Closes #12068: Establish a direct relationship from jobs to objects (#12075)
* Reference database object by GFK when running scripts & reports via UI * Reference database object by GFK when running scripts & reports via API * Remove old enqueue_job() method * Enable filtering jobs by object * Introduce ObjectJobsView * Add tabbed views for report & script jobs * Add object_id to JobSerializer * Move generic relation to JobsMixin * Clean up old naming
1 parent 15590f1 commit d2a694a

33 files changed

+583
-353
lines changed

netbox/core/api/serializers.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,6 @@ class JobSerializer(BaseModelSerializer):
6767
class Meta:
6868
model = Job
6969
fields = [
70-
'id', 'url', 'display', 'status', 'created', 'scheduled', 'interval', 'started', 'completed', 'name',
71-
'object_type', 'user', 'data', 'job_id',
70+
'id', 'url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'interval',
71+
'started', 'completed', 'user', 'data', 'job_id',
7272
]

netbox/core/filtersets.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class JobFilterSet(BaseFilterSet):
113113

114114
class Meta:
115115
model = Job
116-
fields = ('id', 'interval', 'status', 'user', 'object_type', 'name')
116+
fields = ('id', 'object_type', 'object_id', 'name', 'interval', 'status', 'user')
117117

118118
def search(self, queryset, name, value):
119119
if not value.strip():

netbox/core/jobs.py

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import logging
22

3-
from .choices import JobStatusChoices
43
from netbox.search.backends import search_backend
54
from .choices import *
65
from .exceptions import SyncError
@@ -9,22 +8,22 @@
98
logger = logging.getLogger(__name__)
109

1110

12-
def sync_datasource(job_result, *args, **kwargs):
11+
def sync_datasource(job, *args, **kwargs):
1312
"""
1413
Call sync() on a DataSource.
1514
"""
16-
datasource = DataSource.objects.get(name=job_result.name)
15+
datasource = DataSource.objects.get(pk=job.object_id)
1716

1817
try:
19-
job_result.start()
18+
job.start()
2019
datasource.sync()
2120

2221
# Update the search cache for DataFiles belonging to this source
2322
search_backend.cache(datasource.datafiles.iterator())
2423

25-
job_result.terminate()
24+
job.terminate()
2625

2726
except SyncError as e:
28-
job_result.terminate(status=JobStatusChoices.STATUS_ERRORED)
27+
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
2928
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
3029
logging.error(e)

netbox/core/models/data.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from urllib.parse import urlparse
66

77
from django.conf import settings
8-
from django.contrib.contenttypes.models import ContentType
8+
from django.contrib.contenttypes.fields import GenericRelation
99
from django.core.exceptions import ValidationError
1010
from django.core.validators import RegexValidator
1111
from django.db import models
@@ -15,6 +15,7 @@
1515
from django.utils.translation import gettext as _
1616

1717
from netbox.models import PrimaryModel
18+
from netbox.models.features import JobsMixin
1819
from netbox.registry import registry
1920
from utilities.files import sha256_hash
2021
from utilities.querysets import RestrictedQuerySet
@@ -31,7 +32,7 @@
3132
logger = logging.getLogger('netbox.core.data')
3233

3334

34-
class DataSource(PrimaryModel):
35+
class DataSource(JobsMixin, PrimaryModel):
3536
"""
3637
A remote source, such as a git repository, from which DataFiles are synchronized.
3738
"""
@@ -118,15 +119,12 @@ def enqueue_sync_job(self, request):
118119
DataSource.objects.filter(pk=self.pk).update(status=self.status)
119120

120121
# Enqueue a sync job
121-
job_result = Job.enqueue_job(
122+
return Job.enqueue(
122123
import_string('core.jobs.sync_datasource'),
123-
name=self.name,
124-
obj_type=ContentType.objects.get_for_model(DataSource),
125-
user=request.user,
124+
instance=self,
125+
user=request.user
126126
)
127127

128-
return job_result
129-
130128
def get_backend(self):
131129
backend_cls = registry['data_backends'].get(self.type)
132130
backend_params = self.parameters or {}

netbox/core/models/jobs.py

Lines changed: 24 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from django.core.validators import MinValueValidator
88
from django.db import models
99
from django.urls import reverse
10-
from django.urls.exceptions import NoReverseMatch
1110
from django.utils import timezone
1211
from django.utils.translation import gettext as _
1312

@@ -96,21 +95,12 @@ class Meta:
9695
def __str__(self):
9796
return str(self.job_id)
9897

99-
def delete(self, *args, **kwargs):
100-
super().delete(*args, **kwargs)
101-
102-
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
103-
queue = django_rq.get_queue(rq_queue_name)
104-
job = queue.fetch_job(str(self.job_id))
105-
106-
if job:
107-
job.cancel()
108-
10998
def get_absolute_url(self):
110-
try:
111-
return reverse(f'extras:{self.object_type.model}_result', args=[self.pk])
112-
except NoReverseMatch:
113-
return None
99+
# TODO: Employ dynamic registration
100+
if self.object_type.model == 'reportmodule':
101+
return reverse(f'extras:report_result', kwargs={'job_pk': self.pk})
102+
if self.object_type.model == 'scriptmodule':
103+
return reverse(f'extras:script_result', kwargs={'job_pk': self.pk})
114104

115105
def get_status_color(self):
116106
return JobStatusChoices.colors.get(self.status)
@@ -130,6 +120,16 @@ def duration(self):
130120

131121
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
132122

123+
def delete(self, *args, **kwargs):
124+
super().delete(*args, **kwargs)
125+
126+
rq_queue_name = get_config().QUEUE_MAPPINGS.get(self.object_type.model, RQ_QUEUE_DEFAULT)
127+
queue = django_rq.get_queue(rq_queue_name)
128+
job = queue.fetch_job(str(self.job_id))
129+
130+
if job:
131+
job.cancel()
132+
133133
def start(self):
134134
"""
135135
Record the job's start time and update its status to "running."
@@ -162,35 +162,37 @@ def terminate(self, status=JobStatusChoices.STATUS_COMPLETED):
162162
self.trigger_webhooks(event=EVENT_JOB_END)
163163

164164
@classmethod
165-
def enqueue_job(cls, func, name, obj_type, user, schedule_at=None, interval=None, *args, **kwargs):
165+
def enqueue(cls, func, instance, name='', user=None, schedule_at=None, interval=None, **kwargs):
166166
"""
167167
Create a Job instance and enqueue a job using the given callable
168168
169169
Args:
170170
func: The callable object to be enqueued for execution
171+
instance: The NetBox object to which this job pertains
171172
name: Name for the job (optional)
172-
obj_type: ContentType to link to the Job instance object_type
173-
user: User object to link to the Job instance
173+
user: The user responsible for running the job
174174
schedule_at: Schedule the job to be executed at the passed date and time
175175
interval: Recurrence interval (in minutes)
176176
"""
177-
rq_queue_name = get_queue_for_model(obj_type.model)
177+
object_type = ContentType.objects.get_for_model(instance, for_concrete_model=False)
178+
rq_queue_name = get_queue_for_model(object_type.model)
178179
queue = django_rq.get_queue(rq_queue_name)
179180
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
180181
job = Job.objects.create(
182+
object_type=object_type,
183+
object_id=instance.pk,
181184
name=name,
182185
status=status,
183-
object_type=obj_type,
184186
scheduled=schedule_at,
185187
interval=interval,
186188
user=user,
187189
job_id=uuid.uuid4()
188190
)
189191

190192
if schedule_at:
191-
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job_result=job, **kwargs)
193+
queue.enqueue_at(schedule_at, func, job_id=str(job.job_id), job=job, **kwargs)
192194
else:
193-
queue.enqueue(func, job_id=str(job.job_id), job_result=job, **kwargs)
195+
queue.enqueue(func, job_id=str(job.job_id), job=job, **kwargs)
194196

195197
return job
196198

netbox/core/tables/jobs.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,18 @@
66

77

88
class JobTable(NetBoxTable):
9+
id = tables.Column(
10+
linkify=True
11+
)
912
name = tables.Column(
1013
linkify=True
1114
)
1215
object_type = columns.ContentTypeColumn(
1316
verbose_name=_('Type')
1417
)
18+
object = tables.Column(
19+
linkify=True
20+
)
1521
status = columns.ChoiceFieldColumn()
1622
created = columns.DateTimeColumn()
1723
scheduled = columns.DateTimeColumn()
@@ -25,10 +31,9 @@ class JobTable(NetBoxTable):
2531
class Meta(NetBoxTable.Meta):
2632
model = Job
2733
fields = (
28-
'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
29-
'user', 'job_id',
34+
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
35+
'completed', 'user', 'job_id',
3036
)
3137
default_columns = (
32-
'pk', 'id', 'object_type', 'name', 'status', 'created', 'scheduled', 'interval', 'started', 'completed',
33-
'user',
38+
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
3439
)

netbox/core/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ def get(self, request, pk):
5555

5656
def post(self, request, pk):
5757
datasource = get_object_or_404(self.queryset, pk=pk)
58-
job_result = datasource.enqueue_sync_job(request)
58+
job = datasource.enqueue_sync_job(request)
5959

60-
messages.success(request, f"Queued job #{job_result.pk} to sync {datasource}")
60+
messages.success(request, f"Queued job #{job.pk} to sync {datasource}")
6161
return redirect(datasource.get_absolute_url())
6262

6363

netbox/extras/api/views.py

Lines changed: 33 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from django.contrib.contenttypes.models import ContentType
22
from django.http import Http404
3+
from django.shortcuts import get_object_or_404
34
from django_rq.queues import get_connection
45
from rest_framework import status
56
from rest_framework.decorators import action
@@ -16,8 +17,8 @@
1617
from core.models import Job
1718
from extras import filtersets
1819
from extras.models import *
19-
from extras.reports import get_report, run_report
20-
from extras.scripts import get_script, run_script
20+
from extras.reports import get_module_and_report, run_report
21+
from extras.scripts import get_module_and_script, run_script
2122
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
2223
from netbox.api.features import SyncedDataMixin
2324
from netbox.api.metadata import ContentTypeMetadata
@@ -170,19 +171,17 @@ class ReportViewSet(ViewSet):
170171
exclude_from_schema = True
171172
lookup_value_regex = '[^/]+' # Allow dots
172173

173-
def _retrieve_report(self, pk):
174-
175-
# Read the PK as "<module>.<report>"
176-
if '.' not in pk:
174+
def _get_report(self, pk):
175+
try:
176+
module_name, report_name = pk.split('.', maxsplit=1)
177+
except ValueError:
177178
raise Http404
178-
module_name, report_name = pk.split('.', maxsplit=1)
179179

180-
# Raise a 404 on an invalid Report module/name
181-
report = get_report(module_name, report_name)
180+
module, report = get_module_and_report(module_name, report_name)
182181
if report is None:
183182
raise Http404
184183

185-
return report
184+
return module, report
186185

187186
def list(self, request):
188187
"""
@@ -215,13 +214,13 @@ def retrieve(self, request, pk):
215214
"""
216215
Retrieve a single Report identified as "<module>.<report>".
217216
"""
217+
module, report = self._get_report(pk)
218218

219219
# Retrieve the Report and Job, if any.
220-
report = self._retrieve_report(pk)
221-
report_content_type = ContentType.objects.get(app_label='extras', model='report')
220+
object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
222221
report.result = Job.objects.filter(
223-
object_type=report_content_type,
224-
name=report.full_name,
222+
object_type=object_type,
223+
name=report.name,
225224
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
226225
).first()
227226

@@ -245,14 +244,14 @@ def run(self, request, pk):
245244
raise RQWorkerNotRunningException()
246245

247246
# Retrieve and run the Report. This will create a new Job.
248-
report = self._retrieve_report(pk)
247+
module, report = self._get_report(pk)
249248
input_serializer = serializers.ReportInputSerializer(data=request.data)
250249

251250
if input_serializer.is_valid():
252-
report.result = Job.enqueue_job(
251+
report.result = Job.enqueue(
253252
run_report,
254-
name=report.full_name,
255-
obj_type=ContentType.objects.get_for_model(Report),
253+
instance=module,
254+
name=report.class_name,
256255
user=request.user,
257256
job_timeout=report.job_timeout,
258257
schedule_at=input_serializer.validated_data.get('schedule_at'),
@@ -275,11 +274,16 @@ class ScriptViewSet(ViewSet):
275274
lookup_value_regex = '[^/]+' # Allow dots
276275

277276
def _get_script(self, pk):
278-
module_name, script_name = pk.split('.', maxsplit=1)
279-
script = get_script(module_name, script_name)
277+
try:
278+
module_name, script_name = pk.split('.', maxsplit=1)
279+
except ValueError:
280+
raise Http404
281+
282+
module, script = get_module_and_script(module_name, script_name)
280283
if script is None:
281284
raise Http404
282-
return script
285+
286+
return module, script
283287

284288
def list(self, request):
285289

@@ -305,11 +309,11 @@ def list(self, request):
305309
return Response(serializer.data)
306310

307311
def retrieve(self, request, pk):
308-
script = self._get_script(pk)
309-
script_content_type = ContentType.objects.get(app_label='extras', model='script')
312+
module, script = self._get_script(pk)
313+
object_type = ContentType.objects.get(app_label='extras', model='scriptmodule')
310314
script.result = Job.objects.filter(
311-
object_type=script_content_type,
312-
name=script.full_name,
315+
object_type=object_type,
316+
name=script.name,
313317
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
314318
).first()
315319
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
@@ -324,18 +328,18 @@ def post(self, request, pk):
324328
if not request.user.has_perm('extras.run_script'):
325329
raise PermissionDenied("This user does not have permission to run scripts.")
326330

327-
script = self._get_script(pk)()
331+
module, script = self._get_script(pk)
328332
input_serializer = serializers.ScriptInputSerializer(data=request.data)
329333

330334
# Check that at least one RQ worker is running
331335
if not Worker.count(get_connection('default')):
332336
raise RQWorkerNotRunningException()
333337

334338
if input_serializer.is_valid():
335-
script.result = Job.enqueue_job(
339+
script.result = Job.enqueue(
336340
run_script,
337-
name=script.full_name,
338-
obj_type=ContentType.objects.get_for_model(Script),
341+
instance=module,
342+
name=script.class_name,
339343
user=request.user,
340344
data=input_serializer.data['data'],
341345
request=copy_safe_request(request),

0 commit comments

Comments
 (0)