Skip to content

Commit fe25a12

Browse files
committed
add cached count fields to erratum model with m2m signals
1 parent 93af23d commit fe25a12

File tree

8 files changed

+279
-10
lines changed

8 files changed

+279
-10
lines changed

errata/apps.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ def ready(self):
2626
from django.db.models.signals import post_save
2727
from django.utils import timezone
2828

29+
import errata.signals # noqa: F401
30+
2931
def set_initial_last_run(sender, instance, created, **kwargs):
3032
if created and instance.name == 'update_errata_cves_cwes_every_12_hours':
3133
instance.last_run_at = timezone.now() - timedelta(days=1)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 4.2.28 on 2026-02-13 06:44
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('errata', '0007_alter_erratum_fixed_packages'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='erratum',
15+
name='affected_packages_count',
16+
field=models.PositiveIntegerField(default=0),
17+
),
18+
migrations.AddField(
19+
model_name='erratum',
20+
name='cves_count',
21+
field=models.PositiveIntegerField(default=0),
22+
),
23+
migrations.AddField(
24+
model_name='erratum',
25+
name='fixed_packages_count',
26+
field=models.PositiveIntegerField(default=0),
27+
),
28+
migrations.AddField(
29+
model_name='erratum',
30+
name='osreleases_count',
31+
field=models.PositiveIntegerField(default=0),
32+
),
33+
migrations.AddField(
34+
model_name='erratum',
35+
name='references_count',
36+
field=models.PositiveIntegerField(default=0),
37+
),
38+
]
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
2+
#
3+
# This file is part of Patchman.
4+
#
5+
# Patchman is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, version 3 only.
8+
#
9+
# Patchman is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Patchman. If not, see <http://www.gnu.org/licenses/>
16+
17+
from django.db import migrations
18+
19+
20+
def backfill_counts(apps, schema_editor):
21+
Erratum = apps.get_model('errata', 'Erratum')
22+
for erratum in Erratum.objects.all().iterator():
23+
erratum.affected_packages_count = erratum.affected_packages.count()
24+
erratum.fixed_packages_count = erratum.fixed_packages.count()
25+
erratum.osreleases_count = erratum.osreleases.count()
26+
erratum.cves_count = erratum.cves.count()
27+
erratum.references_count = erratum.references.count()
28+
erratum.save(update_fields=[
29+
'affected_packages_count',
30+
'fixed_packages_count',
31+
'osreleases_count',
32+
'cves_count',
33+
'references_count',
34+
])
35+
36+
37+
class Migration(migrations.Migration):
38+
39+
dependencies = [
40+
('errata', '0008_add_cached_count_fields'),
41+
]
42+
43+
operations = [
44+
migrations.RunPython(backfill_counts, migrations.RunPython.noop),
45+
]

errata/models.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ class Erratum(models.Model):
4040
osreleases = models.ManyToManyField(OSRelease, blank=True)
4141
cves = models.ManyToManyField(CVE, blank=True)
4242
references = models.ManyToManyField(Reference, blank=True)
43+
affected_packages_count = models.PositiveIntegerField(default=0)
44+
fixed_packages_count = models.PositiveIntegerField(default=0)
45+
osreleases_count = models.PositiveIntegerField(default=0)
46+
cves_count = models.PositiveIntegerField(default=0)
47+
references_count = models.PositiveIntegerField(default=0)
4348

4449
objects = ErratumManager()
4550

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

5156
def __str__(self):
52-
text = f'{self.name} ({self.e_type}), {self.cves.count()} related CVEs, '
53-
text += f'affecting {self.osreleases.count()} OS Releases, '
54-
text += f'providing {self.fixed_packages.count()} fixed Packages'
57+
text = f'{self.name} ({self.e_type}), {self.cves_count} related CVEs, '
58+
text += f'affecting {self.osreleases_count} OS Releases, '
59+
text += f'providing {self.fixed_packages_count} fixed Packages'
5560
return text
5661

5762
def get_absolute_url(self):

errata/signals.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Copyright 2026 Marcus Furlong <furlongm@gmail.com>
2+
#
3+
# This file is part of Patchman.
4+
#
5+
# Patchman is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU General Public License as published by
7+
# the Free Software Foundation, version 3 only.
8+
#
9+
# Patchman is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with Patchman. If not, see <http://www.gnu.org/licenses/>
16+
17+
from django.db.models.signals import m2m_changed
18+
from django.dispatch import receiver
19+
20+
from errata.models import Erratum
21+
22+
23+
@receiver(m2m_changed, sender=Erratum.affected_packages.through)
24+
def update_affected_packages_count(sender, instance, action, **kwargs):
25+
"""Update affected_packages_count when Erratum.affected_packages M2M changes."""
26+
if action in ('post_add', 'post_remove', 'post_clear'):
27+
instance.affected_packages_count = instance.affected_packages.count()
28+
instance.save(update_fields=['affected_packages_count'])
29+
30+
31+
@receiver(m2m_changed, sender=Erratum.fixed_packages.through)
32+
def update_fixed_packages_count(sender, instance, action, **kwargs):
33+
"""Update fixed_packages_count when Erratum.fixed_packages M2M changes."""
34+
if action in ('post_add', 'post_remove', 'post_clear'):
35+
instance.fixed_packages_count = instance.fixed_packages.count()
36+
instance.save(update_fields=['fixed_packages_count'])
37+
38+
39+
@receiver(m2m_changed, sender=Erratum.osreleases.through)
40+
def update_osreleases_count(sender, instance, action, **kwargs):
41+
"""Update osreleases_count when Erratum.osreleases M2M changes."""
42+
if action in ('post_add', 'post_remove', 'post_clear'):
43+
instance.osreleases_count = instance.osreleases.count()
44+
instance.save(update_fields=['osreleases_count'])
45+
46+
47+
@receiver(m2m_changed, sender=Erratum.cves.through)
48+
def update_cves_count(sender, instance, action, **kwargs):
49+
"""Update cves_count when Erratum.cves M2M changes."""
50+
if action in ('post_add', 'post_remove', 'post_clear'):
51+
instance.cves_count = instance.cves.count()
52+
instance.save(update_fields=['cves_count'])
53+
54+
55+
@receiver(m2m_changed, sender=Erratum.references.through)
56+
def update_references_count(sender, instance, action, **kwargs):
57+
"""Update references_count when Erratum.references M2M changes."""
58+
if action in ('post_add', 'post_remove', 'post_clear'):
59+
instance.references_count = instance.references.count()
60+
instance.save(update_fields=['references_count'])

errata/tables.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,31 +19,31 @@
1919

2020
ERRATUM_NAME_TEMPLATE = '<a href="{{ record.get_absolute_url }}">{{ record.name }}</a>'
2121
PACKAGES_AFFECTED_TEMPLATE = (
22-
'{% with count=record.affected_packages.count %}'
22+
'{% with count=record.affected_packages_count %}'
2323
'{% if count != 0 %}'
2424
'<a href="{% url \'packages:package_list\' %}?erratum_id={{ record.id }}&type=affected">{{ count }}</a>'
2525
'{% else %}{% endif %}{% endwith %}'
2626
)
2727
PACKAGES_FIXED_TEMPLATE = (
28-
'{% with count=record.fixed_packages.count %}'
28+
'{% with count=record.fixed_packages_count %}'
2929
'{% if count != 0 %}'
3030
'<a href="{% url \'packages:package_list\' %}?erratum_id={{ record.id }}&type=fixed">{{ count }}</a>'
3131
'{% else %}{% endif %}{% endwith %}'
3232
)
3333
OSRELEASES_TEMPLATE = (
34-
'{% with count=record.osreleases.count %}'
34+
'{% with count=record.osreleases_count %}'
3535
'{% if count != 0 %}'
3636
'<a href="{% url \'operatingsystems:osrelease_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
3737
'{% else %}{% endif %}{% endwith %}'
3838
)
3939
ERRATUM_CVES_TEMPLATE = (
40-
'{% with count=record.cves.count %}'
40+
'{% with count=record.cves_count %}'
4141
'{% if count != 0 %}'
4242
'<a href="{% url \'security:cve_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
4343
'{% else %}{% endif %}{% endwith %}'
4444
)
4545
REFERENCES_TEMPLATE = (
46-
'{% with count=record.references.count %}'
46+
'{% with count=record.references_count %}'
4747
'{% if count != 0 %}'
4848
'<a href="{% url \'security:reference_list\' %}?erratum_id={{ record.id }}">{{ count }}</a>'
4949
'{% else %}{% endif %}{% endwith %}'

errata/templates/errata/erratum_detail.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
{% block content %}
1010

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

errata/tests/test_models.py

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
from errata.models import Erratum
2121
from operatingsystems.models import OSRelease
22-
from security.models import CVE
22+
from security.models import CVE, Reference
2323

2424

2525
@override_settings(
@@ -120,3 +120,122 @@ def test_bugfix_erratum(self):
120120
issue_date=timezone.now(),
121121
)
122122
self.assertEqual(erratum.e_type, 'bugfix')
123+
124+
125+
@override_settings(
126+
CELERY_TASK_ALWAYS_EAGER=True,
127+
CACHES={'default': {'BACKEND': 'django.core.cache.backends.locmem.LocMemCache'}}
128+
)
129+
class ErratumCachedCountTests(TestCase):
130+
"""Tests for Erratum cached count fields and M2M signals."""
131+
132+
def setUp(self):
133+
self.erratum = Erratum.objects.create(
134+
name='USN-5678-1',
135+
e_type='security',
136+
synopsis='Security update',
137+
issue_date=timezone.now(),
138+
)
139+
140+
def test_initial_counts_are_zero(self):
141+
"""Test that cached counts start at zero."""
142+
self.assertEqual(self.erratum.cves_count, 0)
143+
self.assertEqual(self.erratum.osreleases_count, 0)
144+
self.assertEqual(self.erratum.affected_packages_count, 0)
145+
self.assertEqual(self.erratum.fixed_packages_count, 0)
146+
self.assertEqual(self.erratum.references_count, 0)
147+
148+
def test_cves_count_on_add(self):
149+
"""Test cves_count increments on add."""
150+
cve1 = CVE.objects.create(cve_id='CVE-2024-0001')
151+
cve2 = CVE.objects.create(cve_id='CVE-2024-0002')
152+
self.erratum.cves.add(cve1)
153+
self.erratum.refresh_from_db()
154+
self.assertEqual(self.erratum.cves_count, 1)
155+
self.erratum.cves.add(cve2)
156+
self.erratum.refresh_from_db()
157+
self.assertEqual(self.erratum.cves_count, 2)
158+
159+
def test_cves_count_on_remove(self):
160+
"""Test cves_count decrements on remove."""
161+
cve = CVE.objects.create(cve_id='CVE-2024-0003')
162+
self.erratum.cves.add(cve)
163+
self.erratum.refresh_from_db()
164+
self.assertEqual(self.erratum.cves_count, 1)
165+
self.erratum.cves.remove(cve)
166+
self.erratum.refresh_from_db()
167+
self.assertEqual(self.erratum.cves_count, 0)
168+
169+
def test_cves_count_on_clear(self):
170+
"""Test cves_count resets to zero on clear."""
171+
cve1 = CVE.objects.create(cve_id='CVE-2024-0004')
172+
cve2 = CVE.objects.create(cve_id='CVE-2024-0005')
173+
self.erratum.cves.add(cve1, cve2)
174+
self.erratum.refresh_from_db()
175+
self.assertEqual(self.erratum.cves_count, 2)
176+
self.erratum.cves.clear()
177+
self.erratum.refresh_from_db()
178+
self.assertEqual(self.erratum.cves_count, 0)
179+
180+
def test_osreleases_count_on_add(self):
181+
"""Test osreleases_count increments on add."""
182+
release = OSRelease.objects.create(name='Ubuntu 24.04')
183+
self.erratum.osreleases.add(release)
184+
self.erratum.refresh_from_db()
185+
self.assertEqual(self.erratum.osreleases_count, 1)
186+
187+
def test_osreleases_count_on_remove(self):
188+
"""Test osreleases_count decrements on remove."""
189+
release = OSRelease.objects.create(name='Ubuntu 24.04')
190+
self.erratum.osreleases.add(release)
191+
self.erratum.refresh_from_db()
192+
self.assertEqual(self.erratum.osreleases_count, 1)
193+
self.erratum.osreleases.remove(release)
194+
self.erratum.refresh_from_db()
195+
self.assertEqual(self.erratum.osreleases_count, 0)
196+
197+
def test_references_count_on_add(self):
198+
"""Test references_count increments on add."""
199+
ref = Reference.objects.create(
200+
ref_type='ADVISORY',
201+
url='https://example.com/advisory/1',
202+
)
203+
self.erratum.references.add(ref)
204+
self.erratum.refresh_from_db()
205+
self.assertEqual(self.erratum.references_count, 1)
206+
207+
def test_references_count_on_remove(self):
208+
"""Test references_count decrements on remove."""
209+
ref = Reference.objects.create(
210+
ref_type='ADVISORY',
211+
url='https://example.com/advisory/2',
212+
)
213+
self.erratum.references.add(ref)
214+
self.erratum.refresh_from_db()
215+
self.assertEqual(self.erratum.references_count, 1)
216+
self.erratum.references.remove(ref)
217+
self.erratum.refresh_from_db()
218+
self.assertEqual(self.erratum.references_count, 0)
219+
220+
def test_str_uses_cached_counts(self):
221+
"""Test __str__ reflects cached count values."""
222+
cve = CVE.objects.create(cve_id='CVE-2024-0010')
223+
release = OSRelease.objects.create(name='RHEL 9')
224+
self.erratum.cves.add(cve)
225+
self.erratum.osreleases.add(release)
226+
self.erratum.refresh_from_db()
227+
result = str(self.erratum)
228+
self.assertIn('1 related CVEs', result)
229+
self.assertIn('affecting 1 OS Releases', result)
230+
self.assertIn('providing 0 fixed Packages', result)
231+
232+
def test_counts_match_actual_m2m(self):
233+
"""Test cached counts stay in sync with actual M2M counts."""
234+
cve1 = CVE.objects.create(cve_id='CVE-2024-0020')
235+
cve2 = CVE.objects.create(cve_id='CVE-2024-0021')
236+
release = OSRelease.objects.create(name='Debian 12')
237+
self.erratum.cves.add(cve1, cve2)
238+
self.erratum.osreleases.add(release)
239+
self.erratum.refresh_from_db()
240+
self.assertEqual(self.erratum.cves_count, self.erratum.cves.count())
241+
self.assertEqual(self.erratum.osreleases_count, self.erratum.osreleases.count())

0 commit comments

Comments
 (0)