diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py index 26726ebdd..5693a8099 100644 --- a/netbox/core/api/serializers_/jobs.py +++ b/netbox/core/api/serializers_/jobs.py @@ -31,7 +31,8 @@ class JobSerializer(BaseModelSerializer): model = Job fields = [ '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') diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index a531c051e..13f046b5b 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -129,10 +129,14 @@ class JobFilterSet(BaseFilterSet): choices=JobStatusChoices, null_value=None ) + queue_name = django_filters.CharFilter() class Meta: 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): if not value.strip(): diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 90908e479..d0e78507c 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -72,7 +72,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): model = Job fieldsets = ( FieldSet('q', 'filter_id'), - FieldSet('object_type_id', 'status', name=_('Attributes')), + FieldSet('object_type_id', 'status', 'queue_name', name=_('Attributes')), FieldSet( 'created__before', 'created__after', 'scheduled__before', 'scheduled__after', 'started__before', 'started__after', 'completed__before', 'completed__after', 'user', name=_('Creation') @@ -88,6 +88,10 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): choices=JobStatusChoices, required=False ) + queue_name = forms.CharField( + label=_('Queue'), + required=False + ) created__after = forms.DateTimeField( label=_('Created after'), required=False, diff --git a/netbox/core/migrations/0021_job_queue_name.py b/netbox/core/migrations/0021_job_queue_name.py new file mode 100644 index 000000000..1525f9e7d --- /dev/null +++ b/netbox/core/migrations/0021_job_queue_name.py @@ -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), + ), + ] diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 8a6bf6a1d..4427bb7ff 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -112,6 +112,12 @@ class Job(models.Model): verbose_name=_('job ID'), 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( verbose_name=_('log entries'), base_field=models.JSONField( @@ -179,11 +185,15 @@ class Job(models.Model): return f"{int(minutes)} minutes, {seconds:.2f} seconds" 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) - 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) - job = queue.fetch_job(str(self.job_id)) + job = queue.fetch_job(rq_job_id) if job: try: @@ -288,7 +298,8 @@ class Job(models.Model): scheduled=schedule_at, interval=interval, user=user, - job_id=uuid.uuid4() + job_id=uuid.uuid4(), + queue_name=rq_queue_name ) job.full_clean() job.save() diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index 00032057f..3720baa39 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -42,6 +42,9 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) + queue_name = tables.Column( + verbose_name=_('Queue'), + ) log_entries = tables.Column( verbose_name=_('Log Entries'), ) @@ -53,7 +56,7 @@ class JobTable(NetBoxTable): model = Job fields = ( '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 = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index 28225c7a6..3a1c2acc7 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -1,8 +1,10 @@ +from unittest.mock import patch, MagicMock + from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.test import TestCase -from core.models import DataSource, ObjectType +from core.models import DataSource, Job, ObjectType from core.choices import ObjectChangeActionChoices from dcim.models import Site, Location, Device from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED @@ -200,3 +202,38 @@ class ObjectTypeTest(TestCase): bookmarks_ots = ObjectType.objects.with_feature('bookmarks') self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), 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() diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index 3371f164e..48adf0319 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -59,6 +59,10 @@ {% trans "Completed" %} {{ object.completed|isodatetime|placeholder }} + + {% trans "Queue" %} + {{ object.queue_name|placeholder }} +