mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-03 22:06:26 -06:00
21129 Store queue_name in Job so correctly deleted in RQ (#21309)
* Add queue name to Job * Add queue name to serializer, filterset, detail view * fix job queue delete * fix job queue delete * review feedback
This commit is contained in:
@@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = [
|
fields = [
|
||||||
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
|
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'object', 'name', 'status', 'created',
|
||||||
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
|
'scheduled', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'queue_name',
|
||||||
|
'log_entries',
|
||||||
]
|
]
|
||||||
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
brief_fields = ('url', 'created', 'completed', 'user', 'status')
|
||||||
|
|
||||||
|
|||||||
@@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet):
|
|||||||
choices=JobStatusChoices,
|
choices=JobStatusChoices,
|
||||||
null_value=None
|
null_value=None
|
||||||
)
|
)
|
||||||
|
queue_name = django_filters.CharFilter()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Job
|
model = Job
|
||||||
fields = ('id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id')
|
fields = (
|
||||||
|
'id', 'object_type', 'object_type_id', 'object_id', 'name', 'interval', 'status', 'user', 'job_id',
|
||||||
|
'queue_name',
|
||||||
|
)
|
||||||
|
|
||||||
def search(self, queryset, name, value):
|
def search(self, queryset, name, value):
|
||||||
if not value.strip():
|
if not value.strip():
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
model = Job
|
model = Job
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
FieldSet('q', 'filter_id'),
|
FieldSet('q', 'filter_id'),
|
||||||
FieldSet('object_type_id', 'status', name=_('Attributes')),
|
FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before',
|
||||||
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
|
'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation')
|
||||||
@@ -88,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
choices=JobStatusChoices,
|
choices=JobStatusChoices,
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
queue_name = forms.CharField(
|
||||||
|
label=_('Queue'),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
created__after = forms.DateTimeField(
|
created__after = forms.DateTimeField(
|
||||||
label=_('Created after'),
|
label=_('Created after'),
|
||||||
required=False,
|
required=False,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 5.2.9 on 2026-01-27 00:39
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0020_owner'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='job',
|
||||||
|
name='queue_name',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -112,6 +112,12 @@ class Job(models.Model):
|
|||||||
verbose_name=_('job ID'),
|
verbose_name=_('job ID'),
|
||||||
unique=True
|
unique=True
|
||||||
)
|
)
|
||||||
|
queue_name = models.CharField(
|
||||||
|
verbose_name=_('queue name'),
|
||||||
|
max_length=100,
|
||||||
|
blank=True,
|
||||||
|
help_text=_('Name of the queue in which this job was enqueued')
|
||||||
|
)
|
||||||
log_entries = ArrayField(
|
log_entries = ArrayField(
|
||||||
verbose_name=_('log entries'),
|
verbose_name=_('log entries'),
|
||||||
base_field=models.JSONField(
|
base_field=models.JSONField(
|
||||||
@@ -179,11 +185,15 @@ class Job(models.Model):
|
|||||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
|
# Use the stored queue name, or fall back to get_queue_for_model for legacy jobs
|
||||||
|
rq_queue_name = self.queue_name or get_queue_for_model(self.object_type.model if self.object_type else None)
|
||||||
|
rq_job_id = str(self.job_id)
|
||||||
|
|
||||||
super().delete(*args, **kwargs)
|
super().delete(*args, **kwargs)
|
||||||
|
|
||||||
rq_queue_name = get_queue_for_model(self.object_type.model if self.object_type else None)
|
# Cancel the RQ job using the stored queue name
|
||||||
queue = django_rq.get_queue(rq_queue_name)
|
queue = django_rq.get_queue(rq_queue_name)
|
||||||
job = queue.fetch_job(str(self.job_id))
|
job = queue.fetch_job(rq_job_id)
|
||||||
|
|
||||||
if job:
|
if job:
|
||||||
try:
|
try:
|
||||||
@@ -288,7 +298,8 @@ class Job(models.Model):
|
|||||||
scheduled=schedule_at,
|
scheduled=schedule_at,
|
||||||
interval=interval,
|
interval=interval,
|
||||||
user=user,
|
user=user,
|
||||||
job_id=uuid.uuid4()
|
job_id=uuid.uuid4(),
|
||||||
|
queue_name=rq_queue_name
|
||||||
)
|
)
|
||||||
job.full_clean()
|
job.full_clean()
|
||||||
job.save()
|
job.save()
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ class JobTable(NetBoxTable):
|
|||||||
completed = columns.DateTimeColumn(
|
completed = columns.DateTimeColumn(
|
||||||
verbose_name=_('Completed'),
|
verbose_name=_('Completed'),
|
||||||
)
|
)
|
||||||
|
queue_name = tables.Column(
|
||||||
|
verbose_name=_('Queue'),
|
||||||
|
)
|
||||||
log_entries = tables.Column(
|
log_entries = tables.Column(
|
||||||
verbose_name=_('Log Entries'),
|
verbose_name=_('Log Entries'),
|
||||||
)
|
)
|
||||||
@@ -53,7 +56,7 @@ class JobTable(NetBoxTable):
|
|||||||
model = Job
|
model = Job
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'scheduled', 'interval', 'started',
|
||||||
'completed', 'user', 'error', 'job_id',
|
'completed', 'user', 'queue_name', 'log_entries', 'error', 'job_id',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from core.models import DataSource, ObjectType
|
from core.models import DataSource, Job, ObjectType
|
||||||
from core.choices import ObjectChangeActionChoices
|
from core.choices import ObjectChangeActionChoices
|
||||||
from dcim.models import Site, Location, Device
|
from dcim.models import Site, Location, Device
|
||||||
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
|
||||||
@@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase):
|
|||||||
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
|
bookmarks_ots = ObjectType.objects.with_feature('bookmarks')
|
||||||
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
|
self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots)
|
||||||
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
|
self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots)
|
||||||
|
|
||||||
|
|
||||||
|
class JobTest(TestCase):
|
||||||
|
|
||||||
|
@patch('core.models.jobs.django_rq.get_queue')
|
||||||
|
def test_delete_cancels_job_from_correct_queue(self, mock_get_queue):
|
||||||
|
"""
|
||||||
|
Test that when a job is deleted, it's canceled from the correct queue.
|
||||||
|
"""
|
||||||
|
mock_queue = MagicMock()
|
||||||
|
mock_rq_job = MagicMock()
|
||||||
|
mock_queue.fetch_job.return_value = mock_rq_job
|
||||||
|
mock_get_queue.return_value = mock_queue
|
||||||
|
|
||||||
|
def dummy_func(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Enqueue a job with a custom queue name
|
||||||
|
custom_queue = 'my_custom_queue'
|
||||||
|
job = Job.enqueue(
|
||||||
|
func=dummy_func,
|
||||||
|
name='Test Job',
|
||||||
|
queue_name=custom_queue
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset mock to clear enqueue call
|
||||||
|
mock_get_queue.reset_mock()
|
||||||
|
|
||||||
|
# Delete the job
|
||||||
|
job.delete()
|
||||||
|
|
||||||
|
# Verify the correct queue was used for cancellation
|
||||||
|
mock_get_queue.assert_called_with(custom_queue)
|
||||||
|
mock_queue.fetch_job.assert_called_with(str(job.job_id))
|
||||||
|
mock_rq_job.cancel.assert_called_once()
|
||||||
|
|||||||
@@ -59,6 +59,10 @@
|
|||||||
<th scope="row">{% trans "Completed" %}</th>
|
<th scope="row">{% trans "Completed" %}</th>
|
||||||
<td>{{ object.completed|isodatetime|placeholder }}</td>
|
<td>{{ object.completed|isodatetime|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Queue" %}</th>
|
||||||
|
<td>{{ object.queue_name|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user