Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion errata/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@

class ErratumManager(models.Manager):
def get_queryset(self):
return super().get_queryset().select_related()
return super().get_queryset()
10 changes: 6 additions & 4 deletions errata/templates/errata/erratum_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@

{% block content %}

{% with affected_count=erratum.affected_packages.count fixed_count=erratum.fixed_packages.count %}
<ul class="nav nav-tabs">
<li class="active"><a data-toggle="tab" href="#erratum_details">Details</a></li>
<li><a data-toggle="tab" href="#erratum_affected_packages">Packages Affected ({{ erratum.affected_packages.count }})</a></li>
<li><a data-toggle="tab" href="#erratum_fixed_packages">Packages Fixed ({{ erratum.fixed_packages.count }})</a></li>
<li><a data-toggle="tab" href="#erratum_affected_packages">Packages Affected ({{ affected_count }})</a></li>
<li><a data-toggle="tab" href="#erratum_fixed_packages">Packages Fixed ({{ fixed_count }})</a></li>
</ul>

<div class="tab-content">
Expand All @@ -22,8 +23,8 @@
<tr><th class="col-sm-1">Type</th><td> {{ erratum.e_type }} </td></tr>
<tr><th class="col-sm-1">Published Date</th><td>{{ erratum.issue_date|date|default_if_none:'' }}</td></tr>
<tr><th class="col-sm-1">Synopsis</th><td> {{ erratum.synopsis }} </td></tr>
<tr><th class="col-sm-1">Packages Affected</th><td><a href="{% url 'packages:package_list' %}?erratum_id={{ erratum.id }}&type=affected"> {{ erratum.affected_packages.count }} </a></td></tr>
<tr><th class="col-sm-1">Packages Fixed</th><td><a href="{% url 'packages:package_list' %}?erratum_id={{ erratum.id }}&type=fixed"> {{ erratum.fixed_packages.count }} </a></td></tr>
<tr><th class="col-sm-1">Packages Affected</th><td><a href="{% url 'packages:package_list' %}?erratum_id={{ erratum.id }}&type=affected"> {{ affected_count }} </a></td></tr>
<tr><th class="col-sm-1">Packages Fixed</th><td><a href="{% url 'packages:package_list' %}?erratum_id={{ erratum.id }}&type=fixed"> {{ fixed_count }} </a></td></tr>
<tr>
<th class="col-sm-2">OS Releases Affected</th>
<td>
Expand Down Expand Up @@ -78,5 +79,6 @@
</div>
</div>
</div>
{% endwith %}

{% endblock %}
2 changes: 1 addition & 1 deletion errata/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 3 additions & 0 deletions hosts/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,6 @@

class HostsConfig(AppConfig):
name = 'hosts'

def ready(self):
import hosts.signals # noqa
2 changes: 1 addition & 1 deletion hosts/managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Original file line number Diff line number Diff line change
@@ -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),
),
]
33 changes: 33 additions & 0 deletions hosts/migrations/0012_backfill_cached_counts.py
Original file line number Diff line number Diff line change
@@ -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),
]
28 changes: 18 additions & 10 deletions hosts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions hosts/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
45 changes: 45 additions & 0 deletions hosts/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
#
# 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 <http://www.gnu.org/licenses/>

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'])
2 changes: 1 addition & 1 deletion hosts/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
'{% endwith %}'
)
AFFECTED_ERRATA_TEMPLATE = (
'{% with count=record.errata.count %}'
'{% with count=record.errata_count %}'
'{% if count != 0 %}'
'<a href="{% url \'errata:erratum_list\' %}?host={{ record.hostname }}">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
Expand Down
45 changes: 20 additions & 25 deletions hosts/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
# along with Patchman. If not, see <http://www.gnu.org/licenses/>

from celery import shared_task
from django.db.models import Count

from hosts.models import Host
from util import get_datetime_now
Expand All @@ -34,47 +33,43 @@ 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)


@shared_task(priority=1)
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}')
Loading