diff --git a/errata/managers.py b/errata/managers.py index e39147be..b8ab16a8 100644 --- a/errata/managers.py +++ b/errata/managers.py @@ -19,4 +19,4 @@ class ErratumManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset() diff --git a/errata/templates/errata/erratum_detail.html b/errata/templates/errata/erratum_detail.html index 2dbfceaf..3e622401 100644 --- a/errata/templates/errata/erratum_detail.html +++ b/errata/templates/errata/erratum_detail.html @@ -8,10 +8,11 @@ {% block content %} +{% with affected_count=erratum.affected_packages.count fixed_count=erratum.fixed_packages.count %}
@@ -22,8 +23,8 @@ Type {{ erratum.e_type }} Published Date{{ erratum.issue_date|date|default_if_none:'' }} Synopsis {{ erratum.synopsis }} - Packages Affected {{ erratum.affected_packages.count }} - Packages Fixed {{ erratum.fixed_packages.count }} + Packages Affected {{ affected_count }} + Packages Fixed {{ fixed_count }} OS Releases Affected @@ -78,5 +79,6 @@
+{% endwith %} {% endblock %} diff --git a/errata/views.py b/errata/views.py index 285f7483..21382eff 100644 --- a/errata/views.py +++ b/errata/views.py @@ -29,7 +29,7 @@ @login_required def erratum_list(request): - errata = Erratum.objects.select_related() + errata = Erratum.objects.all() if 'e_type' in request.GET: errata = errata.filter(e_type=request.GET['e_type']).distinct() diff --git a/hosts/apps.py b/hosts/apps.py index 84efc94f..a805f23e 100644 --- a/hosts/apps.py +++ b/hosts/apps.py @@ -19,3 +19,6 @@ class HostsConfig(AppConfig): name = 'hosts' + + def ready(self): + import hosts.signals # noqa diff --git a/hosts/managers.py b/hosts/managers.py index 85a489d4..73301b58 100644 --- a/hosts/managers.py +++ b/hosts/managers.py @@ -20,4 +20,4 @@ class HostManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset().select_related('osvariant', 'arch', 'domain') diff --git a/hosts/migrations/0011_host_bug_updates_count_host_errata_count_and_more.py b/hosts/migrations/0011_host_bug_updates_count_host_errata_count_and_more.py new file mode 100644 index 00000000..58e363d0 --- /dev/null +++ b/hosts/migrations/0011_host_bug_updates_count_host_errata_count_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.28 on 2026-02-11 04:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0010_alter_hostrepo_options'), + ] + + operations = [ + migrations.AddField( + model_name='host', + name='bug_updates_count', + field=models.PositiveIntegerField(db_index=True, default=0), + ), + migrations.AddField( + model_name='host', + name='errata_count', + field=models.PositiveIntegerField(db_index=True, default=0), + ), + migrations.AddField( + model_name='host', + name='packages_count', + field=models.PositiveIntegerField(db_index=True, default=0), + ), + migrations.AddField( + model_name='host', + name='sec_updates_count', + field=models.PositiveIntegerField(db_index=True, default=0), + ), + ] diff --git a/hosts/migrations/0012_backfill_cached_counts.py b/hosts/migrations/0012_backfill_cached_counts.py new file mode 100644 index 00000000..4e7b515d --- /dev/null +++ b/hosts/migrations/0012_backfill_cached_counts.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.28 on 2026-02-11 + +from django.db import migrations + + +def backfill_host_counts(apps, schema_editor): + """Backfill cached count fields for existing hosts.""" + Host = apps.get_model('hosts', 'Host') + for host in Host.objects.all(): + host.sec_updates_count = host.updates.filter(security=True).count() + host.bug_updates_count = host.updates.filter(security=False).count() + host.packages_count = host.packages.count() + host.errata_count = host.errata.count() + host.save(update_fields=[ + 'sec_updates_count', 'bug_updates_count', + 'packages_count', 'errata_count' + ]) + + +def reverse_backfill(apps, schema_editor): + """No-op reverse - counts will be recalculated on next report.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('hosts', '0011_host_bug_updates_count_host_errata_count_and_more'), + ] + + operations = [ + migrations.RunPython(backfill_host_counts, reverse_backfill), + ] diff --git a/hosts/models.py b/hosts/models.py index 7c646b98..96f163bf 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -60,6 +60,11 @@ class Host(models.Model): tags = TaggableManager(blank=True) updated_at = models.DateTimeField(default=timezone.now) errata = models.ManyToManyField(Erratum, blank=True) + # Cached count fields for query optimization + sec_updates_count = models.PositiveIntegerField(default=0, db_index=True) + bug_updates_count = models.PositiveIntegerField(default=0, db_index=True) + packages_count = models.PositiveIntegerField(default=0, db_index=True) + errata_count = models.PositiveIntegerField(default=0, db_index=True) from hosts.managers import HostManager objects = HostManager() @@ -97,20 +102,23 @@ def get_absolute_url(self): return reverse('hosts:host_detail', args=[self.hostname]) def get_num_security_updates(self): - return self.updates.filter(security=True).count() + return self.sec_updates_count def get_num_bugfix_updates(self): - return self.updates.filter(security=False).count() + return self.bug_updates_count def get_num_updates(self): - return self.updates.count() + return self.sec_updates_count + self.bug_updates_count def get_num_packages(self): - return self.packages.count() + return self.packages_count def get_num_repos(self): return self.repos.count() + def get_num_errata(self): + return self.errata_count + def check_rdns(self): if self.check_dns: update_rdns(self) @@ -127,9 +135,9 @@ def clean_reports(self): """ Remove all but the last 3 reports for a host """ from reports.models import Report - reports = Report.objects.filter(host=self).order_by('-created')[3:] - rlen = reports.count() - for report in Report.objects.filter(host=self).order_by('-created')[3:]: + reports = list(Report.objects.filter(host=self).order_by('-created')[3:]) + rlen = len(reports) + for report in reports: report.delete() if rlen > 0: info_message(text=f'{self.hostname}: removed {rlen} old reports') @@ -149,14 +157,14 @@ def get_host_repo_packages(self): Q(mirror__repo__in=self.repos.all(), mirror__enabled=True, mirror__repo__enabled=True) - return Package.objects.select_related().filter(hostrepos_q).distinct() + return Package.objects.select_related('name', 'arch').filter(hostrepos_q).distinct() def process_update(self, package, highest_package): if self.host_repos_only: host_repos = Q(repo__host=self) else: host_repos = Q(repo__osrelease__osvariant__host=self, repo__arch=self.arch) | Q(repo__host=self) - mirrors = highest_package.mirror_set.filter(host_repos) + mirrors = highest_package.mirror_set.filter(host_repos).select_related('repo') security = False # if any of the containing repos are security, mark the update as a security update for mirror in mirrors: @@ -215,7 +223,7 @@ def find_host_repo_updates(self, host_packages, repo_packages): repo__mirror__refresh=True, repo__mirror__repo__enabled=True, host=self) - hostrepos = HostRepo.objects.select_related().filter(hostrepos_q) + hostrepos = HostRepo.objects.select_related('host', 'repo').filter(hostrepos_q) for package in host_packages: highest_package = package diff --git a/hosts/serializers.py b/hosts/serializers.py index c0313e47..9565bae6 100644 --- a/hosts/serializers.py +++ b/hosts/serializers.py @@ -33,10 +33,10 @@ class Meta: 'updated_at', 'bugfix_update_count', 'security_update_count') def get_bugfix_update_count(self, obj): - return obj.updates.filter(security=False).count() + return obj.bug_updates_count def get_security_update_count(self, obj): - return obj.updates.filter(security=True).count() + return obj.sec_updates_count class HostRepoSerializer(serializers.HyperlinkedModelSerializer): diff --git a/hosts/signals.py b/hosts/signals.py new file mode 100644 index 00000000..ca5de922 --- /dev/null +++ b/hosts/signals.py @@ -0,0 +1,45 @@ +# Copyright 2026 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from hosts.models import Host + + +@receiver(m2m_changed, sender=Host.packages.through) +def update_host_packages_count(sender, instance, action, **kwargs): + """Update packages_count when Host.packages M2M changes.""" + if action in ('post_add', 'post_remove', 'post_clear'): + instance.packages_count = instance.packages.count() + instance.save(update_fields=['packages_count']) + + +@receiver(m2m_changed, sender=Host.updates.through) +def update_host_updates_count(sender, instance, action, **kwargs): + """Update sec_updates_count and bug_updates_count when Host.updates M2M changes.""" + if action in ('post_add', 'post_remove', 'post_clear'): + instance.sec_updates_count = instance.updates.filter(security=True).count() + instance.bug_updates_count = instance.updates.filter(security=False).count() + instance.save(update_fields=['sec_updates_count', 'bug_updates_count']) + + +@receiver(m2m_changed, sender=Host.errata.through) +def update_host_errata_count(sender, instance, action, **kwargs): + """Update errata_count when Host.errata M2M changes.""" + if action in ('post_add', 'post_remove', 'post_clear'): + instance.errata_count = instance.errata.count() + instance.save(update_fields=['errata_count']) diff --git a/hosts/tables.py b/hosts/tables.py index 715a29ae..b34b7d61 100644 --- a/hosts/tables.py +++ b/hosts/tables.py @@ -31,7 +31,7 @@ '{% endwith %}' ) AFFECTED_ERRATA_TEMPLATE = ( - '{% with count=record.errata.count %}' + '{% with count=record.errata_count %}' '{% if count != 0 %}' '{{ count }}' '{% else %}{% endif %}{% endwith %}' diff --git a/hosts/tasks.py b/hosts/tasks.py index f186760f..71ec203a 100755 --- a/hosts/tasks.py +++ b/hosts/tasks.py @@ -15,7 +15,6 @@ # along with Patchman. If not, see from celery import shared_task -from django.db.models import Count from hosts.models import Host from util import get_datetime_now @@ -34,7 +33,7 @@ def find_host_updates(host_id): def find_all_host_updates(): """ Task to find updates for all hosts """ - for host in Host.objects.all(): + for host in Host.objects.all().iterator(): find_host_updates.delay(host.id) @@ -42,39 +41,35 @@ def find_all_host_updates(): def find_all_host_updates_homogenous(): """ Task to find updates for all hosts where hosts are expected to be homogenous """ - updated_hosts = [] + updated_host_ids = set() ts = get_datetime_now() - for host in Host.objects.all(): - if host not in updated_hosts: + for host in Host.objects.all().iterator(): + if host.id not in updated_host_ids: host.find_updates() host.updated_at = ts host.save() # only include hosts with the exact same number of packages - filtered_hosts = Host.objects.annotate( - packages_count=Count('packages')).filter( - packages_count=host.packages.count() - ) + filtered_hosts = Host.objects.filter( + packages_count=host.packages_count + ) # and exclude hosts with the current timestamp filtered_hosts = filtered_hosts.exclude(updated_at=ts) - packages = set(host.packages.all()) - repos = set(host.repos.all()) - updates = host.updates.all() + package_ids = frozenset(host.packages.values_list('id', flat=True)) + repo_ids = frozenset(host.repos.values_list('id', flat=True)) + updates = list(host.updates.all()) - phosts = [] - for fhost in filtered_hosts: - frepos = set(fhost.repos.all()) - if repos != frepos: + for fhost in filtered_hosts.iterator(): + frepo_ids = frozenset(fhost.repos.values_list('id', flat=True)) + if repo_ids != frepo_ids: continue - fpackages = set(fhost.packages.all()) - if packages != fpackages: + fpackage_ids = frozenset(fhost.packages.values_list('id', flat=True)) + if package_ids != fpackage_ids: continue - phosts.append(fhost) - for phost in phosts: - phost.updates.set(updates) - phost.updated_at = ts - phost.save() - updated_hosts.append(phost) - info_message(text=f'Added the same updates to {phost}') + fhost.updates.set(updates) + fhost.updated_at = ts + fhost.save() + updated_host_ids.add(fhost.id) + info_message(text=f'Added the same updates to {fhost}') diff --git a/hosts/views.py b/hosts/views.py index 259cef57..f940c3a4 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -15,9 +15,11 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see +from urllib.parse import parse_qs + from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.db.models import Count, Q +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django_filters import rest_framework as filters @@ -31,6 +33,7 @@ from hosts.models import Host, HostRepo from hosts.serializers import HostRepoSerializer, HostSerializer from hosts.tables import HostTable +from hosts.tasks import find_host_updates from operatingsystems.models import OSRelease, OSVariant from reports.models import Report from util import sanitize_filter_params @@ -39,10 +42,9 @@ def _get_filtered_hosts(filter_params): """Helper to reconstruct filtered queryset from filter params.""" - from urllib.parse import parse_qs params = parse_qs(filter_params) - hosts = Host.objects.select_related() + hosts = Host.objects.select_related('osvariant', 'arch', 'domain') if 'domain_id' in params: hosts = hosts.filter(domain=params['domain_id'][0]) @@ -76,12 +78,8 @@ def _get_filtered_hosts(filter_params): @login_required def host_list(request): - hosts = Host.objects.select_related().annotate( - sec_updates_count=Count('updates', filter=Q(updates__security=True), distinct=True), - bug_updates_count=Count('updates', filter=Q(updates__security=False), distinct=True), - errata_count=Count('errata', distinct=True), - packages_count=Count('packages', distinct=True), - ) + # Use cached count fields instead of expensive annotations + hosts = Host.objects.select_related('osvariant', 'arch', 'domain') if 'domain_id' in request.GET: hosts = hosts.filter(domain=request.GET['domain_id']) @@ -160,7 +158,7 @@ def host_detail(request, hostname): hostrepos = HostRepo.objects.filter(host=host) # Build packages list with update info - updates_by_package = {u.oldpackage_id: u for u in host.updates.select_related()} + updates_by_package = {u.oldpackage_id: u for u in host.updates.select_related('oldpackage', 'newpackage')} packages_with_updates = [] for package in host.packages.select_related('name', 'arch').order_by('name__name'): package.update = updates_by_package.get(package.id) @@ -226,7 +224,6 @@ def host_delete(request, hostname): def host_find_updates(request, hostname): """ Find updates using a celery task """ - from hosts.tasks import find_host_updates host = get_object_or_404(Host, hostname=hostname) find_host_updates.delay(host.id) text = f'Finding updates for Host {host}' @@ -265,7 +262,6 @@ def host_bulk_action(request): name = Host._meta.verbose_name if count == 1 else Host._meta.verbose_name_plural if action == 'find_updates': - from hosts.tasks import find_host_updates for host in hosts: find_host_updates.delay(host.id) messages.success(request, f'Queued {count} {name} for update check') @@ -298,7 +294,7 @@ class HostViewSet(viewsets.ModelViewSet): """ API endpoint that allows hosts to be viewed or edited. """ - queryset = Host.objects.all() + queryset = Host.objects.select_related('osvariant', 'arch', 'domain').all() serializer_class = HostSerializer filterset_class = HostFilter @@ -307,5 +303,5 @@ class HostRepoViewSet(viewsets.ModelViewSet): """ API endpoint that allows host repos to be viewed or edited. """ - queryset = HostRepo.objects.all() + queryset = HostRepo.objects.select_related('host', 'repo').all() serializer_class = HostRepoSerializer diff --git a/modules/managers.py b/modules/managers.py index ebf7fe31..ca0a6dee 100644 --- a/modules/managers.py +++ b/modules/managers.py @@ -19,4 +19,4 @@ class ModuleManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset().select_related('arch', 'repo') diff --git a/modules/views.py b/modules/views.py index 45703f29..bde0b741 100644 --- a/modules/views.py +++ b/modules/views.py @@ -28,7 +28,7 @@ @login_required def module_list(request): - modules = Module.objects.select_related() + modules = Module.objects.select_related('arch', 'repo') if 'search' in request.GET: terms = request.GET['search'].lower() @@ -62,6 +62,6 @@ class ModuleViewSet(viewsets.ModelViewSet): """ API endpoint that allows modules to be viewed or edited. """ - queryset = Module.objects.all() + queryset = Module.objects.select_related('arch', 'repo').all() serializer_class = ModuleSerializer permission_classes = (permissions.IsAuthenticatedOrReadOnly,) diff --git a/operatingsystems/apps.py b/operatingsystems/apps.py index 4a7f65f7..b0467e42 100644 --- a/operatingsystems/apps.py +++ b/operatingsystems/apps.py @@ -19,3 +19,6 @@ class OperatingsystemsConfig(AppConfig): name = 'operatingsystems' + + def ready(self): + import operatingsystems.signals # noqa diff --git a/operatingsystems/forms.py b/operatingsystems/forms.py index fa319182..5d063b42 100644 --- a/operatingsystems/forms.py +++ b/operatingsystems/forms.py @@ -47,7 +47,7 @@ class Meta: class AddReposToOSReleaseForm(ModelForm): repos = ModelMultipleChoiceField( - queryset=Repository.objects.select_related(), + queryset=Repository.objects.select_related('arch'), required=False, label=None, widget=FilteredSelectMultiple('Repos', False, attrs={'size': '30'})) diff --git a/operatingsystems/migrations/0009_osvariant_hosts_count.py b/operatingsystems/migrations/0009_osvariant_hosts_count.py new file mode 100644 index 00000000..e388f1ad --- /dev/null +++ b/operatingsystems/migrations/0009_osvariant_hosts_count.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.28 on 2026-02-11 04:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('operatingsystems', '0008_alter_osrelease_options_alter_osvariant_options'), + ] + + operations = [ + migrations.AddField( + model_name='osvariant', + name='hosts_count', + field=models.PositiveIntegerField(db_index=True, default=0), + ), + ] diff --git a/operatingsystems/migrations/0010_backfill_osvariant_counts.py b/operatingsystems/migrations/0010_backfill_osvariant_counts.py new file mode 100644 index 00000000..a14fc90d --- /dev/null +++ b/operatingsystems/migrations/0010_backfill_osvariant_counts.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.28 on 2026-02-11 + +from django.db import migrations + + +def backfill_osvariant_counts(apps, schema_editor): + """Backfill hosts_count for existing osvariants.""" + OSVariant = apps.get_model('operatingsystems', 'OSVariant') + Host = apps.get_model('hosts', 'Host') + for osvariant in OSVariant.objects.all(): + osvariant.hosts_count = Host.objects.filter(osvariant=osvariant).count() + osvariant.save(update_fields=['hosts_count']) + + +def reverse_backfill(apps, schema_editor): + """No-op reverse.""" + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('operatingsystems', '0009_osvariant_hosts_count'), + ('hosts', '0012_backfill_cached_counts'), + ] + + operations = [ + migrations.RunPython(backfill_osvariant_counts, reverse_backfill), + ] diff --git a/operatingsystems/models.py b/operatingsystems/models.py index 234b8ab3..911332a9 100644 --- a/operatingsystems/models.py +++ b/operatingsystems/models.py @@ -57,6 +57,8 @@ class OSVariant(models.Model): arch = models.ForeignKey(MachineArchitecture, blank=True, null=True, on_delete=models.CASCADE) osrelease = models.ForeignKey(OSRelease, blank=True, null=True, on_delete=models.SET_NULL) codename = models.CharField(max_length=255, blank=True) + # Cached count field for query optimization + hosts_count = models.PositiveIntegerField(default=0, db_index=True) class Meta: verbose_name = 'Operating System Variant' diff --git a/operatingsystems/signals.py b/operatingsystems/signals.py new file mode 100644 index 00000000..5f1d0507 --- /dev/null +++ b/operatingsystems/signals.py @@ -0,0 +1,56 @@ +# Copyright 2026 Marcus Furlong +# +# This file is part of Patchman. +# +# Patchman is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, version 3 only. +# +# Patchman is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Patchman. If not, see + +from django.db.models.signals import post_delete, post_save, pre_save +from django.dispatch import receiver + +from hosts.models import Host + + +@receiver(pre_save, sender=Host) +def track_osvariant_change(sender, instance, **kwargs): + """Track old osvariant before save to update its count.""" + if instance.pk: + try: + old_instance = Host.objects.get(pk=instance.pk) + instance._old_osvariant = old_instance.osvariant + except Host.DoesNotExist: + instance._old_osvariant = None + else: + instance._old_osvariant = None + + +@receiver(post_save, sender=Host) +def update_osvariant_count_on_save(sender, instance, created, **kwargs): + """Update OSVariant.hosts_count when Host is created or osvariant changes.""" + # Update new osvariant count + if instance.osvariant: + instance.osvariant.hosts_count = Host.objects.filter(osvariant=instance.osvariant).count() + instance.osvariant.save(update_fields=['hosts_count']) + + # Update old osvariant count if it changed + old_osvariant = getattr(instance, '_old_osvariant', None) + if old_osvariant and old_osvariant != instance.osvariant: + old_osvariant.hosts_count = Host.objects.filter(osvariant=old_osvariant).count() + old_osvariant.save(update_fields=['hosts_count']) + + +@receiver(post_delete, sender=Host) +def update_osvariant_count_on_delete(sender, instance, **kwargs): + """Update OSVariant.hosts_count when Host is deleted.""" + if instance.osvariant: + instance.osvariant.hosts_count = Host.objects.filter(osvariant=instance.osvariant).count() + instance.osvariant.save(update_fields=['hosts_count']) diff --git a/operatingsystems/templates/operatingsystems/osrelease_detail.html b/operatingsystems/templates/operatingsystems/osrelease_detail.html index 740b9c4b..be94f43d 100644 --- a/operatingsystems/templates/operatingsystems/osrelease_detail.html +++ b/operatingsystems/templates/operatingsystems/osrelease_detail.html @@ -12,6 +12,7 @@ {% block content %} +{% with osvariant_count=osrelease.osvariant_set.count repos_count=osrelease.repos.count %}