From 41fdf615b8fc5745fe77d5e31bfd7bd8482b2097 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 4 Mar 2026 21:00:13 -0500 Subject: [PATCH] add package updates list view with table, filters, and nav entry - add PackageUpdateTable with installed/available package links and security badges - add package_update_list view with security type and search filters - add /packages/updates/ url route - add packages submenu in navbar (packages + updates) - add 6 view tests --- hosts/tables.py | 6 +- hosts/views.py | 3 + packages/tables.py | 73 ++++++++++++++++++- .../packages/package_update_list.html | 7 ++ packages/tests/test_views.py | 72 ++++++++++++++++++ packages/urls.py | 1 + packages/views.py | 46 +++++++++++- util/templates/navbar.html | 8 +- 8 files changed, 210 insertions(+), 6 deletions(-) create mode 100644 packages/templates/packages/package_update_list.html create mode 100644 packages/tests/test_views.py diff --git a/hosts/tables.py b/hosts/tables.py index b34b7d61..296b8df3 100644 --- a/hosts/tables.py +++ b/hosts/tables.py @@ -22,12 +22,14 @@ HOSTNAME_TEMPLATE = '{{ record.hostname }}' SEC_UPDATES_TEMPLATE = ( '{% with count=record.get_num_security_updates %}' - '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% if count != 0 %}' + '{{ count }}{% endif %}' '{% endwith %}' ) BUG_UPDATES_TEMPLATE = ( '{% with count=record.get_num_bugfix_updates %}' - '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% if count != 0 %}' + '{{ count }}{% endif %}' '{% endwith %}' ) AFFECTED_ERRATA_TEMPLATE = ( diff --git a/hosts/views.py b/hosts/views.py index f940c3a4..b4e002dd 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -90,6 +90,9 @@ def host_list(request): if 'package' in request.GET: hosts = hosts.filter(packages__name__name=request.GET['package']) + if 'update_id' in request.GET: + hosts = hosts.filter(updates=request.GET['update_id']) + if 'repo_id' in request.GET: hosts = hosts.filter(repos=request.GET['repo_id']) diff --git a/packages/tables.py b/packages/tables.py index 633c79a2..0004c79a 100644 --- a/packages/tables.py +++ b/packages/tables.py @@ -14,7 +14,7 @@ import django_tables2 as tables -from packages.models import Package, PackageName +from packages.models import Package, PackageName, PackageUpdate from util.tables import BaseTable PACKAGE_NAME_TEMPLATE = '{{ record }}' @@ -118,3 +118,74 @@ class PackageNameTable(BaseTable): class Meta(BaseTable.Meta): model = PackageName fields = ('packagename_name', 'versions') + + +UPDATE_OLD_TEMPLATE = ( + '' + '{{ record.oldpackage }}' +) +UPDATE_NEW_TEMPLATE = ( + '' + '{{ record.newpackage }}' +) +UPDATE_HOSTS_TEMPLATE = ( + '' + '{{ record.host_count }}' +) +UPDATE_AFFECTED_TEMPLATE = ( + '' + '{{ record.affected_count }}' +) +UPDATE_FIXED_TEMPLATE = ( + '' + '{{ record.fixed_count }}' +) + + +UPDATE_TYPE_TEMPLATE = ( + '{% if record.security %}' + 'Security' + '{% else %}' + 'Bugfix' + '{% endif %}' +) + + +class PackageUpdateTable(BaseTable): + oldpackage = tables.TemplateColumn( + UPDATE_OLD_TEMPLATE, + verbose_name='Installed', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + newpackage = tables.TemplateColumn( + UPDATE_NEW_TEMPLATE, + verbose_name='Available', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + security = tables.TemplateColumn( + UPDATE_TYPE_TEMPLATE, + verbose_name='Type', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + hosts = tables.TemplateColumn( + UPDATE_HOSTS_TEMPLATE, + verbose_name='Hosts', + order_by='host_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + affected = tables.TemplateColumn( + UPDATE_AFFECTED_TEMPLATE, + verbose_name='Affected by Errata', + order_by='affected_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + fixed = tables.TemplateColumn( + UPDATE_FIXED_TEMPLATE, + verbose_name='Fixed in Errata', + order_by='fixed_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = PackageUpdate + fields = ('oldpackage', 'newpackage', 'security', 'hosts', 'affected', 'fixed') diff --git a/packages/templates/packages/package_update_list.html b/packages/templates/packages/package_update_list.html new file mode 100644 index 00000000..62c2557d --- /dev/null +++ b/packages/templates/packages/package_update_list.html @@ -0,0 +1,7 @@ +{% extends "objectlist.html" %} + +{% block page_title %}Package Updates{% endblock %} + +{% block breadcrumbs %} {{ block.super }}
  • Package Updates
  • {% endblock %} + +{% block content_title %} Package Updates {% endblock %} diff --git a/packages/tests/test_views.py b/packages/tests/test_views.py new file mode 100644 index 00000000..9fc86abd --- /dev/null +++ b/packages/tests/test_views.py @@ -0,0 +1,72 @@ +from django.contrib.auth.models import User +from django.test import TestCase, override_settings +from django.urls import reverse + +from arch.models import PackageArchitecture +from packages.models import Package, PackageName, PackageUpdate + + +@override_settings( + CELERY_TASK_ALWAYS_EAGER=True, + CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}} +) +class PackageUpdateViewTests(TestCase): + + def setUp(self): + self.user = User.objects.create_user( + username='testuser', password='testpass' + ) + self.client.login(username='testuser', password='testpass') + self.arch = PackageArchitecture.objects.create(name='x86_64') + self.name = PackageName.objects.create(name='openssl') + self.old = Package.objects.create( + name=self.name, arch=self.arch, epoch='', + version='1.1.1', release='1', packagetype='R', + ) + self.new = Package.objects.create( + name=self.name, arch=self.arch, epoch='', + version='1.1.2', release='1', packagetype='R', + ) + self.sec_update = PackageUpdate.objects.create( + oldpackage=self.old, newpackage=self.new, security=True, + ) + self.bug_update = PackageUpdate.objects.create( + oldpackage=self.old, newpackage=self.new, security=False, + ) + + def test_update_list(self): + resp = self.client.get(reverse('packages:package_update_list')) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'openssl') + + def test_update_list_filter_security(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'security': 'true'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Security') + + def test_update_list_filter_bugfix(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'security': 'false'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Bugfix') + + def test_update_list_search(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'search': 'openssl'} + ) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'openssl') + + def test_update_list_search_no_results(self): + resp = self.client.get( + reverse('packages:package_update_list'), {'search': 'nonexistent'} + ) + self.assertEqual(resp.status_code, 200) + + def test_update_list_requires_login(self): + self.client.logout() + resp = self.client.get(reverse('packages:package_update_list')) + self.assertEqual(resp.status_code, 302) diff --git a/packages/urls.py b/packages/urls.py index bc027807..75273450 100644 --- a/packages/urls.py +++ b/packages/urls.py @@ -27,4 +27,5 @@ path('name//', views.package_name_detail, name='package_name_detail'), path('id/', views.package_list, name='package_list'), path('id//', views.package_detail, name='package_detail'), + path('updates/', views.package_update_list, name='package_update_list'), ] diff --git a/packages/views.py b/packages/views.py index 9f75b415..6afa4f4d 100644 --- a/packages/views.py +++ b/packages/views.py @@ -16,7 +16,7 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.db.models import Q +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, render from django_tables2 import RequestConfig from rest_framework import viewsets @@ -26,7 +26,7 @@ from packages.serializers import ( PackageNameSerializer, PackageSerializer, PackageUpdateSerializer, ) -from packages.tables import PackageNameTable, PackageTable +from packages.tables import PackageNameTable, PackageTable, PackageUpdateTable from util.filterspecs import Filter, FilterBar @@ -172,6 +172,48 @@ def package_name_detail(request, packagename): 'allversions': allversions}) +@login_required +def package_update_list(request): + updates = PackageUpdate.objects.select_related( + 'oldpackage__name', 'oldpackage__arch', + 'newpackage__name', 'newpackage__arch', + ).annotate( + host_count=Count('host', distinct=True), + affected_count=Count('oldpackage__affected_by_erratum', distinct=True), + fixed_count=Count('newpackage__provides_fix_in_erratum', distinct=True), + ) + + if 'security' in request.GET: + security = request.GET['security'] == 'true' + updates = updates.filter(security=security) + if 'host_id' in request.GET: + updates = updates.filter(host=request.GET['host_id']) + if 'search' in request.GET: + terms = request.GET['search'].lower() + query = Q() + for term in terms.split(' '): + q = (Q(oldpackage__name__name__icontains=term) | + Q(newpackage__name__name__icontains=term)) + query = query & q + updates = updates.filter(query) + else: + terms = '' + + filter_list = [] + filter_list.append(Filter(request, 'Type', 'security', + {'true': 'Security', 'false': 'Bugfix'})) + filter_bar = FilterBar(request, filter_list) + + table = PackageUpdateTable(updates.distinct()) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + return render(request, + 'packages/package_update_list.html', + {'table': table, + 'filter_bar': filter_bar, + 'terms': terms}) + + class PackageNameViewSet(viewsets.ModelViewSet): """ API endpoint that allows package names to be viewed or edited. diff --git a/util/templates/navbar.html b/util/templates/navbar.html index 263be6a4..d07c40d5 100644 --- a/util/templates/navbar.html +++ b/util/templates/navbar.html @@ -12,7 +12,13 @@
  • Mirrors
  • -
  • Packages
  • +
  • + Packages + +
  • Errata
  • Security