From fa31d91fee6e04b236b2112db82ea26bdaafd156 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 30 Oct 2026 00:58:01 -0400 Subject: [PATCH] ensure update counts are refreshed --- errata/utils.py | 2 ++ hosts/tests/test_utils.py | 37 ++++++++++++++++++++++++++++++++++++- hosts/utils.py | 23 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/errata/utils.py b/errata/utils.py index 7023e1fb..c8fb6ec9 100644 --- a/errata/utils.py +++ b/errata/utils.py @@ -15,6 +15,7 @@ # along with Patchman. If not, see from errata.models import Erratum +from hosts.utils import refresh_host_update_counts from packages.models import PackageUpdate from patchman.signals import pbar_start, pbar_update from util import fetch_concurrently, run_concurrently, tz_aware_datetime @@ -72,6 +73,7 @@ def mark_errata_security_updates(concurrent_processing=True, max_workers=25): for i, e in enumerate(Erratum.objects.all()): e.scan_for_security_updates() pbar_update.send(sender=None, index=i + 1) + refresh_host_update_counts() def scan_security_worker(pk): diff --git a/hosts/tests/test_utils.py b/hosts/tests/test_utils.py index c41c2890..068d5b7c 100644 --- a/hosts/tests/test_utils.py +++ b/hosts/tests/test_utils.py @@ -17,11 +17,16 @@ from unittest.mock import patch from django.test import TestCase +from django.utils import timezone -from arch.models import MachineArchitecture +from arch.models import MachineArchitecture, PackageArchitecture from domains.models import Domain +from errata.models import Erratum +from errata.utils import mark_errata_security_updates +from hosts.models import Host from hosts.utils import get_or_create_host from operatingsystems.models import OSRelease, OSVariant +from packages.models import Package, PackageName, PackageUpdate from reports.models import Report @@ -54,3 +59,33 @@ def test_protocol2_keeps_spaces_within_tags(self, _): def test_protocol2_empty_tags(self, _): self.assertEqual(self.host_tags('2', ''), []) + + +class RefreshHostUpdateCountsTests(TestCase): + + def test_counts_refreshed_after_errata_security_marking(self): + os_release = OSRelease.objects.create(name='Debian 13') + host = Host.objects.create( + hostname='h.example.com', ipaddress='192.0.2.1', + osvariant=OSVariant.objects.create(name='Debian 13', osrelease=os_release), + kernel='6.12.0', arch=MachineArchitecture.objects.create(name='x86_64'), + domain=Domain.objects.create(name='example.com'), + lastreport=timezone.now(), + ) + pkg_arch = PackageArchitecture.objects.create(name='amd64') + name = PackageName.objects.create(name='curl') + old = Package.objects.create(name=name, arch=pkg_arch, epoch='', version='1', release='1', packagetype='D') + new = Package.objects.create(name=name, arch=pkg_arch, epoch='', version='2', release='1', packagetype='D') + update = PackageUpdate.objects.create(oldpackage=old, newpackage=new, security=False) + host.updates.add(update) + host.refresh_from_db() + self.assertEqual((host.sec_updates_count, host.bug_updates_count), (0, 1)) + + erratum = Erratum.objects.create( + name='DSA-0000-1', e_type='security', issue_date=timezone.now(), synopsis='curl', + ) + erratum.fixed_packages.add(new) + mark_errata_security_updates(concurrent_processing=False) + + host.refresh_from_db() + self.assertEqual((host.sec_updates_count, host.bug_updates_count), (1, 0)) diff --git a/hosts/utils.py b/hosts/utils.py index 9e618c4e..58a11fd3 100644 --- a/hosts/utils.py +++ b/hosts/utils.py @@ -18,6 +18,8 @@ from socket import gaierror, gethostbyaddr, herror from django.db import IntegrityError, transaction +from django.db.models import Count, OuterRef, Subquery +from django.db.models.functions import Coalesce from taggit.models import Tag from taggit.utils import parse_tags @@ -37,6 +39,27 @@ def update_rdns(host): host.save() +def refresh_host_update_counts(): + """ Recalculate cached security/bugfix update counts for all hosts. + Needed when PackageUpdate.security changes, as that does not fire + the Host.updates m2m_changed signal. + """ + from hosts.models import Host + through = Host.updates.through + + def count_updates(security): + counts = through.objects.filter( + host_id=OuterRef('pk'), + packageupdate__security=security, + ).values('host_id').annotate(c=Count('pk')).values('c') + return Coalesce(Subquery(counts), 0) + + Host.objects.update( + sec_updates_count=count_updates(True), + bug_updates_count=count_updates(False), + ) + + def get_report_tags(report): """ Protocol 2 sends a list (stored comma-joined), protocol 1 sends the raw config string, which may be comma or space delimited