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: 2 additions & 0 deletions errata/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ def ready(self):
from django.db.models.signals import post_save
from django.utils import timezone

import errata.signals # noqa: F401

def set_initial_last_run(sender, instance, created, **kwargs):
if created and instance.name == 'update_errata_cves_cwes_every_12_hours':
instance.last_run_at = timezone.now() - timedelta(days=1)
Expand Down
38 changes: 38 additions & 0 deletions errata/migrations/0008_add_cached_count_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 4.2.28 on 2026-02-13 06:44

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('errata', '0007_alter_erratum_fixed_packages'),
]

operations = [
migrations.AddField(
model_name='erratum',
name='affected_packages_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='erratum',
name='cves_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='erratum',
name='fixed_packages_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='erratum',
name='osreleases_count',
field=models.PositiveIntegerField(default=0),
),
migrations.AddField(
model_name='erratum',
name='references_count',
field=models.PositiveIntegerField(default=0),
),
]
45 changes: 45 additions & 0 deletions errata/migrations/0009_backfill_cached_counts.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 import migrations


def backfill_counts(apps, schema_editor):
Erratum = apps.get_model('errata', 'Erratum')
for erratum in Erratum.objects.all().iterator():
erratum.affected_packages_count = erratum.affected_packages.count()
erratum.fixed_packages_count = erratum.fixed_packages.count()
erratum.osreleases_count = erratum.osreleases.count()
erratum.cves_count = erratum.cves.count()
erratum.references_count = erratum.references.count()
erratum.save(update_fields=[
'affected_packages_count',
'fixed_packages_count',
'osreleases_count',
'cves_count',
'references_count',
])


class Migration(migrations.Migration):

dependencies = [
('errata', '0008_add_cached_count_fields'),
]

operations = [
migrations.RunPython(backfill_counts, migrations.RunPython.noop),
]
11 changes: 8 additions & 3 deletions errata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ class Erratum(models.Model):
osreleases = models.ManyToManyField(OSRelease, blank=True)
cves = models.ManyToManyField(CVE, blank=True)
references = models.ManyToManyField(Reference, blank=True)
affected_packages_count = models.PositiveIntegerField(default=0)
fixed_packages_count = models.PositiveIntegerField(default=0)
osreleases_count = models.PositiveIntegerField(default=0)
cves_count = models.PositiveIntegerField(default=0)
references_count = models.PositiveIntegerField(default=0)

objects = ErratumManager()

Expand All @@ -49,9 +54,9 @@ class Meta:
ordering = ['-issue_date', 'name']

def __str__(self):
text = f'{self.name} ({self.e_type}), {self.cves.count()} related CVEs, '
text += f'affecting {self.osreleases.count()} OS Releases, '
text += f'providing {self.fixed_packages.count()} fixed Packages'
text = f'{self.name} ({self.e_type}), {self.cves_count} related CVEs, '
text += f'affecting {self.osreleases_count} OS Releases, '
text += f'providing {self.fixed_packages_count} fixed Packages'
return text

def get_absolute_url(self):
Expand Down
60 changes: 60 additions & 0 deletions errata/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 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 errata.models import Erratum


@receiver(m2m_changed, sender=Erratum.affected_packages.through)
def update_affected_packages_count(sender, instance, action, **kwargs):
"""Update affected_packages_count when Erratum.affected_packages M2M changes."""
if action in ('post_add', 'post_remove', 'post_clear'):
instance.affected_packages_count = instance.affected_packages.count()
instance.save(update_fields=['affected_packages_count'])


@receiver(m2m_changed, sender=Erratum.fixed_packages.through)
def update_fixed_packages_count(sender, instance, action, **kwargs):
"""Update fixed_packages_count when Erratum.fixed_packages M2M changes."""
if action in ('post_add', 'post_remove', 'post_clear'):
instance.fixed_packages_count = instance.fixed_packages.count()
instance.save(update_fields=['fixed_packages_count'])


@receiver(m2m_changed, sender=Erratum.osreleases.through)
def update_osreleases_count(sender, instance, action, **kwargs):
"""Update osreleases_count when Erratum.osreleases M2M changes."""
if action in ('post_add', 'post_remove', 'post_clear'):
instance.osreleases_count = instance.osreleases.count()
instance.save(update_fields=['osreleases_count'])


@receiver(m2m_changed, sender=Erratum.cves.through)
def update_cves_count(sender, instance, action, **kwargs):
"""Update cves_count when Erratum.cves M2M changes."""
if action in ('post_add', 'post_remove', 'post_clear'):
instance.cves_count = instance.cves.count()
instance.save(update_fields=['cves_count'])


@receiver(m2m_changed, sender=Erratum.references.through)
def update_references_count(sender, instance, action, **kwargs):
"""Update references_count when Erratum.references M2M changes."""
if action in ('post_add', 'post_remove', 'post_clear'):
instance.references_count = instance.references.count()
instance.save(update_fields=['references_count'])
10 changes: 5 additions & 5 deletions errata/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,31 +19,31 @@

ERRATUM_NAME_TEMPLATE = '<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>'
PACKAGES_AFFECTED_TEMPLATE = (
'{% with count=record.affected_packages.count %}'
'{% with count=record.affected_packages_count %}'
'{% if count != 0 %}'
'<a href="{% url \'packages:package_list\' %}?erratum_id={{ record.id }}&type=affected">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
)
PACKAGES_FIXED_TEMPLATE = (
'{% with count=record.fixed_packages.count %}'
'{% with count=record.fixed_packages_count %}'
'{% if count != 0 %}'
'<a href="{% url \'packages:package_list\' %}?erratum_id={{ record.id }}&type=fixed">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
)
OSRELEASES_TEMPLATE = (
'{% with count=record.osreleases.count %}'
'{% with count=record.osreleases_count %}'
'{% if count != 0 %}'
'<a href="{% url \'operatingsystems:osrelease_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
)
ERRATUM_CVES_TEMPLATE = (
'{% with count=record.cves.count %}'
'{% with count=record.cves_count %}'
'{% if count != 0 %}'
'<a href="{% url \'security:cve_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
)
REFERENCES_TEMPLATE = (
'{% with count=record.references.count %}'
'{% with count=record.references_count %}'
'{% if count != 0 %}'
'<a href="{% url \'security:reference_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
'{% else %}{% endif %}{% endwith %}'
Expand Down
2 changes: 1 addition & 1 deletion errata/templates/errata/erratum_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

{% block content %}

{% with affected_count=erratum.affected_packages.count fixed_count=erratum.fixed_packages.count %}
{% 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 ({{ affected_count }})</a></li>
Expand Down
121 changes: 120 additions & 1 deletion errata/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

from errata.models import Erratum
from operatingsystems.models import OSRelease
from security.models import CVE
from security.models import CVE, Reference


@override_settings(
Expand Down Expand Up @@ -120,3 +120,122 @@ def test_bugfix_erratum(self):
issue_date=timezone.now(),
)
self.assertEqual(erratum.e_type, 'bugfix')


@override_settings(
CELERY_TASK_ALWAYS_EAGER=True,
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
)
class ErratumCachedCountTests(TestCase):
"""Tests for Erratum cached count fields and M2M signals."""

def setUp(self):
self.erratum = Erratum.objects.create(
name='USN-5678-1',
e_type='security',
synopsis='Security update',
issue_date=timezone.now(),
)

def test_initial_counts_are_zero(self):
"""Test that cached counts start at zero."""
self.assertEqual(self.erratum.cves_count, 0)
self.assertEqual(self.erratum.osreleases_count, 0)
self.assertEqual(self.erratum.affected_packages_count, 0)
self.assertEqual(self.erratum.fixed_packages_count, 0)
self.assertEqual(self.erratum.references_count, 0)

def test_cves_count_on_add(self):
"""Test cves_count increments on add."""
cve1 = CVE.objects.create(cve_id='CVE-2024-0001')
cve2 = CVE.objects.create(cve_id='CVE-2024-0002')
self.erratum.cves.add(cve1)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 1)
self.erratum.cves.add(cve2)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 2)

def test_cves_count_on_remove(self):
"""Test cves_count decrements on remove."""
cve = CVE.objects.create(cve_id='CVE-2024-0003')
self.erratum.cves.add(cve)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 1)
self.erratum.cves.remove(cve)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 0)

def test_cves_count_on_clear(self):
"""Test cves_count resets to zero on clear."""
cve1 = CVE.objects.create(cve_id='CVE-2024-0004')
cve2 = CVE.objects.create(cve_id='CVE-2024-0005')
self.erratum.cves.add(cve1, cve2)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 2)
self.erratum.cves.clear()
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, 0)

def test_osreleases_count_on_add(self):
"""Test osreleases_count increments on add."""
release = OSRelease.objects.create(name='Ubuntu 24.04')
self.erratum.osreleases.add(release)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.osreleases_count, 1)

def test_osreleases_count_on_remove(self):
"""Test osreleases_count decrements on remove."""
release = OSRelease.objects.create(name='Ubuntu 24.04')
self.erratum.osreleases.add(release)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.osreleases_count, 1)
self.erratum.osreleases.remove(release)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.osreleases_count, 0)

def test_references_count_on_add(self):
"""Test references_count increments on add."""
ref = Reference.objects.create(
ref_type='ADVISORY',
url='https://example.com/advisory/1',
)
self.erratum.references.add(ref)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.references_count, 1)

def test_references_count_on_remove(self):
"""Test references_count decrements on remove."""
ref = Reference.objects.create(
ref_type='ADVISORY',
url='https://example.com/advisory/2',
)
self.erratum.references.add(ref)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.references_count, 1)
self.erratum.references.remove(ref)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.references_count, 0)

def test_str_uses_cached_counts(self):
"""Test __str__ reflects cached count values."""
cve = CVE.objects.create(cve_id='CVE-2024-0010')
release = OSRelease.objects.create(name='RHEL 9')
self.erratum.cves.add(cve)
self.erratum.osreleases.add(release)
self.erratum.refresh_from_db()
result = str(self.erratum)
self.assertIn('1 related CVEs', result)
self.assertIn('affecting 1 OS Releases', result)
self.assertIn('providing 0 fixed Packages', result)

def test_counts_match_actual_m2m(self):
"""Test cached counts stay in sync with actual M2M counts."""
cve1 = CVE.objects.create(cve_id='CVE-2024-0020')
cve2 = CVE.objects.create(cve_id='CVE-2024-0021')
release = OSRelease.objects.create(name='Debian 12')
self.erratum.cves.add(cve1, cve2)
self.erratum.osreleases.add(release)
self.erratum.refresh_from_db()
self.assertEqual(self.erratum.cves_count, self.erratum.cves.count())
self.assertEqual(self.erratum.osreleases_count, self.erratum.osreleases.count())