diff --git a/netbox/core/tests/test_views.py b/netbox/core/tests/test_views.py index b763eaed2..b48bc89e7 100644 --- a/netbox/core/tests/test_views.py +++ b/netbox/core/tests/test_views.py @@ -1,7 +1,7 @@ import json import urllib.parse import uuid -from datetime import datetime +from datetime import UTC, datetime from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -152,6 +152,77 @@ class JobTestCase( ) +class JobLogViewTestCase(TestCase): + user_permissions = ( + 'core.view_job', + ) + + @classmethod + def setUpTestData(cls): + cls.job = Job.objects.create( + name='Test Job', + job_id=uuid.uuid4(), + ) + cls.job.log_entries = [ + { + 'level': 'info', + 'message': f'log line {i}', + 'timestamp': datetime(2026, 1, 1, tzinfo=UTC), + } + for i in range(120) + ] + cls.job.save() + + def setUp(self): + super().setUp() + # UserConfig.set() mutates self.data in place, which can mutate DEFAULT_USER_PREFERENCES + # (the signal in users/signals.py initializes data with a shared reference). Assign a + # fresh literal instead. Pin per_page so page-boundary assertions don't depend on PAGINATE_COUNT. + self.user.config.data = {'pagination': {'per_page': 50}} + self.user.config.save() + + def test_log_page_renders_table_inline(self): + """The full page renders the first log page inside an HTMX container.""" + url = reverse('core:job_log', kwargs={'pk': self.job.pk}) + response = self.client.get(url) + self.assertHttpStatus(response, 200) + self.assertContains(response, 'htmx-container') + self.assertContains(response, 'log line 0') + self.assertContains(response, 'Showing 1-50 of 120') + + def test_log_page_table_is_embedded(self): + """The embedded table never pushes page/per_page into the browser URL.""" + url = reverse('core:job_log', kwargs={'pk': self.job.pk}) + response = self.client.get(url) + self.assertHttpStatus(response, 200) + self.assertNotContains(response, 'hx-push-url="true"') + + def test_log_table_htmx_renders_partial(self): + """An HTMX request returns the paginated table partial.""" + url = reverse('core:job_log', kwargs={'pk': self.job.pk}) + response = self.client.get(url, headers={'hx-request': 'true'}) + self.assertHttpStatus(response, 200) + self.assertContains(response, 'log line 0') + self.assertContains(response, 'Showing 1-50 of 120') + self.assertContains(response, 'Per Page') + + def test_log_table_htmx_page_navigation(self): + """`?page=2` advances the embedded table to the second page.""" + url = reverse('core:job_log', kwargs={'pk': self.job.pk}) + response = self.client.get(f'{url}?page=2', headers={'hx-request': 'true'}) + self.assertHttpStatus(response, 200) + self.assertContains(response, 'log line 50') + self.assertNotContains(response, 'log line 49') + + def test_log_table_htmx_per_page(self): + """`?per_page=100` widens the embedded table page size.""" + url = reverse('core:job_log', kwargs={'pk': self.job.pk}) + response = self.client.get(f'{url}?per_page=100', headers={'hx-request': 'true'}) + self.assertHttpStatus(response, 200) + self.assertContains(response, 'log line 99') + self.assertNotContains(response, 'log line 100') + + # TODO: Convert to StandardTestCases.Views class ObjectChangeTestCase(TestCase): user_permissions = ( diff --git a/netbox/core/views.py b/netbox/core/views.py index 760346b3d..78059ce2d 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -39,7 +39,6 @@ from netbox.plugins.utils import get_installed_plugins from netbox.ui import layout from netbox.ui.panels import ( CommentsPanel, - ContextTablePanel, JSONPanel, ObjectsTablePanel, PluginContentPanel, @@ -269,7 +268,7 @@ class JobLogView(generic.ObjectView): layout = layout.Layout( layout.Row( layout.Column( - ContextTablePanel('table', title=_('Log Entries')), + TemplatePanel('core/job/log_entries.html', title=_('Log Entries')), PluginContentPanel('left_page'), ), ), @@ -280,13 +279,27 @@ class JobLogView(generic.ObjectView): ), ) - def get_extra_context(self, request, instance): + def get_table(self, request, instance): table = JobLogEntryTable(instance.log_entries) + table.embedded = True + table.htmx_url = reverse('core:job_log', kwargs={'pk': instance.pk}) table.configure(request) + return table + + def get_extra_context(self, request, instance): return { - 'table': table, + 'table': self.get_table(request, instance), } + def get(self, request, **kwargs): + if htmx_partial(request): + instance = self.get_object(**kwargs) + return render(request, 'htmx/table.html', { + 'object': instance, + 'table': self.get_table(request, instance), + }) + return super().get(request, **kwargs) + @register_model_view(Job, 'delete') class JobDeleteView(generic.ObjectDeleteView): diff --git a/netbox/templates/core/job/log_entries.html b/netbox/templates/core/job/log_entries.html new file mode 100644 index 000000000..b675a9f94 --- /dev/null +++ b/netbox/templates/core/job/log_entries.html @@ -0,0 +1,5 @@ +{% extends 'ui/panels/_base.html' %} + +{% block panel_content %} + {% include 'htmx/table.html' %} +{% endblock panel_content %}