From 5a4f8dc60adbc550dca6093265b373484ef77ae4 Mon Sep 17 00:00:00 2001 From: grumo35 Date: Mon, 31 Jan 2022 11:24:04 +0100 Subject: [PATCH 001/146] Update __init__.py Added basic proxy support from environment variables, need to add this option in parameter of patchman cli. --- util/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/util/__init__.py b/util/__init__.py index ddbe63eb..bc669c1e 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -14,6 +14,7 @@ # # You should have received a copy of the GNU General Public License # along with Patchman. If not, see +from os import getenv import sys import requests @@ -33,6 +34,11 @@ from progressbar import Bar, ETA, Percentage, ProgressBar from patchman.signals import error_message +http_proxy=getenv('http_proxy') +proxies = { + 'http': http_proxy, + 'https': http_proxy, +} if ProgressBar.__dict__.get('maxval'): pbar2 = False @@ -130,7 +136,7 @@ def get_url(url): """ res = None try: - res = requests.get(url, stream=True) + res = requests.get(url,proxies=proxies, stream=True) except requests.exceptions.Timeout: error_message.send(sender=None, text='Timeout - {0!s}'.format(url)) except requests.exceptions.TooManyRedirects: From c62ccb2cc9769d2dd20b0807daa342368b424df0 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 22 Mar 2025 00:48:33 -0400 Subject: [PATCH 002/146] Update __init__.py --- util/__init__.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/util/__init__.py b/util/__init__.py index f27e232a..457e6782 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -29,24 +29,23 @@ from time import time from tqdm import tqdm -from patchman.signals import error_message, info_message, debug_message - -http_proxy = getenv('http_proxy') -http_proxy = getenv('https_proxy') -proxies = { - 'http': http_proxy, - 'https': https_proxy, -} - from django.utils.timezone import make_aware from django.utils.dateparse import parse_datetime from django.conf import settings +from patchman.signals import error_message, info_message, debug_message pbar = None verbose = None Checksum = Enum('Checksum', 'md5 sha sha1 sha256 sha512') +http_proxy = getenv('http_proxy') +https_proxy = getenv('https_proxy') +proxies = { + 'http': http_proxy, + 'https': https_proxy, +} + def get_verbosity(): """ Get the global verbosity level From 959d3cbe89697f95dc82ce7d91d5a92075b13234 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 22 Mar 2025 00:49:38 -0400 Subject: [PATCH 003/146] Update __init__.py --- util/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/util/__init__.py b/util/__init__.py index 457e6782..10ee21f7 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -14,13 +14,13 @@ # # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from os import getenv import requests import bz2 import magic import zlib import lzma +import os from datetime import datetime, timezone from enum import Enum from hashlib import md5, sha1, sha256, sha512 @@ -39,8 +39,8 @@ verbose = None Checksum = Enum('Checksum', 'md5 sha sha1 sha256 sha512') -http_proxy = getenv('http_proxy') -https_proxy = getenv('https_proxy') +http_proxy = os.getenv('http_proxy') +https_proxy = os.getenv('https_proxy') proxies = { 'http': http_proxy, 'https': https_proxy, @@ -122,7 +122,7 @@ def get_url(url, headers={}, params={}): response = None try: debug_message.send(sender=None, text=f'Trying {url} headers:{headers} params:{params}') - response = requests.get(url, headers=headers, params=params, stream=True, proxies = proxies, timeout=30) + response = requests.get(url, headers=headers, params=params, stream=True, proxies=proxies, timeout=30) debug_message.send(sender=None, text=f'{response.status_code}: {response.headers}') if response.status_code in [403, 404]: return response From 50d46d215ee2292f3d0c68a24d7e3171f82b4ab8 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 23 Apr 2025 17:35:03 -0400 Subject: [PATCH 004/146] handle duplicate CVSSes better --- security/models.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/security/models.py b/security/models.py index a847ab02..b60acb47 100644 --- a/security/models.py +++ b/security/models.py @@ -125,19 +125,20 @@ def add_cvss_score(self, vector_string, score=None, severity=None, version=None) score = cvss_score.base_score if not severity: severity = cvss_score.severities()[0] - existing = self.cvss_scores.filter(version=version, vector_string=vector_string) - if existing: - cvss = existing.first() - else: + try: cvss, created = CVSS.objects.get_or_create( version=version, vector_string=vector_string, score=score, severity=severity, ) - cvss.score = score - cvss.severity = severity - cvss.save() + except CVSS.MultipleObjectsReturned: + matching_cvsses = CVSS.objects.filter( + version=version, + vector_string=vector_string, + ) + cvss = matching_cvsses.first() + matching_cvsses.exclude(id=cvss.id).delete() self.cvss_scores.add(cvss) def fetch_cve_data(self, fetch_nist_data=False, sleep_secs=6): From 51201364a391f5c2bb102442a5562cd31eb756fa Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 8 Apr 2025 15:49:31 -0400 Subject: [PATCH 005/146] reduce max charfield length for mysql --- security/migrations/0001_initial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/security/migrations/0001_initial.py b/security/migrations/0001_initial.py index 5655f8b0..c22d1727 100644 --- a/security/migrations/0001_initial.py +++ b/security/migrations/0001_initial.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('cwe_id', models.CharField(max_length=255, unique=True)), ('name', models.CharField(blank=True, max_length=255, null=True)), - ('description', models.CharField(blank=True, max_length=65535, null=True)), + ('description', models.CharField(blank=True, max_length=21844, null=True)), ], ), migrations.CreateModel( @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('cve_id', models.CharField(max_length=255, unique=True)), ('title', models.CharField(blank=True, max_length=255, null=True)), - ('description', models.CharField(max_length=65535)), + ('description', models.CharField(max_length=21844)), ('reserved_date', models.DateTimeField(blank=True, null=True)), ('published_date', models.DateTimeField(blank=True, null=True)), ('rejected_date', models.DateTimeField(blank=True, null=True)), From 662d02498d1fbb59ff4eff2dc5e7b8f5f1268061 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 18 Apr 2025 00:05:34 -0400 Subject: [PATCH 006/146] further reduce charfield size for mysql --- security/migrations/0001_initial.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/security/migrations/0001_initial.py b/security/migrations/0001_initial.py index c22d1727..5f922c9a 100644 --- a/security/migrations/0001_initial.py +++ b/security/migrations/0001_initial.py @@ -27,7 +27,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('cwe_id', models.CharField(max_length=255, unique=True)), ('name', models.CharField(blank=True, max_length=255, null=True)), - ('description', models.CharField(blank=True, max_length=21844, null=True)), + ('description', models.CharField(blank=True, max_length=255, null=True)), ], ), migrations.CreateModel( @@ -36,7 +36,7 @@ class Migration(migrations.Migration): ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('cve_id', models.CharField(max_length=255, unique=True)), ('title', models.CharField(blank=True, max_length=255, null=True)), - ('description', models.CharField(max_length=21844)), + ('description', models.CharField(max_length=255)), ('reserved_date', models.DateTimeField(blank=True, null=True)), ('published_date', models.DateTimeField(blank=True, null=True)), ('rejected_date', models.DateTimeField(blank=True, null=True)), From 7cff020620dd1d398f48074325f4fa931e90b941 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 29 Apr 2025 16:10:52 -0400 Subject: [PATCH 007/146] reduce URLField max_length to 765 --- errata/migrations/0001_initial.py | 2 +- security/migrations/0005_reference_cve_references.py | 2 +- security/models.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/errata/migrations/0001_initial.py b/errata/migrations/0001_initial.py index 85fe88b4..d02a7dc8 100644 --- a/errata/migrations/0001_initial.py +++ b/errata/migrations/0001_initial.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('er_type', models.CharField(max_length=255)), - ('url', models.URLField(max_length=2000)), + ('url', models.URLField(max_length=765)), ], ), migrations.CreateModel( diff --git a/security/migrations/0005_reference_cve_references.py b/security/migrations/0005_reference_cve_references.py index 97251add..f94cf7d5 100644 --- a/security/migrations/0005_reference_cve_references.py +++ b/security/migrations/0005_reference_cve_references.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('ref_type', models.CharField(max_length=255)), - ('url', models.URLField(max_length=2000)), + ('url', models.URLField(max_length=765)), ], options={ 'unique_together': {('ref_type', 'url')}, diff --git a/security/models.py b/security/models.py index b60acb47..9c097eed 100644 --- a/security/models.py +++ b/security/models.py @@ -29,7 +29,7 @@ class Reference(models.Model): ref_type = models.CharField(max_length=255) - url = models.URLField(max_length=2000) + url = models.URLField(max_length=765) class Meta: unique_together = ['ref_type', 'url'] From aeddb403c3efc407d098899343d009cee608e118 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 8 May 2025 15:45:31 +0000 Subject: [PATCH 008/146] Bump django from 4.2.20 to 4.2.21 Bumps [django](https://github.com/django/django) from 4.2.20 to 4.2.21. - [Commits](https://github.com/django/django/compare/4.2.20...4.2.21) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.21 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index dca4fe03..2f72fae5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.20 +Django==4.2.21 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From f1b92c2b22d587f964de36a5d38635150f161d07 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 6 Jun 2025 23:28:39 +0000 Subject: [PATCH 009/146] Bump django from 4.2.21 to 4.2.22 Bumps [django](https://github.com/django/django) from 4.2.21 to 4.2.22. - [Commits](https://github.com/django/django/compare/4.2.21...4.2.22) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.22 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2f72fae5..3418b5d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.21 +Django==4.2.22 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From 489dfeca351468edef8e3e22662a42d4600af08f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 10 Jun 2025 08:47:24 +0000 Subject: [PATCH 010/146] Bump requests from 2.32.3 to 2.32.4 Bumps [requests](https://github.com/psf/requests) from 2.32.3 to 2.32.4. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.3...v2.32.4) --- updated-dependencies: - dependency-name: requests dependency-version: 2.32.4 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 2f72fae5..955c7bbb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ python-debian==1.0.1 defusedxml==0.7.1 PyYAML==6.0.2 chardet==5.2.0 -requests==2.32.3 +requests==2.32.4 colorama==0.4.6 djangorestframework==3.15.2 django-filter==25.1 From 3559762a23704e4ca04d1dcfc5c5f60f8a206aa2 Mon Sep 17 00:00:00 2001 From: vtalos Date: Thu, 17 Jul 2025 19:33:36 +0300 Subject: [PATCH 011/146] Remove unused dependency 'chardet' from requirements.txt --- requirements.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 088f0870..a39eb8cc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ django-bootstrap3==23.1 python-debian==1.0.1 defusedxml==0.7.1 PyYAML==6.0.2 -chardet==5.2.0 requests==2.32.4 colorama==0.4.6 djangorestframework==3.15.2 From 08e37c67a843916362f0af341da23fa63ff4b7d3 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 5 Aug 2025 20:15:16 -0400 Subject: [PATCH 012/146] get_or_create_module only returns module --- modules/utils.py | 4 ++-- repos/repo_types/yum.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/utils.py b/modules/utils.py index 817a610c..f56a0f62 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -23,7 +23,7 @@ def get_or_create_module(name, stream, version, context, arch, repo): """ Get or create a module object - Returns the module and a boolean for created + Returns the module """ created = False m_arch, c = PackageArchitecture.objects.get_or_create(name=arch) @@ -46,7 +46,7 @@ def get_or_create_module(name, stream, version, context, arch, repo): arch=m_arch, repo=repo, ) - return module, created + return module def get_matching_modules(name, stream, version, context, arch): diff --git a/repos/repo_types/yum.py b/repos/repo_types/yum.py index d08c7393..7ac85816 100644 --- a/repos/repo_types/yum.py +++ b/repos/repo_types/yum.py @@ -91,7 +91,7 @@ def extract_module_metadata(data, url, repo): packages.add(package) from modules.utils import get_or_create_module - module, created = get_or_create_module(m_name, m_stream, m_version, m_context, arch, repo) + module = get_or_create_module(m_name, m_stream, m_version, m_context, arch, repo) package_ids = [] for package in packages: From 82087ec7f41f5fb0908f1ea56dc03f08902778bd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 10 Sep 2025 02:19:45 +0000 Subject: [PATCH 013/146] Bump django from 4.2.22 to 4.2.24 Bumps [django](https://github.com/django/django) from 4.2.22 to 4.2.24. - [Commits](https://github.com/django/django/compare/4.2.22...4.2.24) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.24 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index a39eb8cc..9d2baa9e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.22 +Django==4.2.24 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From 36decd54c55b614c25c6b9c8fbba9be5ee7926f3 Mon Sep 17 00:00:00 2001 From: Will Furnell Date: Fri, 12 Sep 2025 13:33:45 +0100 Subject: [PATCH 014/146] Package types are in the Package class --- packages/models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/models.py b/packages/models.py index f4c9c59e..74a83c0c 100644 --- a/packages/models.py +++ b/packages/models.py @@ -195,11 +195,11 @@ def __str__(self): rel = f'-{self.release}' else: rel = '' - if self.packagetype == self.GENTOO: + if self.packagetype == Package.GENTOO: return f'{self.category}/{self.name}-{epo}{self.version}{rel}-{self.arch}.{self.get_packagetype_display()}' - elif self.packagetype in [self.DEB, self.ARCH]: + elif self.packagetype in [Package.DEB, Package.ARCH]: return f'{self.name}_{epo}{self.version}{rel}_{self.arch}.{self.get_packagetype_display()}' - elif self.packagetype == self.RPM: + elif self.packagetype == Package.RPM: return f'{self.name}-{epo}{self.version}{rel}-{self.arch}.{self.get_packagetype_display()}' else: return f'{self.name}-{epo}{self.version}{rel}-{self.arch}.{self.get_packagetype_display()}' From c7e54c55fc3b1f54482fb2c232ea2e961bf8ac8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Oct 2025 23:00:50 +0000 Subject: [PATCH 015/146] Bump django from 4.2.24 to 4.2.25 Bumps [django](https://github.com/django/django) from 4.2.24 to 4.2.25. - [Commits](https://github.com/django/django/compare/4.2.24...4.2.25) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.25 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9d2baa9e..9d277d38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.24 +Django==4.2.25 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From d52251e37517f67d1d4e969511dda562fa8e7f85 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 3 Oct 2025 10:54:33 -0400 Subject: [PATCH 016/146] bump redis --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 9d277d38..2f264c9b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,7 +15,7 @@ python-magic==0.4.27 gitpython==3.1.44 tenacity==8.2.3 celery==5.4.0 -redis==5.2.1 +redis==6.4.0 django-celery-beat==2.7.0 tqdm==4.67.1 cvss==3.4 From c3697d897d6ecf493c70f02dd215db48dabe175d Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 3 Oct 2025 10:59:40 -0400 Subject: [PATCH 017/146] Update license in common.py --- util/templatetags/common.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/util/templatetags/common.py b/util/templatetags/common.py index 6737c438..2aea1e5e 100644 --- a/util/templatetags/common.py +++ b/util/templatetags/common.py @@ -1,12 +1,10 @@ -# Copyright 2010 VPAC -# Copyright 2013-2021 Marcus Furlong +# Copyright 2013-2025 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, either version 3 of the License, or -# (at your option) any later version. +# 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 @@ -14,7 +12,7 @@ # 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 . +# along with Patchman. If not, see import re From 80a2417c5bf3616b43d0e45dd33a2173bfdc3bf4 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 3 Oct 2025 11:11:25 -0400 Subject: [PATCH 018/146] fix licenses --- hosts/templatetags/report_alert.py | 7 +++---- setup.py | 2 +- util/filterspecs.py | 7 +++---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/hosts/templatetags/report_alert.py b/hosts/templatetags/report_alert.py index 3a3e3a9a..a28c5058 100644 --- a/hosts/templatetags/report_alert.py +++ b/hosts/templatetags/report_alert.py @@ -1,11 +1,10 @@ -# Copyright 2016-2021 Marcus Furlong +# Copyright 2016-2025 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, either version 3 of the License, or -# (at your option) any later version. +# 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 @@ -13,7 +12,7 @@ # 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 . +# along with Patchman. If not, see from datetime import timedelta diff --git a/setup.py b/setup.py index d8249a67..6ec6d974 100755 --- a/setup.py +++ b/setup.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 # -# Copyright 2013-2021 Marcus Furlong +# Copyright 2013-2025 Marcus Furlong # # This file is part of Patchman. # diff --git a/util/filterspecs.py b/util/filterspecs.py index 1c845ff3..722b45df 100644 --- a/util/filterspecs.py +++ b/util/filterspecs.py @@ -1,12 +1,11 @@ # Copyright 2010 VPAC -# Copyright 2014-2021 Marcus Furlong +# Copyright 2014-2025 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, either version 3 of the License, or -# (at your option) any later version. +# 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 @@ -14,7 +13,7 @@ # 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 . +# along with Patchman. If not, see from django.utils.safestring import mark_safe from django.db.models.query import QuerySet From 2a9b67afb5f3f115e1c13295a118c92b07192030 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 3 Oct 2025 11:57:40 -0400 Subject: [PATCH 019/146] use GPL-3.0-only for debian copyright --- debian/copyright | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debian/copyright b/debian/copyright index ab051037..5202ff0e 100644 --- a/debian/copyright +++ b/debian/copyright @@ -6,7 +6,7 @@ Source: https://github.com/furlongm/patchman Files: * Copyright: 2011-2012 VPAC http://www.vpac.org 2013-2021 Marcus Furlong -License: GPL-3.0 +License: GPL-3.0-only This package 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. From 7ae40dc9e8d3651ef8b7d98c2b5ae7d4e0941528 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 20 Oct 2025 20:47:26 -0400 Subject: [PATCH 020/146] fix tag handling Signed-off-by: Marcus Furlong --- hosts/models.py | 2 +- hosts/utils.py | 19 +++++++++++++++++-- sbin/patchman | 2 ++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/hosts/models.py b/hosts/models.py index a6c451b5..5b7b3979 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -85,7 +85,7 @@ def show(self): text += f'Packages : {self.get_num_packages()}\n' text += f'Repos : {self.get_num_repos()}\n' text += f'Updates : {self.get_num_updates()}\n' - text += f'Tags : {self.tags}\n' + text += f'Tags : {" ".join(self.tags.slugs())}\n' text += f'Needs reboot : {self.reboot_required}\n' text += f'Updated at : {self.updated_at}\n' text += f'Host repos : {self.host_repos_only}\n' diff --git a/hosts/utils.py b/hosts/utils.py index b328129f..f07d5d1e 100644 --- a/hosts/utils.py +++ b/hosts/utils.py @@ -18,8 +18,9 @@ from socket import gethostbyaddr, gaierror, herror from django.db import transaction, IntegrityError +from taggit.models import Tag -from patchman.signals import error_message +from patchman.signals import error_message, info_message def update_rdns(host): @@ -62,7 +63,7 @@ def get_or_create_host(report, arch, osvariant, domain): host.osvariant = osvariant host.domain = domain host.lastreport = report.created - host.tags = report.tags + host.tags.set(report.tags.split(','), clear=True) if report.reboot == 'True': host.reboot_required = True else: @@ -73,3 +74,17 @@ def get_or_create_host(report, arch, osvariant, domain): if host: host.check_rdns() return host + + +def clean_tags(): + """ Delete Tags that have no Host + """ + tags = Tag.objects.filter( + host__isnull=True, + ) + tlen = tags.count() + if tlen == 0: + info_message.send(sender=None, text='No orphaned Tags found.') + else: + info_message.send(sender=None, text=f'{tlen} orphaned Tags found.') + tags.delete() diff --git a/sbin/patchman b/sbin/patchman index 9cc6048e..c0911434 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -34,6 +34,7 @@ from errata.utils import mark_errata_security_updates, enrich_errata, \ scan_package_updates_for_affected_packages from errata.tasks import update_errata from hosts.models import Host +from hosts.utils import clean_tags from modules.utils import clean_modules from packages.utils import clean_packages, clean_packageupdates, clean_packagenames from repos.models import Repository @@ -362,6 +363,7 @@ def dbcheck(remove_duplicates=False): clean_repos() clean_modules() clean_packageupdates() + clean_tags() def collect_args(): From ee13ad310bb86789f78f110eb2d82dbd228a5d86 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 21 Oct 2025 22:35:58 -0400 Subject: [PATCH 021/146] fix some flake8-bugbear bugs Signed-off-by: Marcus Furlong --- patchman/urls.py | 2 +- repos/repo_types/gentoo.py | 2 +- repos/repo_types/rpm.py | 2 +- util/__init__.py | 6 +++++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/patchman/urls.py b/patchman/urls.py index 337d6b63..ee786566 100644 --- a/patchman/urls.py +++ b/patchman/urls.py @@ -44,7 +44,7 @@ router.register(r'package', package_views.PackageViewSet) router.register(r'package-update', package_views.PackageUpdateViewSet) router.register(r'cve', security_views.CVEViewSet) -router.register(r'reference', security_views.ReferenceViewSet), +router.register(r'reference', security_views.ReferenceViewSet) router.register(r'erratum', errata_views.ErratumViewSet) router.register(r'repo', repo_views.RepositoryViewSet) router.register(r'mirror', repo_views.MirrorViewSet) diff --git a/repos/repo_types/gentoo.py b/repos/repo_types/gentoo.py index 94df139a..8e4198d9 100644 --- a/repos/repo_types/gentoo.py +++ b/repos/repo_types/gentoo.py @@ -226,7 +226,7 @@ def extract_gentoo_overlay_ebuilds(t): """ Extract ebuilds from a Gentoo overlay tarball """ extracted_ebuilds = {} - for root, dirs, files in os.walk(t): + for root, _, files in os.walk(t): for name in files: if fnmatch(name, '*.ebuild'): package_name = root.replace(t + '/', '') diff --git a/repos/repo_types/rpm.py b/repos/repo_types/rpm.py index aa3354c7..d9501cde 100644 --- a/repos/repo_types/rpm.py +++ b/repos/repo_types/rpm.py @@ -69,7 +69,7 @@ def refresh_rpm_repo_mirrors(repo, errata_only=False): ] ts = get_datetime_now() enabled_mirrors = repo.mirror_set.filter(mirrorlist=False, refresh=True, enabled=True) - for i, mirror in enumerate(enabled_mirrors): + for mirror in enabled_mirrors: res = find_mirror_url(mirror.url, formats) if not res: mirror.fail() diff --git a/util/__init__.py b/util/__init__.py index a56ed3b6..4958dfb0 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -107,10 +107,14 @@ def fetch_content(response, text='', ljust=35): wait=wait_exponential(multiplier=1, min=1, max=10), reraise=False, ) -def get_url(url, headers={}, params={}): +def get_url(url, headers=None, params=None): """ Perform a http GET on a URL. Return None on error. """ response = None + if not headers: + headers = {} + if not params: + params = {} try: debug_message.send(sender=None, text=f'Trying {url} headers:{headers} params:{params}') response = requests.get(url, headers=headers, params=params, stream=True, timeout=30) From 346b02ca119b0197ba434bfdeb45de4448d21a92 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 29 Oct 2025 23:34:45 -0400 Subject: [PATCH 022/146] fix package filter list for errata --- packages/views.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/views.py b/packages/views.py index cd53fa6e..c55a6c72 100644 --- a/packages/views.py +++ b/packages/views.py @@ -62,9 +62,16 @@ def package_list(request): if 'affected_by_errata' in request.GET: affected_by_errata = request.GET['affected_by_errata'] == 'true' if affected_by_errata: - packages = packages.filter(erratum__isnull=False) + packages = packages.filter(affected_by_erratum__isnull=False) else: - packages = packages.filter(erratum__isnull=True) + packages = packages.filter(affected_by_erratum__isnull=True) + + if 'provides_fix_in_erratum' in request.GET: + provides_fix_in_erratum = request.GET['provides_fix_in_erratum'] == 'true' + if provides_fix_in_erratum: + packages = packages.filter(provides_fix_in_erratum__isnull=False) + else: + packages = packages.filter(provides_fix_in_erratum__isnull=True) if 'installed_on_hosts' in request.GET: installed_on_hosts = request.GET['installed_on_hosts'] == 'true' @@ -102,6 +109,8 @@ def package_list(request): filter_list = [] filter_list.append(Filter(request, 'Affected by Errata', 'affected_by_errata', {'true': 'Yes', 'false': 'No'})) + filter_list.append(Filter(request, 'Provides Fix in Errata', 'provides_fix_in_erratum', + {'true': 'Yes', 'false': 'No'})) filter_list.append(Filter(request, 'Installed on Hosts', 'installed_on_hosts', {'true': 'Yes', 'false': 'No'})) filter_list.append(Filter(request, 'Available in Repos', 'available_in_repos', {'true': 'Yes', 'false': 'No'})) filter_list.append(Filter(request, 'Package Type', 'packagetype', Package.PACKAGE_TYPES)) From c4caf0ce60cedcedf27b6f53efe098b6837b92b4 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 29 Oct 2025 23:34:29 -0400 Subject: [PATCH 023/146] add support for zstd compression in deb and rpm repos fixes: #698 Signed-off-by: Marcus Furlong --- debian/control | 2 +- repos/repo_types/deb.py | 7 ++++++- repos/repo_types/rpm.py | 2 ++ requirements.txt | 1 + setup.cfg | 1 + util/__init__.py | 17 +++++++++++++++++ 6 files changed, 28 insertions(+), 2 deletions(-) diff --git a/debian/control b/debian/control index 67026269..7bfe320e 100644 --- a/debian/control +++ b/debian/control @@ -20,7 +20,7 @@ Depends: ${misc:Depends}, python3 (>= 3.11), python3-django (>= 4.2), python3-requests, python3-colorama, python3-magic, python3-humanize, python3-yaml, libapache2-mod-wsgi-py3, apache2, sqlite3, celery, python3-celery, python3-django-celery-beat, redis-server, - python3-redis, python3-git, python3-django-taggit + python3-redis, python3-git, python3-django-taggit, python3-zstandard Suggests: python3-mysqldb, python3-psycopg2, python3-pymemcache, memcached Description: Django-based patch status monitoring tool for linux systems. . diff --git a/repos/repo_types/deb.py b/repos/repo_types/deb.py index 25d8eba7..1d3607c5 100644 --- a/repos/repo_types/deb.py +++ b/repos/repo_types/deb.py @@ -71,7 +71,12 @@ def refresh_deb_repo(repo): are and then fetches and extracts packages from those files. """ - formats = ['Packages.xz', 'Packages.bz2', 'Packages.gz', 'Packages'] + formats = [ + 'Packages.xz', + 'Packages.bz2', + 'Packages.gz', + 'Packages', + ] ts = get_datetime_now() enabled_mirrors = repo.mirror_set.filter(refresh=True, enabled=True) diff --git a/repos/repo_types/rpm.py b/repos/repo_types/rpm.py index d9501cde..93d47007 100644 --- a/repos/repo_types/rpm.py +++ b/repos/repo_types/rpm.py @@ -57,10 +57,12 @@ def refresh_rpm_repo_mirrors(repo, errata_only=False): which type of repo it is, then refreshes the mirrors """ formats = [ + 'repodata/repomd.xml.zst', 'repodata/repomd.xml.xz', 'repodata/repomd.xml.bz2', 'repodata/repomd.xml.gz', 'repodata/repomd.xml', + 'suse/repodata/repomd.xml.zst', 'suse/repodata/repomd.xml.xz', 'suse/repodata/repomd.xml.bz2', 'suse/repodata/repomd.xml.gz', diff --git a/requirements.txt b/requirements.txt index 2f264c9b..788f4241 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,3 +19,4 @@ redis==6.4.0 django-celery-beat==2.7.0 tqdm==4.67.1 cvss==3.4 +zstandard==0.25.0 diff --git a/setup.cfg b/setup.cfg index 7af9ccb0..b1d5ee4e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -25,6 +25,7 @@ requires = /usr/bin/python3 python3-importlib-metadata python3-cvss python3-redis + python3-zstandard redis celery python3-django-celery-beat diff --git a/util/__init__.py b/util/__init__.py index ac6f8f1b..4a3f9caa 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -21,6 +21,11 @@ import zlib import lzma import os +try: + # python 3.14+ - can also remove the dependency at that stage + from compression import zstd +except ImportError: + import zstandard as zstd from datetime import datetime, timezone from enum import Enum from hashlib import md5, sha1, sha256, sha512 @@ -202,6 +207,16 @@ def unxz(contents): error_message.send(sender=None, text='lzma: ' + e) +def unzstd(contents): + """ unzstd contents in memory and return the data + """ + try: + zstddata = zstd.ZstdDecompressor().stream_reader(contents).read() + return zstddata + except zstd.ZstdError as e: + error_message.send(sender=None, text='zstd: ' + e) + + def extract(data, fmt): """ Extract the contents based on mimetype or file ending. Return the unmodified data if neither mimetype nor file ending matches, otherwise @@ -214,6 +229,8 @@ def extract(data, fmt): m = magic.open(magic.MAGIC_MIME) m.load() mime = m.buffer(data).split(';')[0] + if mime == 'application/zstd' or fmt.endswith('zst'): + return unzstd(data) if mime == 'application/x-xz' or fmt.endswith('xz'): return unxz(data) elif mime == 'application/x-bzip2' or fmt.endswith('bz2'): From b90a04bb225144817d4b5ad71874dc3a420231e2 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 29 Oct 2025 23:34:41 -0400 Subject: [PATCH 024/146] simplify logging --- arch/utils.py | 10 ++--- errata/models.py | 12 ++--- errata/sources/distros/arch.py | 5 ++- errata/sources/distros/centos.py | 5 ++- errata/sources/distros/debian.py | 7 +-- errata/sources/distros/rocky.py | 11 ++--- errata/sources/distros/ubuntu.py | 7 +-- errata/sources/repos/yum.py | 5 ++- errata/tasks.py | 2 +- errata/utils.py | 9 ++-- hosts/models.py | 14 +++--- hosts/tasks.py | 4 +- hosts/utils.py | 8 ++-- modules/utils.py | 8 ++-- packages/utils.py | 26 +++++------ patchman/receivers.py | 21 ++++----- patchman/signals.py | 8 ++-- reports/models.py | 10 ++--- reports/tasks.py | 4 +- reports/utils.py | 5 ++- repos/models.py | 18 ++++---- repos/repo_types/arch.py | 11 ++--- repos/repo_types/deb.py | 11 ++--- repos/repo_types/gentoo.py | 15 ++++--- repos/repo_types/rpm.py | 8 ++-- repos/repo_types/yast.py | 5 ++- repos/repo_types/yum.py | 23 +++++----- repos/utils.py | 37 ++++++++-------- sbin/patchman | 76 ++++++++++++++++---------------- security/models.py | 8 ++-- util/__init__.py | 24 +++++----- util/logging.py | 42 ++++++++++++++++++ 32 files changed, 258 insertions(+), 201 deletions(-) create mode 100644 util/logging.py diff --git a/arch/utils.py b/arch/utils.py index 1498fdec..04d0b350 100644 --- a/arch/utils.py +++ b/arch/utils.py @@ -15,7 +15,7 @@ # along with Patchman. If not, see from arch.models import PackageArchitecture, MachineArchitecture -from patchman.signals import info_message +from util.logging import info_message def clean_package_architectures(): @@ -24,9 +24,9 @@ def clean_package_architectures(): parches = PackageArchitecture.objects.filter(package__isnull=True) plen = parches.count() if plen == 0: - info_message.send(sender=None, text='No orphaned PackageArchitectures found.') + info_message(text='No orphaned PackageArchitectures found.') else: - info_message.send(sender=None, text=f'Removing {plen} orphaned PackageArchitectures') + info_message(text=f'Removing {plen} orphaned PackageArchitectures') parches.delete() @@ -39,9 +39,9 @@ def clean_machine_architectures(): ) mlen = marches.count() if mlen == 0: - info_message.send(sender=None, text='No orphaned MachineArchitectures found.') + info_message(text='No orphaned MachineArchitectures found.') else: - info_message.send(sender=None, text=f'Removing {mlen} orphaned MachineArchitectures') + info_message(text=f'Removing {mlen} orphaned MachineArchitectures') marches.delete() diff --git a/errata/models.py b/errata/models.py index b10daf4d..cfc9bd0d 100644 --- a/errata/models.py +++ b/errata/models.py @@ -25,7 +25,7 @@ from errata.managers import ErratumManager from security.models import CVE, Reference from security.utils import get_or_create_cve, get_or_create_reference -from patchman.signals import error_message +from util.logging import error_message from util import get_url @@ -70,7 +70,7 @@ def scan_for_security_updates(self): try: affected_update.save() except IntegrityError as e: - error_message.send(sender=None, text=e) + error_message(text=e) # a version of this update already exists that is # marked as a security update, so delete this one affected_update.delete() @@ -84,7 +84,7 @@ def scan_for_security_updates(self): try: affected_update.save() except IntegrityError as e: - error_message.send(sender=None, text=e) + error_message(text=e) # a version of this update already exists that is # marked as a security update, so delete this one affected_update.delete() @@ -93,7 +93,7 @@ def fetch_osv_dev_data(self): osv_dev_url = f'https://api.osv.dev/v1/vulns/{self.name}' res = get_url(osv_dev_url) if res.status_code == 404: - error_message.send(sender=None, text=f'404 - Skipping {self.name} - {osv_dev_url}') + error_message(text=f'404 - Skipping {self.name} - {osv_dev_url}') return data = res.content osv_dev_json = json.loads(data) @@ -102,7 +102,7 @@ def fetch_osv_dev_data(self): def parse_osv_dev_data(self, osv_dev_json): name = osv_dev_json.get('id') if name != self.name: - error_message.send(sender=None, text=f'Erratum name mismatch - {self.name} != {name}') + error_message(text=f'Erratum name mismatch - {self.name} != {name}') return related = osv_dev_json.get('related') if related: @@ -155,7 +155,7 @@ def add_cve(self, cve_id): """ Add a CVE to an Erratum object """ if not cve_id.startswith('CVE') or not cve_id.split('-')[1].isdigit(): - error_message.send(sender=None, text=f'Not a CVE ID: {cve_id}') + error_message(text=f'Not a CVE ID: {cve_id}') return self.cves.add(get_or_create_cve(cve_id)) diff --git a/errata/sources/distros/arch.py b/errata/sources/distros/arch.py index 40d0dada..87c6c47a 100644 --- a/errata/sources/distros/arch.py +++ b/errata/sources/distros/arch.py @@ -20,7 +20,8 @@ from django.db import connections from operatingsystems.utils import get_or_create_osrelease -from patchman.signals import error_message, pbar_start, pbar_update +from util.logging import error_message +from patchman.signals import pbar_start, pbar_update from packages.models import Package from packages.utils import find_evr, get_matching_packages, get_or_create_package from util import get_url, fetch_content @@ -99,7 +100,7 @@ def process_arch_erratum(advisory, osrelease): add_arch_erratum_references(e, advisory) add_arch_erratum_packages(e, advisory) except Exception as exc: - error_message.send(sender=None, text=exc) + error_message(text=exc) def add_arch_linux_osrelease(): diff --git a/errata/sources/distros/centos.py b/errata/sources/distros/centos.py index eefb2b88..d2722a6b 100644 --- a/errata/sources/distros/centos.py +++ b/errata/sources/distros/centos.py @@ -20,7 +20,8 @@ from operatingsystems.utils import get_or_create_osrelease from packages.models import Package from packages.utils import parse_package_string, get_or_create_package -from patchman.signals import error_message, pbar_start, pbar_update +from util.logging import error_message +from patchman.signals import pbar_start, pbar_update from util import bunzip2, get_url, fetch_content, get_sha1, get_setting_of_type @@ -34,7 +35,7 @@ def update_centos_errata(): if actual_checksum != expected_checksum: e = 'CEFS checksum mismatch, skipping CentOS errata parsing\n' e += f'{actual_checksum} (actual) != {expected_checksum} (expected)' - error_message.send(sender=None, text=e) + error_message(text=e) else: if data: parse_centos_errata(bunzip2(data)) diff --git a/errata/sources/distros/debian.py b/errata/sources/distros/debian.py index 93ae2bd5..1ae919e4 100644 --- a/errata/sources/distros/debian.py +++ b/errata/sources/distros/debian.py @@ -27,7 +27,8 @@ from operatingsystems.utils import get_or_create_osrelease from packages.models import Package from packages.utils import get_or_create_package, find_evr -from patchman.signals import error_message, pbar_start, pbar_update, warning_message +from util.logging import error_message, warning_message +from patchman.signals import pbar_start, pbar_update from util import get_url, fetch_content, get_setting_of_type, extract DSCs = {} @@ -217,7 +218,7 @@ def process_debian_erratum(erratum, accepted_codenames): for package in packages: process_debian_erratum_fixed_packages(e, package) except Exception as exc: - error_message.send(sender=None, text=exc) + error_message(text=exc) def parse_debian_erratum_package(line, accepted_codenames): @@ -249,7 +250,7 @@ def fetch_debian_dsc_package_list(package, version): """ Fetch the package list from a DSC file for a given source package/version """ if not DSCs.get(package) or not DSCs[package].get(version): - warning_message.send(sender=None, text=f'No DSC found for {package} {version}') + warning_message(text=f'No DSC found for {package} {version}') return source_url = DSCs[package][version]['url'] res = get_url(source_url) diff --git a/errata/sources/distros/rocky.py b/errata/sources/distros/rocky.py index 693d7b0c..16d4d12c 100644 --- a/errata/sources/distros/rocky.py +++ b/errata/sources/distros/rocky.py @@ -25,7 +25,8 @@ from packages.models import Package from packages.utils import parse_package_string, get_or_create_package from patchman.signals import pbar_start, pbar_update -from util import get_url, fetch_content, info_message, error_message +from util import get_url, fetch_content +from util.logging import info_message, error_message def update_rocky_errata(concurrent_processing=True): @@ -50,16 +51,16 @@ def check_rocky_errata_endpoint_health(rocky_errata_api_host): health = json.loads(data) if health.get('status') == 'ok': s = f'Rocky Errata API healthcheck OK: {rocky_errata_healthcheck_url}' - info_message.send(sender=None, text=s) + info_message(text=s) return True else: s = f'Rocky Errata API healthcheck FAILED: {rocky_errata_healthcheck_url}' - error_message.send(sender=None, text=s) + error_message(text=s) return False except Exception as e: s = f'Rocky Errata API healthcheck exception occured: {rocky_errata_healthcheck_url}\n' s += str(e) - error_message.send(sender=None, text=s) + error_message(text=s) return False @@ -194,7 +195,7 @@ def process_rocky_erratum(advisory): add_rocky_erratum_oses(e, advisory) add_rocky_erratum_packages(e, advisory) except Exception as exc: - error_message.send(sender=None, text=exc) + error_message(text=exc) def add_rocky_erratum_references(e, advisory): diff --git a/errata/sources/distros/ubuntu.py b/errata/sources/distros/ubuntu.py index 7f50962c..d1ce7cc5 100644 --- a/errata/sources/distros/ubuntu.py +++ b/errata/sources/distros/ubuntu.py @@ -28,7 +28,8 @@ from packages.models import Package from packages.utils import get_or_create_package, parse_package_string, find_evr, get_matching_packages from util import get_url, fetch_content, get_sha256, bunzip2, get_setting_of_type -from patchman.signals import error_message, pbar_start, pbar_update +from util.logging import error_message +from patchman.signals import pbar_start, pbar_update def update_ubuntu_errata(concurrent_processing=False): @@ -45,7 +46,7 @@ def update_ubuntu_errata(concurrent_processing=False): else: e = 'Ubuntu USN DB checksum mismatch, skipping Ubuntu errata parsing\n' e += f'{actual_checksum} (actual) != {expected_checksum} (expected)' - error_message.send(sender=None, text=e) + error_message(text=e) def fetch_ubuntu_usn_db(): @@ -126,7 +127,7 @@ def process_usn(usn_id, advisory, accepted_releases): add_ubuntu_erratum_references(e, usn_id, advisory) add_ubuntu_erratum_packages(e, advisory) except Exception as exc: - error_message.send(sender=None, text=exc) + error_message(text=exc) def add_ubuntu_erratum_osreleases(e, affected_releases, accepted_releases): diff --git a/errata/sources/repos/yum.py b/errata/sources/repos/yum.py index dfeed879..f361d10e 100644 --- a/errata/sources/repos/yum.py +++ b/errata/sources/repos/yum.py @@ -23,7 +23,8 @@ from operatingsystems.utils import get_or_create_osrelease from packages.models import Package from packages.utils import get_or_create_package -from patchman.signals import pbar_start, pbar_update, error_message +from util.logging import error_message +from patchman.signals import pbar_start, pbar_update from security.models import Reference from util import extract, get_url @@ -38,7 +39,7 @@ def extract_updateinfo(data, url, concurrent_processing=True): elen = root.__len__() updates = root.findall('update') except ElementTree.ParseError as e: - error_message.send(sender=None, text=f'Error parsing updateinfo file from {url} : {e}') + error_message(text=f'Error parsing updateinfo file from {url} : {e}') if concurrent_processing: extract_updateinfo_concurrently(updates, elen) else: diff --git a/errata/tasks.py b/errata/tasks.py index fe53b415..f1d6eeee 100644 --- a/errata/tasks.py +++ b/errata/tasks.py @@ -22,7 +22,7 @@ from errata.sources.distros.centos import update_centos_errata from errata.sources.distros.rocky import update_rocky_errata from errata.sources.distros.ubuntu import update_ubuntu_errata -from patchman.signals import error_message +from util.logging import error_message from repos.models import Repository from security.tasks import update_cves, update_cwes from util import get_setting_of_type diff --git a/errata/utils.py b/errata/utils.py index d8099db4..a8d8d424 100644 --- a/errata/utils.py +++ b/errata/utils.py @@ -21,7 +21,8 @@ from util import tz_aware_datetime from errata.models import Erratum from packages.models import PackageUpdate -from patchman.signals import pbar_start, pbar_update, warning_message +from util.logging import warning_message +from patchman.signals import pbar_start, pbar_update def get_or_create_erratum(name, e_type, issue_date, synopsis): @@ -36,16 +37,16 @@ def get_or_create_erratum(name, e_type, issue_date, synopsis): days_delta = abs(e.issue_date.date() - issue_date_tz.date()).days updated = False if e.e_type != e_type: - warning_message.send(sender=None, text=f'Updating {name} type `{e.e_type}` -> `{e_type}`') + warning_message(text=f'Updating {name} type `{e.e_type}` -> `{e_type}`') e.e_type = e_type updated = True if days_delta > 1: text = f'Updating {name} issue date `{e.issue_date.date()}` -> `{issue_date_tz.date()}`' - warning_message.send(sender=None, text=text) + warning_message(text=text) e.issue_date = issue_date_tz updated = True if e.synopsis != synopsis: - warning_message.send(sender=None, text=f'Updating {name} synopsis `{e.synopsis}` -> `{synopsis}`') + warning_message(text=f'Updating {name} synopsis `{e.synopsis}` -> `{synopsis}`') e.synopsis = synopsis updated = True if updated: diff --git a/hosts/models.py b/hosts/models.py index 5b7b3979..650544dc 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -34,7 +34,7 @@ from operatingsystems.models import OSVariant from packages.models import Package, PackageUpdate from packages.utils import get_or_create_package_update -from patchman.signals import info_message +from util.logging import info_message from repos.models import Repository from repos.utils import find_best_repo @@ -90,7 +90,7 @@ def show(self): text += f'Updated at : {self.updated_at}\n' text += f'Host repos : {self.host_repos_only}\n' - info_message.send(sender=None, text=text) + info_message(text=text) def get_absolute_url(self): return reverse('hosts:host_detail', args=[self.hostname]) @@ -114,13 +114,13 @@ def check_rdns(self): if self.check_dns: update_rdns(self) if self.hostname.lower() == self.reversedns.lower(): - info_message.send(sender=None, text='Reverse DNS matches') + info_message(text='Reverse DNS matches') else: text = 'Reverse DNS mismatch found: ' text += f'{self.hostname} != {self.reversedns}' - info_message.send(sender=None, text=text) + info_message(text=text) else: - info_message.send(sender=None, text='Reverse DNS check disabled') + info_message(text='Reverse DNS check disabled') def clean_reports(self): """ Remove all but the last 3 reports for a host @@ -131,7 +131,7 @@ def clean_reports(self): for report in Report.objects.filter(host=self).order_by('-created')[3:]: report.delete() if rlen > 0: - info_message.send(sender=None, text=f'{self.hostname}: removed {rlen} old reports') + info_message(text=f'{self.hostname}: removed {rlen} old reports') def get_host_repo_packages(self): if self.host_repos_only: @@ -163,7 +163,7 @@ def process_update(self, package, highest_package): security = True update = get_or_create_package_update(oldpackage=package, newpackage=highest_package, security=security) self.updates.add(update) - info_message.send(sender=None, text=f'{update}') + info_message(text=f'{update}') return update.id def find_updates(self): diff --git a/hosts/tasks.py b/hosts/tasks.py index 2fdce96f..1643901d 100755 --- a/hosts/tasks.py +++ b/hosts/tasks.py @@ -20,7 +20,7 @@ from hosts.models import Host from util import get_datetime_now -from patchman.signals import info_message +from util.logging import info_message @shared_task @@ -78,4 +78,4 @@ def find_all_host_updates_homogenous(): phost.updated_at = ts phost.save() updated_hosts.append(phost) - info_message.send(sender=None, text=f'Added the same updates to {phost}') + info_message(text=f'Added the same updates to {phost}') diff --git a/hosts/utils.py b/hosts/utils.py index f07d5d1e..d6e663cf 100644 --- a/hosts/utils.py +++ b/hosts/utils.py @@ -20,7 +20,7 @@ from django.db import transaction, IntegrityError from taggit.models import Tag -from patchman.signals import error_message, info_message +from util.logging import error_message, info_message def update_rdns(host): @@ -70,7 +70,7 @@ def get_or_create_host(report, arch, osvariant, domain): host.reboot_required = False host.save() except IntegrityError as e: - error_message.send(sender=None, text=e) + error_message(text=e) if host: host.check_rdns() return host @@ -84,7 +84,7 @@ def clean_tags(): ) tlen = tags.count() if tlen == 0: - info_message.send(sender=None, text='No orphaned Tags found.') + info_message(text='No orphaned Tags found.') else: - info_message.send(sender=None, text=f'{tlen} orphaned Tags found.') + info_message(text=f'{tlen} orphaned Tags found.') tags.delete() diff --git a/modules/utils.py b/modules/utils.py index f56a0f62..05c57c80 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -15,7 +15,7 @@ # along with Patchman. If not, see from django.db import IntegrityError -from patchman.signals import error_message, info_message +from util.logging import error_message, info_message from modules.models import Module from arch.models import PackageArchitecture @@ -37,7 +37,7 @@ def get_or_create_module(name, stream, version, context, arch, repo): repo=repo, ) except IntegrityError as e: - error_message.send(sender=None, text=e) + error_message(text=e) module = Module.objects.get( name=name, stream=stream, @@ -73,7 +73,7 @@ def clean_modules(): ) mlen = modules.count() if mlen == 0: - info_message.send(sender=None, text='No orphaned Modules found.') + info_message(text='No orphaned Modules found.') else: - info_message.send(sender=None, text=f'{mlen} orphaned Modules found.') + info_message(text=f'{mlen} orphaned Modules found.') modules.delete() diff --git a/packages/utils.py b/packages/utils.py index 9b098225..f00f6710 100644 --- a/packages/utils.py +++ b/packages/utils.py @@ -22,7 +22,7 @@ from arch.models import PackageArchitecture from packages.models import PackageName, Package, PackageUpdate, PackageCategory, PackageString -from patchman.signals import error_message, info_message, warning_message +from util.logging import error_message, info_message, warning_message def convert_package_to_packagestring(package): @@ -141,7 +141,7 @@ def parse_redhat_package_string(pkg_str): name, epoch, ver, rel, dist, arch = m.groups() else: e = f'Error parsing package string: "{pkg_str}"' - error_message.send(sender=None, text=e) + error_message(text=e) return if dist: rel = f'{rel}.{dist}' @@ -195,7 +195,7 @@ def get_or_create_package(name, epoch, version, release, arch, p_type): package = packages.first() # TODO this should handle gentoo package categories too, otherwise we may be deleting packages # that should be kept - warning_message.send(sender=None, text=f'Deleting duplicate packages: {packages.exclude(id=package.id)}') + warning_message(text=f'Deleting duplicate packages: {packages.exclude(id=package.id)}') packages.exclude(id=package.id).delete() return package @@ -218,10 +218,10 @@ def get_or_create_package_update(oldpackage, newpackage, security): except MultipleObjectsReturned: e = 'Error: MultipleObjectsReturned when attempting to add package \n' e += f'update with oldpackage={oldpackage} | newpackage={newpackage}:' - error_message.send(sender=None, text=e) + error_message(text=e) updates = PackageUpdate.objects.filter(oldpackage=oldpackage, newpackage=newpackage) for update in updates: - error_message.send(sender=None, text=str(update)) + error_message(text=str(update)) return try: if update: @@ -281,13 +281,13 @@ def clean_packageupdates(): for update in package_updates: if update.host_set.count() == 0: text = f'Removing unused PackageUpdate {update}' - info_message.send(sender=None, text=text) + info_message(text=text) update.delete() for duplicate in package_updates: if update.oldpackage == duplicate.oldpackage and update.newpackage == duplicate.newpackage and \ update.security == duplicate.security and update.id != duplicate.id: text = f'Removing duplicate PackageUpdate: {update}' - info_message.send(sender=None, text=text) + info_message(text=text) for host in duplicate.host_set.all(): host.updates.remove(duplicate) host.updates.add(update) @@ -307,12 +307,12 @@ def clean_packages(remove_duplicates=False): ) plen = packages.count() if plen == 0: - info_message.send(sender=None, text='No orphaned Packages found.') + info_message(text='No orphaned Packages found.') else: - info_message.send(sender=None, text=f'Removing {plen} orphaned Packages') + info_message(text=f'Removing {plen} orphaned Packages') packages.delete() if remove_duplicates: - info_message.send(sender=None, text='Checking for duplicate Packages...') + info_message(text='Checking for duplicate Packages...') for package in Package.objects.all(): potential_duplicates = Package.objects.filter( name=package.name, @@ -326,7 +326,7 @@ def clean_packages(remove_duplicates=False): if potential_duplicates.count() > 1: for dupe in potential_duplicates: if dupe.id != package.id: - info_message.send(sender=None, text=f'Removing duplicate Package {dupe}') + info_message(text=f'Removing duplicate Package {dupe}') dupe.delete() @@ -336,7 +336,7 @@ def clean_packagenames(): names = PackageName.objects.filter(package__isnull=True) nlen = names.count() if nlen == 0: - info_message.send(sender=None, text='No orphaned PackageNames found.') + info_message(text='No orphaned PackageNames found.') else: - info_message.send(sender=None, text=f'Removing {nlen} orphaned PackageNames') + info_message(text=f'Removing {nlen} orphaned PackageNames') names.delete() diff --git a/patchman/receivers.py b/patchman/receivers.py index 5ec32cdd..8d8893ca 100644 --- a/patchman/receivers.py +++ b/patchman/receivers.py @@ -21,7 +21,8 @@ from django.dispatch import receiver from util import create_pbar, update_pbar, get_verbosity -from patchman.signals import pbar_start, pbar_update, info_message, warning_message, error_message, debug_message +from patchman.signals import pbar_start, pbar_update, \ + info_message_s, warning_message_s, error_message_s, debug_message_s from django.conf import settings @@ -47,36 +48,36 @@ def pbar_update_receiver(**kwargs): update_pbar(index) -@receiver(info_message) -def print_info_message(sender=None, **kwargs): - """ Receiver to print an info message, no color +@receiver(info_message_s) +def print_info_message(**kwargs): + """ Receiver to handle an info message, no color """ text = str(kwargs.get('text')) if get_verbosity(): tqdm.write(Style.RESET_ALL + Fore.RESET + text) -@receiver(warning_message) +@receiver(warning_message_s) def print_warning_message(**kwargs): - """ Receiver to print a warning message in yellow text + """ Receiver to handle a warning message, yellow text """ text = str(kwargs.get('text')) if get_verbosity(): tqdm.write(Style.BRIGHT + Fore.YELLOW + text) -@receiver(error_message) +@receiver(error_message_s) def print_error_message(**kwargs): - """ Receiver to print an error message in red text + """ Receiver to handle an error message, red text """ text = str(kwargs.get('text')) if text: tqdm.write(Style.BRIGHT + Fore.RED + text) -@receiver(debug_message) +@receiver(debug_message_s) def print_debug_message(**kwargs): - """ Receiver to print a debug message in blue, if verbose and DEBUG are set + """ Receiver to handle a debug message, blue text if verbose and DEBUG are set """ text = str(kwargs.get('text')) if get_verbosity() and settings.DEBUG and text: diff --git a/patchman/signals.py b/patchman/signals.py index 917a48e4..799b9c98 100644 --- a/patchman/signals.py +++ b/patchman/signals.py @@ -19,7 +19,7 @@ pbar_start = Signal() pbar_update = Signal() -info_message = Signal() -warning_message = Signal() -error_message = Signal() -debug_message = Signal() +info_message_s = Signal() +warning_message_s = Signal() +error_message_s = Signal() +debug_message_s = Signal() diff --git a/reports/models.py b/reports/models.py index 6818ea23..d529804b 100644 --- a/reports/models.py +++ b/reports/models.py @@ -19,7 +19,7 @@ from django.urls import reverse from hosts.utils import get_or_create_host -from patchman.signals import error_message, info_message +from util.logging import error_message, info_message class Report(models.Model): @@ -97,11 +97,11 @@ def process(self, find_updates=True, verbose=False): """ Process a report and extract os, arch, domain, packages, repos etc """ if not self.os or not self.kernel or not self.arch: - error_message.send(sender=None, text=f'Error: OS, kernel or arch not sent with report {self.id}') + error_message(text=f'Error: OS, kernel or arch not sent with report {self.id}') return if self.processed: - info_message.send(sender=None, text=f'Report {self.id} has already been processed') + info_message(text=f'Report {self.id} has already been processed') return from reports.utils import get_arch, get_os, get_domain @@ -111,7 +111,7 @@ def process(self, find_updates=True, verbose=False): host = get_or_create_host(self, arch, osvariant, domain) if verbose: - info_message.send(sender=None, text=f'Processing report {self.id} - {self.host}') + info_message(text=f'Processing report {self.id} - {self.host}') from reports.utils import process_packages, process_repos, process_updates, process_modules process_repos(report=self, host=host) @@ -124,5 +124,5 @@ def process(self, find_updates=True, verbose=False): if find_updates: if verbose: - info_message.send(sender=None, text=f'Finding updates for report {self.id} - {self.host}') + info_message(text=f'Finding updates for report {self.id} - {self.host}') host.find_updates() diff --git a/reports/tasks.py b/reports/tasks.py index db9e4103..fe294e8d 100755 --- a/reports/tasks.py +++ b/reports/tasks.py @@ -21,7 +21,7 @@ from hosts.models import Host from reports.models import Report -from util import info_message +from util.logging import info_message @shared_task(bind=True, autoretry_for=(OperationalError,), retry_backoff=True, retry_kwargs={'max_retries': 5}) @@ -48,5 +48,5 @@ def clean_reports_with_no_hosts(): for report in Report.objects.filter(processed=True): if not Host.objects.filter(hostname=report.host).exists(): text = f'Deleting report {report.id} for Host `{report.host}` as the host no longer exists' - info_message.send(sender=None, text=text) + info_message(text=text) report.delete() diff --git a/reports/utils.py b/reports/utils.py index 641f90df..76b6e09c 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -26,7 +26,8 @@ from operatingsystems.utils import get_or_create_osrelease, get_or_create_osvariant from packages.models import Package, PackageCategory from packages.utils import find_evr, get_or_create_package, get_or_create_package_update, parse_package_string -from patchman.signals import pbar_start, pbar_update, info_message +from util.logging import info_message +from patchman.signals import pbar_start, pbar_update from repos.models import Repository, Mirror, MirrorPackage from repos.utils import get_or_create_repo @@ -93,7 +94,7 @@ def process_packages(report, host): host.packages.add(package) else: if pkg_str[0].lower() != 'gpg-pubkey': - info_message.send(sender=None, text=f'No package returned for {pkg_str}') + info_message(text=f'No package returned for {pkg_str}') pbar_update.send(sender=None, index=i + 1) for package in host.packages.all(): diff --git a/repos/models.py b/repos/models.py index 181a103d..a1db2a93 100644 --- a/repos/models.py +++ b/repos/models.py @@ -26,7 +26,7 @@ from repos.repo_types.rpm import refresh_rpm_repo, refresh_repo_errata from repos.repo_types.arch import refresh_arch_repo from repos.repo_types.gentoo import refresh_gentoo_repo -from patchman.signals import info_message, warning_message, error_message +from util.logging import info_message, warning_message, error_message class Repository(models.Model): @@ -72,7 +72,7 @@ def show(self): text += f'arch: {self.arch}\n' text += 'Mirrors:' - info_message.send(sender=None, text=text) + info_message(text=text) for mirror in self.mirror_set.all(): mirror.show() @@ -99,10 +99,10 @@ def refresh(self, force=False): refresh_gentoo_repo(self) else: text = f'Error: unknown repo type for repo {self.id}: {self.repotype}' - error_message.send(sender=None, text=text) + error_message(text=text) else: text = 'Repo requires authentication, not updating' - warning_message.send(sender=None, text=text) + warning_message(text=text) def refresh_errata(self, force=False): """ Refresh errata metadata for all of a repos mirrors @@ -168,7 +168,7 @@ def show(self): text = f' {self.id} : {self.url}\n' text += ' last updated: ' text += f'{self.timestamp} checksum: {self.packages_checksum}\n' - info_message.send(sender=None, text=text) + info_message(text=text) def fail(self): """ Records that the mirror has failed @@ -178,10 +178,10 @@ def fail(self): """ if self.repo.auth_required: text = f'Mirror requires authentication, not updating - {self.url}' - warning_message.send(sender=None, text=text) + warning_message(text=text) return text = f'No usable mirror found at {self.url}' - error_message.send(sender=None, text=text) + error_message(text=text) default_max_mirror_failures = 28 max_mirror_failures = get_setting_of_type( setting_name='MAX_MIRROR_FAILURES', @@ -191,11 +191,11 @@ def fail(self): self.fail_count = self.fail_count + 1 if max_mirror_failures == -1: text = f'Mirror has failed {self.fail_count} times, but MAX_MIRROR_FAILURES=-1, not disabling refresh' - error_message.send(sender=None, text=text) + error_message(text=text) elif self.fail_count > max_mirror_failures: self.refresh = False text = f'Mirror has failed {self.fail_count} times (max={max_mirror_failures}), disabling refresh' - error_message.send(sender=None, text=text) + error_message(text=text) self.last_access_ok = False self.save() diff --git a/repos/repo_types/arch.py b/repos/repo_types/arch.py index 6e85b153..09719428 100644 --- a/repos/repo_types/arch.py +++ b/repos/repo_types/arch.py @@ -18,7 +18,8 @@ from io import BytesIO from packages.models import PackageString -from patchman.signals import info_message, warning_message, pbar_start, pbar_update +from util.logging import info_message, warning_message +from patchman.signals import pbar_start, pbar_update from repos.utils import get_max_mirrors, fetch_mirror_data, find_mirror_url, update_mirror_packages from util import get_datetime_now, get_checksum, Checksum @@ -34,7 +35,7 @@ def refresh_arch_repo(repo): for i, mirror in enumerate(enabled_mirrors): if i >= max_mirrors: text = f'{max_mirrors} Mirrors already refreshed (max={max_mirrors}), skipping further refreshes' - warning_message.send(sender=None, text=text) + warning_message(text=text) break res = find_mirror_url(mirror.url, [fname]) @@ -42,7 +43,7 @@ def refresh_arch_repo(repo): continue mirror_url = res.url text = f'Found Arch Repo - {mirror_url}' - info_message.send(sender=None, text=text) + info_message(text=text) package_data = fetch_mirror_data( mirror=mirror, @@ -54,7 +55,7 @@ def refresh_arch_repo(repo): computed_checksum = get_checksum(package_data, Checksum.sha1) if mirror.packages_checksum == computed_checksum: text = 'Mirror checksum has not changed, not refreshing Package metadata' - warning_message.send(sender=None, text=text) + warning_message(text=text) continue else: mirror.packages_checksum = computed_checksum @@ -111,5 +112,5 @@ def extract_arch_packages(data): packagetype='A') packages.add(package) else: - info_message.send(sender=None, text='No Packages found in Repo') + info_message(text='No Packages found in Repo') return packages diff --git a/repos/repo_types/deb.py b/repos/repo_types/deb.py index 1d3607c5..c6c26d78 100644 --- a/repos/repo_types/deb.py +++ b/repos/repo_types/deb.py @@ -19,7 +19,8 @@ from debian.debian_support import Version from packages.models import PackageString -from patchman.signals import error_message, pbar_start, pbar_update, info_message, warning_message +from util.logging import error_message, info_message, warning_message +from patchman.signals import pbar_start, pbar_update from repos.utils import fetch_mirror_data, update_mirror_packages, find_mirror_url from util import get_datetime_now, get_checksum, Checksum, extract @@ -30,7 +31,7 @@ def extract_deb_packages(data, url): try: extracted = extract(data, url).decode('utf-8') except UnicodeDecodeError as e: - error_message.send(sender=None, text=f'Skipping {url} : {e}') + error_message(text=f'Skipping {url} : {e}') return package_re = re.compile('^Package: ', re.M) plen = len(package_re.findall(extracted)) @@ -61,7 +62,7 @@ def extract_deb_packages(data, url): packagetype='D') packages.add(package) else: - info_message.send(sender=None, text='No packages found in repo') + info_message(text='No packages found in repo') return packages @@ -86,7 +87,7 @@ def refresh_deb_repo(repo): continue mirror_url = res.url text = f'Found deb Repo - {mirror_url}' - info_message.send(sender=None, text=text) + info_message(text=text) package_data = fetch_mirror_data( mirror=mirror, @@ -98,7 +99,7 @@ def refresh_deb_repo(repo): computed_checksum = get_checksum(package_data, Checksum.sha1) if mirror.packages_checksum == computed_checksum: text = 'Mirror checksum has not changed, not refreshing Package metadata' - warning_message.send(sender=None, text=text) + warning_message(text=text) continue else: mirror.packages_checksum = computed_checksum diff --git a/repos/repo_types/gentoo.py b/repos/repo_types/gentoo.py index 8e4198d9..e440f0d5 100644 --- a/repos/repo_types/gentoo.py +++ b/repos/repo_types/gentoo.py @@ -26,7 +26,8 @@ from packages.models import PackageString from packages.utils import find_evr -from patchman.signals import info_message, warning_message, error_message, pbar_start, pbar_update +from util.logging import info_message, warning_message, error_message +from patchman.signals import pbar_start, pbar_update from repos.utils import add_mirrors_from_urls, mirror_checksum_is_valid, update_mirror_packages from util import extract, get_url, get_datetime_now, get_checksum, Checksum, fetch_content, response_is_valid @@ -56,7 +57,7 @@ def refresh_gentoo_main_repo(repo): if mirror.packages_checksum == checksum: text = 'Mirror checksum has not changed, not refreshing Package metadata' - warning_message.send(sender=None, text=text) + warning_message(text=text) continue res = get_url(mirror.url) @@ -70,7 +71,7 @@ def refresh_gentoo_main_repo(repo): mirror.fail() continue extracted = extract(data, mirror.url) - info_message.send(sender=None, text=f'Found Gentoo Repo - {mirror.url}') + info_message(text=f'Found Gentoo Repo - {mirror.url}') computed_checksum = get_checksum(data, Checksum.md5) if not mirror_checksum_is_valid(computed_checksum, checksum, mirror, 'package'): @@ -165,7 +166,7 @@ def get_gentoo_overlay_mirrors(repo_name): if element.text.startswith('http'): mirrors.append(element.text) except ElementTree.ParseError as e: - error_message.send(sender=None, text=f'Error parsing {gentoo_overlays_url}: {e}') + error_message(text=f'Error parsing {gentoo_overlays_url}: {e}') return mirrors @@ -199,7 +200,7 @@ def get_gentoo_mirror_urls(): if element.get('protocol') == 'http': mirrors[name]['urls'].append(element.text) except ElementTree.ParseError as e: - error_message.send(sender=None, text=f'Error parsing {gentoo_distfiles_url}: {e}') + error_message(text=f'Error parsing {gentoo_distfiles_url}: {e}') mirror_urls = [] # for now, ignore region data and choose MAX_MIRRORS mirrors at random for _, v in mirrors.items(): @@ -274,7 +275,7 @@ def extract_gentoo_packages_from_ebuilds(extracted_ebuilds): ) packages.add(package) plen = len(packages) - info_message.send(sender=None, text=f'Extracted {plen} Packages', plen=plen) + info_message(text=f'Extracted {plen} Packages', plen=plen) return packages @@ -282,7 +283,7 @@ def extract_gentoo_overlay_packages(mirror): """ Extract packages from gentoo overlay repo """ t = tempfile.mkdtemp() - info_message.send(sender=None, text=f'Extracting Gentoo packages from {mirror.url}') + info_message(text=f'Extracting Gentoo packages from {mirror.url}') git.Repo.clone_from(mirror.url, t, depth=1) packages = set() extracted_ebuilds = extract_gentoo_overlay_ebuilds(t) diff --git a/repos/repo_types/rpm.py b/repos/repo_types/rpm.py index 93d47007..51661809 100644 --- a/repos/repo_types/rpm.py +++ b/repos/repo_types/rpm.py @@ -16,7 +16,7 @@ from django.db.models import Q -from patchman.signals import info_message, warning_message +from util.logging import info_message, warning_message from repos.repo_types.yast import refresh_yast_repo from repos.repo_types.yum import refresh_yum_repo from repos.utils import check_for_metalinks, check_for_mirrorlists, find_mirror_url, get_max_mirrors, fetch_mirror_data @@ -47,7 +47,7 @@ def max_mirrors_refreshed(repo, checksum, ts): have_checksum_and_ts = repo.mirror_set.filter(mirrors_q).count() if have_checksum_and_ts >= max_mirrors: text = f'{max_mirrors} Mirrors already have this checksum and timestamp, skipping further refreshes' - warning_message.send(sender=None, text=text) + warning_message(text=text) return True return False @@ -87,11 +87,11 @@ def refresh_rpm_repo_mirrors(repo, errata_only=False): if mirror_url.endswith('content'): text = f'Found yast rpm Repo - {mirror_url}' - info_message.send(sender=None, text=text) + info_message(text=text) refresh_yast_repo(mirror, repo_data) else: text = f'Found yum rpm Repo - {mirror_url}' - info_message.send(sender=None, text=text) + info_message(text=text) refresh_yum_repo(mirror, repo_data, mirror_url, errata_only) if mirror.last_access_ok: mirror.timestamp = ts diff --git a/repos/repo_types/yast.py b/repos/repo_types/yast.py index 0ef54358..bf594040 100644 --- a/repos/repo_types/yast.py +++ b/repos/repo_types/yast.py @@ -17,7 +17,8 @@ import re from packages.models import PackageString -from patchman.signals import pbar_start, pbar_update, info_message +from util.logging import info_message +from patchman.signals import pbar_start, pbar_update from repos.utils import fetch_mirror_data, update_mirror_packages from util import extract @@ -65,5 +66,5 @@ def extract_yast_packages(data): packagetype='R') packages.add(package) else: - info_message.send(sender=None, text='No packages found in repo') + info_message(text='No packages found in repo') return packages diff --git a/repos/repo_types/yum.py b/repos/repo_types/yum.py index 7ac85816..bc0fbc4b 100644 --- a/repos/repo_types/yum.py +++ b/repos/repo_types/yum.py @@ -22,7 +22,8 @@ from errata.sources.repos.yum import extract_updateinfo from packages.models import Package, PackageString from packages.utils import get_or_create_package, parse_package_string -from patchman.signals import warning_message, error_message, pbar_start, pbar_update +from util.logging import warning_message, error_message +from patchman.signals import pbar_start, pbar_update from repos.utils import fetch_mirror_data, update_mirror_packages from util import extract @@ -50,7 +51,7 @@ def get_repomd_url(mirror_url, data, url_type='primary'): checksum = grandchild.text checksum_type = grandchild.attrib.get('type') except ElementTree.ParseError as e: - error_message.send(sender=None, text=(f'Error parsing repomd from {mirror_url}: {e}')) + error_message(text=(f'Error parsing repomd from {mirror_url}: {e}')) if not location: return None, None, None url = str(mirror_url.rsplit('/', 2)[0]) + '/' + location @@ -65,7 +66,7 @@ def extract_module_metadata(data, url, repo): try: modules_yaml = yaml.safe_load_all(extracted) except yaml.YAMLError as e: - error_message.send(sender=None, text=f'Error parsing modules.yaml: {e}') + error_message(text=f'Error parsing modules.yaml: {e}') mlen = len(re.findall(r'---', yaml.dump(extracted.decode()))) pbar_start.send(sender=None, ptext=f'Extracting {mlen} Modules ', plen=mlen) @@ -150,10 +151,10 @@ def extract_yum_packages(data, url): i += 1 else: text = f'Error parsing Package: {name} {epoch} {version} {release} {arch}' - error_message.send(sender=None, text=text) + error_message(text=text) elem.clear() except ElementTree.ParseError as e: - error_message.send(sender=None, text=f'Error parsing yum primary.xml from {url}: {e}') + error_message(text=f'Error parsing yum primary.xml from {url}: {e}') return packages @@ -162,7 +163,7 @@ def refresh_repomd_updateinfo(mirror, data, mirror_url): """ url, checksum, checksum_type = get_repomd_url(mirror_url, data, url_type='updateinfo') if not url: - warning_message.send(sender=None, text=f'No Errata metadata found in {mirror_url}') + warning_message(text=f'No Errata metadata found in {mirror_url}') return data = fetch_mirror_data( mirror=mirror, @@ -177,7 +178,7 @@ def refresh_repomd_updateinfo(mirror, data, mirror_url): if mirror.errata_checksum and mirror.errata_checksum == checksum: text = 'Mirror Errata checksum has not changed, skipping Erratum refresh' - warning_message.send(sender=None, text=text) + warning_message(text=text) return else: mirror.errata_checksum = checksum @@ -191,7 +192,7 @@ def refresh_repomd_modules(mirror, data, mirror_url): """ url, checksum, checksum_type = get_repomd_url(mirror_url, data, url_type='modules') if not url: - warning_message.send(sender=None, text=f'No Module metadata found in {mirror_url}') + warning_message(text=f'No Module metadata found in {mirror_url}') return data = fetch_mirror_data( mirror=mirror, @@ -206,7 +207,7 @@ def refresh_repomd_modules(mirror, data, mirror_url): if mirror.modules_checksum and mirror.modules_checksum == checksum: text = 'Mirror Modules checksum has not changed, skipping Module refresh' - warning_message.send(sender=None, text=text) + warning_message(text=text) return else: mirror.modules_checksum = checksum @@ -220,7 +221,7 @@ def refresh_repomd_primary(mirror, data, mirror_url): """ url, checksum, checksum_type = get_repomd_url(mirror_url, data, url_type='primary') if not url: - warning_message.send(sender=None, text=f'No Package metadata found in {mirror_url}') + warning_message(text=f'No Package metadata found in {mirror_url}') data = fetch_mirror_data( mirror=mirror, url=url, @@ -234,7 +235,7 @@ def refresh_repomd_primary(mirror, data, mirror_url): if mirror.packages_checksum and mirror.packages_checksum == checksum: text = 'Mirror Packages checksum has not changed, skipping Package refresh' - warning_message.send(sender=None, text=text) + warning_message(text=text) return else: mirror.packages_checksum = checksum diff --git a/repos/utils.py b/repos/utils.py index 49b5d07f..c11c41ad 100644 --- a/repos/utils.py +++ b/repos/utils.py @@ -26,7 +26,8 @@ from packages.models import Package from packages.utils import convert_package_to_packagestring, convert_packagestring_to_package from util import get_url, fetch_content, response_is_valid, extract, get_checksum, Checksum, get_setting_of_type -from patchman.signals import info_message, warning_message, error_message, debug_message, pbar_start, pbar_update +from util.logging import info_message, warning_message, error_message, debug_message +from patchman.signals import pbar_start, pbar_update def get_or_create_repo(r_name, r_arch, r_type, r_id=None): @@ -77,7 +78,7 @@ def update_mirror_packages(mirror, packages): package = convert_packagestring_to_package(strpackage) mirror_package, c = MirrorPackage.objects.get_or_create(mirror=mirror, package=package) except Package.MultipleObjectsReturned: - error_message.send(sender=None, text=f'Duplicate Package found in {mirror}: {strpackage}') + error_message(text=f'Duplicate Package found in {mirror}: {strpackage}') def find_mirror_url(stored_mirror_url, formats): @@ -89,7 +90,7 @@ def find_mirror_url(stored_mirror_url, formats): if mirror_url.endswith(f): mirror_url = mirror_url[:-len(f)] mirror_url = f"{mirror_url.rstrip('/')}/{fmt}" - debug_message.send(sender=None, text=f'Checking for Mirror at {mirror_url}') + debug_message(text=f'Checking for Mirror at {mirror_url}') try: res = get_url(mirror_url) except RetryError: @@ -133,7 +134,7 @@ def get_metalink_urls(url): if greatgreatgrandchild.attrib.get('protocol') in ['https', 'http']: metalink_urls.append(greatgreatgrandchild.text) except ElementTree.ParseError as e: - error_message.send(sender=None, text=f'Error parsing metalink {url}: {e}') + error_message(text=f'Error parsing metalink {url}: {e}') return metalink_urls @@ -152,12 +153,12 @@ def get_mirrorlist_urls(url): return mirror_urls = re.findall(r'^http[s]*://.*$|^ftp://.*$', data.decode('utf-8'), re.MULTILINE) if mirror_urls: - debug_message.send(sender=None, text=f'Found mirrorlist: {url}') + debug_message(text=f'Found mirrorlist: {url}') return mirror_urls else: - debug_message.send(sender=None, text=f'Not a mirrorlist: {url}') + debug_message(text=f'Not a mirrorlist: {url}') except Exception as e: - error_message.send(sender=None, text=f'Error attempting to parse a mirrorlist: {e} {url}') + error_message(text=f'Error attempting to parse a mirrorlist: {e} {url}') def add_mirrors_from_urls(repo, mirror_urls): @@ -172,7 +173,7 @@ def add_mirrors_from_urls(repo, mirror_urls): existing = repo.mirror_set.filter(q).count() if existing >= max_mirrors: text = f'{existing} Mirrors already exist (max={max_mirrors}), not adding more' - warning_message.send(sender=None, text=text) + warning_message(text=text) break from repos.models import Mirror # FIXME: maybe we should store the mirrorlist url with full path to repomd.xml? @@ -180,7 +181,7 @@ def add_mirrors_from_urls(repo, mirror_urls): m, c = Mirror.objects.get_or_create(repo=repo, url=mirror_url.rstrip('/').replace('repodata/repomd.xml', '')) if c: text = f'Added Mirror - {mirror_url}' - info_message.send(sender=None, text=text) + info_message(text=text) def check_for_mirrorlists(repo): @@ -193,7 +194,7 @@ def check_for_mirrorlists(repo): mirror.mirrorlist = True mirror.last_access_ok = True mirror.save() - info_message.send(sender=None, text=f'Found mirrorlist - {mirror.url}') + info_message(text=f'Found mirrorlist - {mirror.url}') add_mirrors_from_urls(repo, mirror_urls) @@ -210,7 +211,7 @@ def check_for_metalinks(repo): mirror.mirrorlist = True mirror.last_access_ok = True mirror.save() - info_message.send(sender=None, text=f'Found metalink - {mirror.url}') + info_message(text=f'Found metalink - {mirror.url}') add_mirrors_from_urls(repo, mirror_urls) @@ -249,9 +250,9 @@ def mirror_checksum_is_valid(computed, provided, mirror, metadata_type): """ if not computed or computed != provided: text = f'Checksum failed for mirror {mirror.id}, not refreshing {metadata_type} metadata' - error_message.send(sender=None, text=text) + error_message(text=text) text = f'Found checksum: {computed}\nExpected checksum: {provided}' - error_message.send(sender=None, text=text) + error_message(text=text) mirror.last_access_ok = False mirror.fail() return False @@ -296,9 +297,9 @@ def clean_repos(): repos = Repository.objects.filter(mirror__isnull=True) rlen = repos.count() if rlen == 0: - info_message.send(sender=None, text='No Repositories with zero Mirrors found.') + info_message(text='No Repositories with zero Mirrors found.') else: - info_message.send(sender=None, text=f'Removing {rlen} empty Repositories.') + info_message(text=f'Removing {rlen} empty Repositories.') repos.delete() @@ -309,13 +310,13 @@ def remove_mirror_trailing_slashes(): mirrors = Mirror.objects.filter(url__endswith='/') mlen = mirrors.count() if mlen == 0: - info_message.send(sender=None, text='No Mirrors with trailing slashes found.') + info_message(text='No Mirrors with trailing slashes found.') else: - info_message.send(sender=None, text=f'Removing trailing slashes from {mlen} Mirrors.') + info_message(text=f'Removing trailing slashes from {mlen} Mirrors.') for mirror in mirrors: mirror.url = mirror.url.rstrip('/') try: mirror.save() except IntegrityError: - warning_message.send(sender=None, text=f'Deleting duplicate Mirror {mirror.id}: {mirror.url}') + warning_message(text=f'Deleting duplicate Mirror {mirror.id}: {mirror.url}') mirror.delete() diff --git a/sbin/patchman b/sbin/patchman index c0911434..47d89b06 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -43,7 +43,7 @@ from reports.models import Report from reports.tasks import clean_reports_with_no_hosts from security.utils import update_cves, update_cwes from util import set_verbosity, get_datetime_now -from patchman.signals import info_message +from util.logging import info_message def get_host(host=None, action='Performing action'): @@ -64,7 +64,7 @@ def get_host(host=None, action='Performing action'): matches = Host.objects.filter(hostname__startswith=host).count() text = f'{matches} Hosts match hostname "{host}"' - info_message.send(sender=None, text=text) + info_message(text=text) return host_obj @@ -84,7 +84,7 @@ def get_hosts(hosts=None, action='Performing action'): host_objs.append(host_obj) else: text = f'{action} for all Hosts\n' - info_message.send(sender=None, text=text) + info_message(text=text) host_objs = Host.objects.all() return host_objs @@ -107,7 +107,7 @@ def get_repos(repo=None, action='Performing action', only_enabled=False): else: repos = Repository.objects.all() - info_message.send(sender=None, text=text) + info_message(text=text) return repos @@ -118,9 +118,9 @@ def refresh_repos(repo=None, force=False): repos = get_repos(repo, 'Refreshing metadata', True) for repo in repos: text = f'Repository {repo.id} : {repo}' - info_message.send(sender=None, text=text) + info_message(text=text) repo.refresh(force) - info_message.send(sender=None, text='') + info_message(text='') def list_repos(repos=None): @@ -161,10 +161,10 @@ def host_updates_alt(host=None): hosts = get_hosts(host, 'Finding updates') ts = get_datetime_now() for host in hosts: - info_message.send(sender=None, text=str(host)) + info_message(text=str(host)) if host not in updated_hosts: host.find_updates() - info_message.send(sender=None, text='') + info_message(text='') host.updated_at = ts host.save() @@ -200,10 +200,10 @@ def host_updates_alt(host=None): phost.save() updated_hosts.append(phost) text = f'Added the same updates to {phost}' - info_message.send(sender=None, text=text) + info_message(text=text) else: text = 'Updates already added in this run' - info_message.send(sender=None, text=text) + info_message(text=text) def host_updates(host=None): @@ -211,9 +211,9 @@ def host_updates(host=None): """ hosts = get_hosts(host, 'Finding updates') for host in hosts: - info_message.send(sender=None, text=str(host)) + info_message(text=str(host)) host.find_updates() - info_message.send(sender=None, text='') + info_message(text='') def diff_hosts(hosts): @@ -236,47 +236,47 @@ def diff_hosts(hosts): repo_diff_AB = reposA.difference(reposB) repo_diff_BA = reposB.difference(reposA) - info_message.send(sender=None, text=f'+ {hostA.hostname}') - info_message.send(sender=None, text=f'- {hostB.hostname}') + info_message(text=f'+ {hostA.hostname}') + info_message(text=f'- {hostB.hostname}') if hostA.os != hostB.os: - info_message.send(sender=None, text='\nOperating Systems') - info_message.send(sender=None, text=f'+ {hostA.os}') - info_message.send(sender=None, text=f'- {hostB.os}') + info_message(text='\nOperating Systems') + info_message(text=f'+ {hostA.os}') + info_message(text=f'- {hostB.os}') else: - info_message.send(sender=None, text='\nNo OS differences') + info_message(text='\nNo OS differences') if hostA.arch != hostB.arch: - info_message.send(sender=None, text='\nArchitecture') - info_message.send(sender=None, text=f'+ {hostA.arch}') - info_message.send(sender=None, text=f'- {hostB.arch}') + info_message(text='\nArchitecture') + info_message(text=f'+ {hostA.arch}') + info_message(text=f'- {hostB.arch}') else: - info_message.send(sender=None, text='\nNo Architecture differences') + info_message(text='\nNo Architecture differences') if hostA.kernel != hostB.kernel: - info_message.send(sender=None, text='\nKernels') - info_message.send(sender=None, text=f'+ {hostA.kernel}') - info_message.send(sender=None, text=f'- {hostB.kernel}') + info_message(text='\nKernels') + info_message(text=f'+ {hostA.kernel}') + info_message(text=f'- {hostB.kernel}') else: - info_message.send(sender=None, text='\nNo Kernel differences') + info_message(text='\nNo Kernel differences') if len(package_diff_AB) != 0 or len(package_diff_BA) != 0: - info_message.send(sender=None, text='\nPackages') + info_message(text='\nPackages') for package in package_diff_AB: - info_message.send(sender=None, text=f'+ {package}') + info_message(text=f'+ {package}') for package in package_diff_BA: - info_message.send(sender=None, text=f'- {package}') + info_message(text=f'- {package}') else: - info_message.send(sender=None, text='\nNo Package differences') + info_message(text='\nNo Package differences') if len(repo_diff_AB) != 0 or len(repo_diff_BA) != 0: - info_message.send(sender=None, text='\nRepositories') + info_message(text='\nRepositories') for repo in repo_diff_AB: - info_message.send(sender=None, text=f'+ {repo}') + info_message(text=f'+ {repo}') for repo in repo_diff_BA: - info_message.send(sender=None, text=f'- {repo}') + info_message(text=f'- {repo}') else: - info_message.send(sender=None, text='\nNo Repo differences') + info_message(text='\nNo Repo differences') def delete_hosts(hosts=None): @@ -286,7 +286,7 @@ def delete_hosts(hosts=None): matching_hosts = get_hosts(hosts) for host in matching_hosts: text = f'Deleting host: {host.hostname}:' - info_message.send(sender=None, text=text) + info_message(text=text) host.delete() @@ -300,7 +300,7 @@ def toggle_host_hro(hosts=None, host_repos_only=True): if hosts: matching_hosts = get_hosts(hosts, f'{toggle} host_repos_only') for host in matching_hosts: - info_message.send(sender=None, text=str(host)) + info_message(text=str(host)) host.host_repos_only = host_repos_only host.save() @@ -315,7 +315,7 @@ def toggle_host_check_dns(hosts=None, check_dns=True): if hosts: matching_hosts = get_hosts(hosts, f'{toggle} check_dns') for host in matching_hosts: - info_message.send(sender=None, text=str(host)) + info_message(text=str(host)) host.check_dns = check_dns host.save() @@ -347,7 +347,7 @@ def process_reports(host=None, force=False): text = 'Processing Reports for all Hosts' reports = Report.objects.filter(processed=force).order_by('created') - info_message.send(sender=None, text=text) + info_message(text=text) for report in reports: report.process(find_updates=False) diff --git a/security/models.py b/security/models.py index 9c097eed..7f674a0b 100644 --- a/security/models.py +++ b/security/models.py @@ -152,7 +152,7 @@ def fetch_mitre_cve_data(self): mitre_cve_url = f'https://cveawg.mitre.org/api/cve/{self.cve_id}' res = get_url(mitre_cve_url) if res.status_code == 404: - error_message.send(sender=None, text=f'404 - Skipping {self.cve_id} - {mitre_cve_url}') + error_message(text=f'404 - Skipping {self.cve_id} - {mitre_cve_url}') return data = fetch_content(res, f'Fetching {self.cve_id} MITRE data') cve_json = json.loads(data) @@ -162,7 +162,7 @@ def fetch_osv_dev_cve_data(self): osv_dev_cve_url = f'https://api.osv.dev/v1/vulns/{self.cve_id}' res = get_url(osv_dev_cve_url) if res.status_code == 404: - error_message.send(sender=None, text=f'404 - Skipping {self.cve_id} - {osv_dev_cve_url}') + error_message(text=f'404 - Skipping {self.cve_id} - {osv_dev_cve_url}') return data = fetch_content(res, f'Fetching {self.cve_id} OSV data') cve_json = json.loads(data) @@ -186,7 +186,7 @@ def fetch_nist_cve_data(self): res = get_url(nist_cve_url) data = fetch_content(res, f'Fetching {self.cve_id} NIST data') if res.status_code == 404: - error_message.send(sender=None, text=f'404 - Skipping {self.cve_id} - {nist_cve_url}') + error_message(text=f'404 - Skipping {self.cve_id} - {nist_cve_url}') cve_json = json.loads(data) self.parse_nist_cve_data(cve_json) @@ -197,7 +197,7 @@ def parse_nist_cve_data(self, cve_json): cve = vulnerability.get('cve') cve_id = cve.get('id') if cve_id != self.cve_id: - error_message.send(sender=None, text=f'CVE ID mismatch - {self.cve_id} != {cve_id}') + error_message(text=f'CVE ID mismatch - {self.cve_id} != {cve_id}') return metrics = cve.get('metrics') for metric, score_data in metrics.items(): diff --git a/util/__init__.py b/util/__init__.py index 4a3f9caa..c3dfd6bd 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -38,7 +38,7 @@ from django.utils.dateparse import parse_datetime from django.conf import settings -from patchman.signals import error_message, info_message, debug_message +from util.logging import error_message, info_message, debug_message pbar = None verbose = None @@ -109,7 +109,7 @@ def fetch_content(response, text='', ljust=35): data += chunk return data else: - info_message.send(sender=None, text=text) + info_message(text=text) return response.content @@ -128,16 +128,16 @@ def get_url(url, headers=None, params=None): if not params: params = {} try: - debug_message.send(sender=None, text=f'Trying {url} headers:{headers} params:{params}') + debug_message(text=f'Trying {url} headers:{headers} params:{params}') response = requests.get(url, headers=headers, params=params, stream=True, proxies=proxies, timeout=30) - debug_message.send(sender=None, text=f'{response.status_code}: {response.headers}') + debug_message(text=f'{response.status_code}: {response.headers}') if response.status_code in [403, 404]: return response response.raise_for_status() except requests.exceptions.TooManyRedirects: - error_message.send(sender=None, text=f'Too many redirects - {url}') + error_message(text=f'Too many redirects - {url}') except ConnectionError: - error_message.send(sender=None, text=f'Connection error - {url}') + error_message(text=f'Connection error - {url}') return response @@ -180,7 +180,7 @@ def gunzip(contents): wbits = zlib.MAX_WBITS | 32 return zlib.decompress(contents, wbits) except zlib.error as e: - error_message.send(sender=None, text='gunzip: ' + str(e)) + error_message(text='gunzip: ' + str(e)) def bunzip2(contents): @@ -191,10 +191,10 @@ def bunzip2(contents): return bzip2data except IOError as e: if e == 'invalid data stream': - error_message.send(sender=None, text='bunzip2: ' + e) + error_message(text='bunzip2: ' + e) except ValueError as e: if e == "couldn't find end of stream": - error_message.send(sender=None, text='bunzip2: ' + e) + error_message(text='bunzip2: ' + e) def unxz(contents): @@ -204,7 +204,7 @@ def unxz(contents): xzdata = lzma.decompress(contents) return xzdata except lzma.LZMAError as e: - error_message.send(sender=None, text='lzma: ' + e) + error_message(text='lzma: ' + e) def unzstd(contents): @@ -214,7 +214,7 @@ def unzstd(contents): zstddata = zstd.ZstdDecompressor().stream_reader(contents).read() return zstddata except zstd.ZstdError as e: - error_message.send(sender=None, text='zstd: ' + e) + error_message(text=f'zstd: {e}') def extract(data, fmt): @@ -253,7 +253,7 @@ def get_checksum(data, checksum_type): checksum = get_md5(data) else: text = f'Unknown checksum type: {checksum_type}' - error_message.send(sender=None, text=text) + error_message(text=text) return checksum diff --git a/util/logging.py b/util/logging.py new file mode 100644 index 00000000..dd79d296 --- /dev/null +++ b/util/logging.py @@ -0,0 +1,42 @@ +# Copyright 2025 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 datetime import datetime + +from patchman.signals import info_message_s +from patchman.signals import warning_message_s +from patchman.signals import error_message_s +from patchman.signals import debug_message_s + + +def info_message(text): + ts = datetime.now() + info_message_s.send(sender=None, text=text, ts=ts) + + +def warning_message(text): + ts = datetime.now() + warning_message_s.send(sender=None, text=text, ts=ts) + + +def debug_message(text): + ts = datetime.now() + debug_message_s.send(sender=None, text=text, ts=ts) + + +def error_message(text): + ts = datetime.now() + error_message_s.send(sender=None, text=text, ts=ts) From f46b57b24f18e79aa374334b95e38d663ac5328e Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 29 Oct 2025 23:34:50 -0400 Subject: [PATCH 025/146] use redis for caching and use locks for tasks --- errata/tasks.py | 68 ++++++++++++++++++++-------------- etc/patchman/local_settings.py | 16 +++----- reports/tasks.py | 15 +++++++- repos/tasks.py | 17 ++++++++- security/tasks.py | 33 ++++++++++++++--- 5 files changed, 101 insertions(+), 48 deletions(-) diff --git a/errata/tasks.py b/errata/tasks.py index f1d6eeee..d9aaf3d4 100644 --- a/errata/tasks.py +++ b/errata/tasks.py @@ -16,13 +16,15 @@ from celery import shared_task +from django.core.cache import cache + from errata.sources.distros.arch import update_arch_errata from errata.sources.distros.alma import update_alma_errata from errata.sources.distros.debian import update_debian_errata from errata.sources.distros.centos import update_centos_errata from errata.sources.distros.rocky import update_rocky_errata from errata.sources.distros.ubuntu import update_ubuntu_errata -from util.logging import error_message +from util.logging import error_message, warning_message from repos.models import Repository from security.tasks import update_cves, update_cwes from util import get_setting_of_type @@ -44,34 +46,44 @@ def update_yum_repo_errata(repo_id=None, force=False): def update_errata(erratum_type=None, force=False, repo=None): """ Update all distros errata """ - errata_os_updates = [] - erratum_types = ['yum', 'rocky', 'alma', 'arch', 'ubuntu', 'debian', 'centos'] - erratum_type_defaults = ['yum', 'rocky', 'alma', 'arch', 'ubuntu', 'debian'] - if erratum_type: - if erratum_type not in erratum_types: - error_message.send(sender=None, text=f'Erratum type `{erratum_type}` not in {erratum_types}') - else: - errata_os_updates = erratum_type + lock_key = 'update_errata_lock' + # lock will expire after 48 hours + lock_expire = 60 * 60 * 48 + + if cache.add(lock_key, 'true', lock_expire): + try: + errata_os_updates = [] + erratum_types = ['yum', 'rocky', 'alma', 'arch', 'ubuntu', 'debian', 'centos'] + erratum_type_defaults = ['yum', 'rocky', 'alma', 'arch', 'ubuntu', 'debian'] + if erratum_type: + if erratum_type not in erratum_types: + error_message(text=f'Erratum type `{erratum_type}` not in {erratum_types}') + else: + errata_os_updates = erratum_type + else: + errata_os_updates = get_setting_of_type( + setting_name='ERRATA_OS_UPDATES', + setting_type=list, + default=erratum_type_defaults, + ) + if 'yum' in errata_os_updates: + update_yum_repo_errata(repo_id=repo, force=force) + if 'arch' in errata_os_updates: + update_arch_errata() + if 'alma' in errata_os_updates: + update_alma_errata() + if 'rocky' in errata_os_updates: + update_rocky_errata() + if 'debian' in errata_os_updates: + update_debian_errata() + if 'ubuntu' in errata_os_updates: + update_ubuntu_errata() + if 'centos' in errata_os_updates: + update_centos_errata() + finally: + cache.delete(lock_key) else: - errata_os_updates = get_setting_of_type( - setting_name='ERRATA_OS_UPDATES', - setting_type=list, - default=erratum_type_defaults, - ) - if 'yum' in errata_os_updates: - update_yum_repo_errata(repo_id=repo, force=force) - if 'arch' in errata_os_updates: - update_arch_errata() - if 'alma' in errata_os_updates: - update_alma_errata() - if 'rocky' in errata_os_updates: - update_rocky_errata() - if 'debian' in errata_os_updates: - update_debian_errata() - if 'ubuntu' in errata_os_updates: - update_ubuntu_errata() - if 'centos' in errata_os_updates: - update_centos_errata() + warning_message('Already updating Errata, skipping task.') @shared_task diff --git a/etc/patchman/local_settings.py b/etc/patchman/local_settings.py index 181c4c4d..9e7ca21b 100644 --- a/etc/patchman/local_settings.py +++ b/etc/patchman/local_settings.py @@ -44,22 +44,16 @@ # Whether to run patchman under the gunicorn web server RUN_GUNICORN = False +# Set the default timeout to e.g. 30 seconds to enable UI caching +# Note that the UI results may be out of date for this amount of time CACHES = { 'default': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + 'BACKEND': 'django.core.cache.backends.redis.RedisCache', + 'LOCATION': 'redis://127.0.0.1:6379', + 'TIMEOUT': 0, } } -# Uncomment to enable redis caching for e.g. 30 seconds -# Note that the UI results may be out of date for this amount of time -# CACHES = { -# 'default': { -# 'BACKEND': 'django.core.cache.backends.redis.RedisCache', -# 'LOCATION': 'redis://127.0.0.1:6379', -# 'TIMEOUT': 30, -# } -# } - from datetime import timedelta # noqa from celery.schedules import crontab # noqa CELERY_BEAT_SCHEDULE = { diff --git a/reports/tasks.py b/reports/tasks.py index fe294e8d..d2a47e8f 100755 --- a/reports/tasks.py +++ b/reports/tasks.py @@ -17,11 +17,12 @@ from celery import shared_task +from django.core.cache import cache from django.db.utils import OperationalError from hosts.models import Host from reports.models import Report -from util.logging import info_message +from util.logging import info_message, warning_message @shared_task(bind=True, autoretry_for=(OperationalError,), retry_backoff=True, retry_kwargs={'max_retries': 5}) @@ -29,7 +30,17 @@ def process_report(self, report_id): """ Task to process a single report """ report = Report.objects.get(id=report_id) - report.process() + lock_key = f'process_report_lock_{report_id}' + # lock will expire after 1 hour + lock_expire = 60 * 60 + + if cache.add(lock_key, 'true', lock_expire): + try: + report.process() + finally: + cache.delete(lock_key) + else: + warning_message(f'Already processing report {report_id}, skipping task.') @shared_task diff --git a/repos/tasks.py b/repos/tasks.py index 39098fa8..436da82a 100644 --- a/repos/tasks.py +++ b/repos/tasks.py @@ -16,7 +16,10 @@ from celery import shared_task +from django.core.cache import cache + from repos.models import Repository +from util.logging import warning_message @shared_task @@ -32,5 +35,15 @@ def refresh_repos(force=False): """ Refresh metadata for all enabled repos """ repos = Repository.objects.filter(enabled=True) - for repo in repos: - refresh_repo.delay(repo.id, force) + lock_key = 'refresh_repos_lock' + # lock will expire after 1 day + lock_expire = 60 * 60 * 24 + + if cache.add(lock_key, 'true', lock_expire): + try: + for repo in repos: + refresh_repo.delay(repo.id, force) + finally: + cache.delete(lock_key) + else: + warning_message('Already refreshing repos, skipping task.') diff --git a/security/tasks.py b/security/tasks.py index a04bb1c8..ce60df83 100644 --- a/security/tasks.py +++ b/security/tasks.py @@ -16,7 +16,10 @@ from celery import shared_task +from django.core.cache import cache + from security.models import CVE, CWE +from util.logging import warning_message @shared_task @@ -31,8 +34,18 @@ def update_cve(cve_id): def update_cves(): """ Task to update all CVEs """ - for cve in CVE.objects.all(): - update_cve.delay(cve.id) + lock_key = 'update_cves_lock' + # lock will expire after 1 week + lock_expire = 60 * 60 * 168 + + if cache.add(lock_key, 'true', lock_expire): + try: + for cve in CVE.objects.all(): + update_cve.delay(cve.id) + finally: + cache.delete(lock_key) + else: + warning_message('Already updating CVEs, skipping task.') @shared_task @@ -45,7 +58,17 @@ def update_cwe(cwe_id): @shared_task def update_cwes(): - """ Task to update all CWEa + """ Task to update all CWEs """ - for cwe in CWE.objects.all(): - update_cwe.delay(cwe.id) + lock_key = 'update_cwes_lock' + # lock will expire after 1 week + lock_expire = 60 * 60 * 168 + + if cache.add(lock_key, 'true', lock_expire): + try: + for cwe in CWE.objects.all(): + update_cwe.delay(cwe.id) + finally: + cache.delete(lock_key) + else: + warning_message('Already updating CWEs, skipping task.') From 652333f638afdb246fa63869a4aaa00043dbebe4 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 18 Apr 2025 18:38:04 -0400 Subject: [PATCH 026/146] add errata source options to config file --- errata/sources/distros/debian.py | 2 +- errata/sources/distros/ubuntu.py | 2 +- etc/patchman/local_settings.py | 12 ++++++++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/errata/sources/distros/debian.py b/errata/sources/distros/debian.py index 1ae919e4..ece3754d 100644 --- a/errata/sources/distros/debian.py +++ b/errata/sources/distros/debian.py @@ -264,7 +264,7 @@ def get_accepted_debian_codenames(): """ Get acceptable Debian OS codenames Can be overridden by specifying DEBIAN_CODENAMES in settings """ - default_codenames = ['bookworm', 'bullseye'] + default_codenames = ['bookworm', 'trixie'] accepted_codenames = get_setting_of_type( setting_name='DEBIAN_CODENAMES', setting_type=list, diff --git a/errata/sources/distros/ubuntu.py b/errata/sources/distros/ubuntu.py index d1ce7cc5..6fafb40a 100644 --- a/errata/sources/distros/ubuntu.py +++ b/errata/sources/distros/ubuntu.py @@ -203,7 +203,7 @@ def get_accepted_ubuntu_codenames(): """ Get acceptable Ubuntu OS codenames Can be overridden by specifying UBUNTU_CODENAMES in settings """ - default_codenames = ['focal', 'jammy', 'noble'] + default_codenames = ['jammy', 'noble'] accepted_codenames = get_setting_of_type( setting_name='UBUNTU_CODENAMES', setting_type=list, diff --git a/etc/patchman/local_settings.py b/etc/patchman/local_settings.py index 181c4c4d..ab93c89d 100644 --- a/etc/patchman/local_settings.py +++ b/etc/patchman/local_settings.py @@ -41,6 +41,18 @@ # Number of days to wait before raising that a host has not reported DAYS_WITHOUT_REPORT = 14 +# list of errata sources to update, remove unwanted ones to improve performance +ERRATA_OS_UPDATES = ['yum', 'rocky', 'alma', 'arch', 'ubuntu', 'debian'] + +# list of Alma Linux releases to update +ALMA_RELEASES = [8, 9, 10] + +# list of Debian Linux releases to update +DEBIAN_CODENAMES = ['bookworm', 'trixie'] + +# list of Ubuntu Linux releases to update +UBUNTU_CODENAMES = ['jammy', 'noble'] + # Whether to run patchman under the gunicorn web server RUN_GUNICORN = False From 78d76b55546d667cae0a68dcbda4f92cd0337993 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 14 May 2025 22:39:29 -0400 Subject: [PATCH 027/146] remove daily cronjob in favour of patchman-celery --- debian/python3-patchman.cron.daily | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 debian/python3-patchman.cron.daily diff --git a/debian/python3-patchman.cron.daily b/debian/python3-patchman.cron.daily deleted file mode 100644 index d4752f75..00000000 --- a/debian/python3-patchman.cron.daily +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh - -/usr/bin/patchman -a -q From 0699d4bed5835dc51705faf0621dfe80bc4d406c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 30 Oct 2025 22:02:44 -0400 Subject: [PATCH 028/146] add isort check --- .github/workflows/lint-and-test.yml | 4 +++ arch/admin.py | 3 ++- arch/serializers.py | 2 +- arch/utils.py | 2 +- arch/views.py | 7 ++--- domains/admin.py | 1 + errata/admin.py | 1 + errata/models.py | 7 +++-- errata/sources/distros/alma.py | 2 +- errata/sources/distros/arch.py | 10 ++++--- errata/sources/distros/centos.py | 7 ++--- errata/sources/distros/debian.py | 8 +++--- errata/sources/distros/rocky.py | 12 +++++---- errata/sources/distros/ubuntu.py | 13 ++++++--- errata/sources/repos/yum.py | 4 +-- errata/tasks.py | 7 +++-- errata/utils.py | 4 +-- errata/views.py | 7 +++-- etc/patchman/local_settings.py | 4 ++- hooks/yum/patchman.py | 1 + hooks/zypper/patchman.py | 3 ++- hosts/admin.py | 1 + hosts/migrations/0001_initial.py | 3 ++- hosts/migrations/0002_initial.py | 2 +- .../0004_remove_host_tags_host_tags.py | 3 ++- hosts/migrations/0006_migrate_to_tz_aware.py | 1 + hosts/migrations/0007_alter_host_tags.py | 2 +- hosts/models.py | 3 ++- hosts/tasks.py | 1 - hosts/templatetags/report_alert.py | 2 +- hosts/utils.py | 4 +-- hosts/views.py | 23 ++++++++-------- modules/admin.py | 1 + modules/migrations/0001_initial.py | 2 +- modules/utils.py | 4 +-- modules/views.py | 7 +++-- operatingsystems/admin.py | 3 ++- operatingsystems/forms.py | 4 +-- operatingsystems/migrations/0002_initial.py | 2 +- operatingsystems/migrations/0003_os_arch.py | 2 +- operatingsystems/serializers.py | 2 +- operatingsystems/views.py | 17 +++++++----- packages/admin.py | 1 + packages/migrations/0001_initial.py | 2 +- .../migrations/0002_auto_20250207_1319.py | 2 +- packages/serializers.py | 2 +- packages/utils.py | 4 ++- packages/views.py | 13 ++++----- patchman/__init__.py | 3 +-- patchman/celery.py | 3 ++- patchman/receivers.py | 16 +++++------ patchman/urls.py | 3 +-- patchman/wsgi.py | 3 +-- reports/admin.py | 1 + .../migrations/0004_migrate_to_tz_aware.py | 1 + reports/models.py | 6 +++-- reports/tasks.py | 1 - reports/utils.py | 13 ++++++--- reports/views.py | 19 ++++++------- repos/admin.py | 3 ++- repos/forms.py | 7 +++-- repos/migrations/0001_initial.py | 2 +- repos/migrations/0003_migrate_to_tz_aware.py | 1 + repos/models.py | 9 +++---- repos/repo_types/arch.py | 9 ++++--- repos/repo_types/deb.py | 9 ++++--- repos/repo_types/gentoo.py | 16 +++++++---- repos/repo_types/rpm.py | 7 +++-- repos/repo_types/yast.py | 2 +- repos/repo_types/yum.py | 5 ++-- repos/serializers.py | 2 +- repos/tasks.py | 1 - repos/utils.py | 18 +++++++++---- repos/views.py | 27 ++++++++++--------- sbin/patchman | 21 +++++++++------ security/admin.py | 2 +- security/models.py | 4 +-- security/tasks.py | 1 - security/views.py | 11 ++++---- setup.py | 3 ++- util/__init__.py | 23 +++++++++------- util/filterspecs.py | 5 ++-- util/logging.py | 8 +++--- util/tasks.py | 4 ++- util/templatetags/common.py | 7 +++-- util/views.py | 6 ++--- 86 files changed, 292 insertions(+), 212 deletions(-) diff --git a/.github/workflows/lint-and-test.yml b/.github/workflows/lint-and-test.yml index 20b221aa..436698be 100644 --- a/.github/workflows/lint-and-test.yml +++ b/.github/workflows/lint-and-test.yml @@ -28,6 +28,10 @@ jobs: pip install flake8 flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics flake8 . --count --max-line-length=120 --show-source --statistics + - name: Check isort + run: | + pip install isort + isort --check --profile=django . - name: Set secret key run: ./sbin/patchman-set-secret-key - name: Test with django diff --git a/arch/admin.py b/arch/admin.py index 624a3720..5224711c 100644 --- a/arch/admin.py +++ b/arch/admin.py @@ -16,7 +16,8 @@ # along with Patchman. If not, see from django.contrib import admin -from arch.models import PackageArchitecture, MachineArchitecture + +from arch.models import MachineArchitecture, PackageArchitecture admin.site.register(PackageArchitecture) admin.site.register(MachineArchitecture) diff --git a/arch/serializers.py b/arch/serializers.py index 5319e796..a5765128 100644 --- a/arch/serializers.py +++ b/arch/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from arch.models import PackageArchitecture, MachineArchitecture +from arch.models import MachineArchitecture, PackageArchitecture class PackageArchitectureSerializer(serializers.HyperlinkedModelSerializer): diff --git a/arch/utils.py b/arch/utils.py index 04d0b350..3db6ac70 100644 --- a/arch/utils.py +++ b/arch/utils.py @@ -14,7 +14,7 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from arch.models import PackageArchitecture, MachineArchitecture +from arch.models import MachineArchitecture, PackageArchitecture from util.logging import info_message diff --git a/arch/views.py b/arch/views.py index 56a2a150..21f6b7c7 100644 --- a/arch/views.py +++ b/arch/views.py @@ -16,9 +16,10 @@ from rest_framework import viewsets -from arch.models import PackageArchitecture, MachineArchitecture -from arch.serializers import PackageArchitectureSerializer, \ - MachineArchitectureSerializer +from arch.models import MachineArchitecture, PackageArchitecture +from arch.serializers import ( + MachineArchitectureSerializer, PackageArchitectureSerializer, +) class PackageArchitectureViewSet(viewsets.ModelViewSet): diff --git a/domains/admin.py b/domains/admin.py index 2ef883e3..5cb0fee3 100644 --- a/domains/admin.py +++ b/domains/admin.py @@ -16,6 +16,7 @@ # along with Patchman. If not, see from django.contrib import admin + from domains.models import Domain admin.site.register(Domain) diff --git a/errata/admin.py b/errata/admin.py index 88190ff6..ac4b8a50 100644 --- a/errata/admin.py +++ b/errata/admin.py @@ -15,6 +15,7 @@ # along with Patchman. If not, see from django.contrib import admin + from errata.models import Erratum diff --git a/errata/models.py b/errata/models.py index cfc9bd0d..8c21bcfa 100644 --- a/errata/models.py +++ b/errata/models.py @@ -16,17 +16,16 @@ import json -from django.db import models +from django.db import IntegrityError, models from django.urls import reverse -from django.db import IntegrityError +from errata.managers import ErratumManager from packages.models import Package, PackageUpdate from packages.utils import find_evr, get_matching_packages -from errata.managers import ErratumManager from security.models import CVE, Reference from security.utils import get_or_create_cve, get_or_create_reference -from util.logging import error_message from util import get_url +from util.logging import error_message class Erratum(models.Model): diff --git a/errata/sources/distros/alma.py b/errata/sources/distros/alma.py index e0f2d4ae..0091b8bf 100644 --- a/errata/sources/distros/alma.py +++ b/errata/sources/distros/alma.py @@ -22,8 +22,8 @@ from operatingsystems.utils import get_or_create_osrelease from packages.models import Package from packages.utils import get_or_create_package, parse_package_string -from util import get_url, fetch_content, get_setting_of_type from patchman.signals import pbar_start, pbar_update +from util import fetch_content, get_setting_of_type, get_url def update_alma_errata(concurrent_processing=True): diff --git a/errata/sources/distros/arch.py b/errata/sources/distros/arch.py index 87c6c47a..e22de403 100644 --- a/errata/sources/distros/arch.py +++ b/errata/sources/distros/arch.py @@ -20,11 +20,13 @@ from django.db import connections from operatingsystems.utils import get_or_create_osrelease -from util.logging import error_message -from patchman.signals import pbar_start, pbar_update from packages.models import Package -from packages.utils import find_evr, get_matching_packages, get_or_create_package -from util import get_url, fetch_content +from packages.utils import ( + find_evr, get_matching_packages, get_or_create_package, +) +from patchman.signals import pbar_start, pbar_update +from util import fetch_content, get_url +from util.logging import error_message def update_arch_errata(concurrent_processing=False): diff --git a/errata/sources/distros/centos.py b/errata/sources/distros/centos.py index d2722a6b..8f4aa4a1 100644 --- a/errata/sources/distros/centos.py +++ b/errata/sources/distros/centos.py @@ -15,14 +15,15 @@ # along with Patchman. If not, see import re + from defusedxml import ElementTree from operatingsystems.utils import get_or_create_osrelease from packages.models import Package -from packages.utils import parse_package_string, get_or_create_package -from util.logging import error_message +from packages.utils import get_or_create_package, parse_package_string from patchman.signals import pbar_start, pbar_update -from util import bunzip2, get_url, fetch_content, get_sha1, get_setting_of_type +from util import bunzip2, fetch_content, get_setting_of_type, get_sha1, get_url +from util.logging import error_message def update_centos_errata(): diff --git a/errata/sources/distros/debian.py b/errata/sources/distros/debian.py index ece3754d..8025b1bf 100644 --- a/errata/sources/distros/debian.py +++ b/errata/sources/distros/debian.py @@ -18,18 +18,18 @@ import csv import re from datetime import datetime -from debian.deb822 import Dsc from io import StringIO +from debian.deb822 import Dsc from django.db import connections from operatingsystems.models import OSRelease from operatingsystems.utils import get_or_create_osrelease from packages.models import Package -from packages.utils import get_or_create_package, find_evr -from util.logging import error_message, warning_message +from packages.utils import find_evr, get_or_create_package from patchman.signals import pbar_start, pbar_update -from util import get_url, fetch_content, get_setting_of_type, extract +from util import extract, fetch_content, get_setting_of_type, get_url +from util.logging import error_message, warning_message DSCs = {} diff --git a/errata/sources/distros/rocky.py b/errata/sources/distros/rocky.py index 16d4d12c..2d805985 100644 --- a/errata/sources/distros/rocky.py +++ b/errata/sources/distros/rocky.py @@ -14,19 +14,21 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -import json import concurrent.futures -from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential +import json from django.db import connections from django.db.utils import OperationalError +from tenacity import ( + retry, retry_if_exception_type, stop_after_attempt, wait_exponential, +) from operatingsystems.utils import get_or_create_osrelease from packages.models import Package -from packages.utils import parse_package_string, get_or_create_package +from packages.utils import get_or_create_package, parse_package_string from patchman.signals import pbar_start, pbar_update -from util import get_url, fetch_content -from util.logging import info_message, error_message +from util import fetch_content, get_url +from util.logging import error_message, info_message def update_rocky_errata(concurrent_processing=True): diff --git a/errata/sources/distros/ubuntu.py b/errata/sources/distros/ubuntu.py index 6fafb40a..5616331f 100644 --- a/errata/sources/distros/ubuntu.py +++ b/errata/sources/distros/ubuntu.py @@ -16,8 +16,8 @@ import concurrent.futures import csv -import os import json +import os from io import StringIO from urllib.parse import urlparse @@ -26,10 +26,15 @@ from operatingsystems.models import OSRelease, OSVariant from operatingsystems.utils import get_or_create_osrelease from packages.models import Package -from packages.utils import get_or_create_package, parse_package_string, find_evr, get_matching_packages -from util import get_url, fetch_content, get_sha256, bunzip2, get_setting_of_type -from util.logging import error_message +from packages.utils import ( + find_evr, get_matching_packages, get_or_create_package, + parse_package_string, +) from patchman.signals import pbar_start, pbar_update +from util import ( + bunzip2, fetch_content, get_setting_of_type, get_sha256, get_url, +) +from util.logging import error_message def update_ubuntu_errata(concurrent_processing=False): diff --git a/errata/sources/repos/yum.py b/errata/sources/repos/yum.py index f361d10e..8b6732c4 100644 --- a/errata/sources/repos/yum.py +++ b/errata/sources/repos/yum.py @@ -16,17 +16,17 @@ import concurrent.futures from io import BytesIO -from defusedxml import ElementTree +from defusedxml import ElementTree from django.db import connections from operatingsystems.utils import get_or_create_osrelease from packages.models import Package from packages.utils import get_or_create_package -from util.logging import error_message from patchman.signals import pbar_start, pbar_update from security.models import Reference from util import extract, get_url +from util.logging import error_message def extract_updateinfo(data, url, concurrent_processing=True): diff --git a/errata/tasks.py b/errata/tasks.py index d9aaf3d4..8ded3a79 100644 --- a/errata/tasks.py +++ b/errata/tasks.py @@ -15,19 +15,18 @@ # along with Patchman. If not, see from celery import shared_task - from django.core.cache import cache -from errata.sources.distros.arch import update_arch_errata from errata.sources.distros.alma import update_alma_errata -from errata.sources.distros.debian import update_debian_errata +from errata.sources.distros.arch import update_arch_errata from errata.sources.distros.centos import update_centos_errata +from errata.sources.distros.debian import update_debian_errata from errata.sources.distros.rocky import update_rocky_errata from errata.sources.distros.ubuntu import update_ubuntu_errata -from util.logging import error_message, warning_message from repos.models import Repository from security.tasks import update_cves, update_cwes from util import get_setting_of_type +from util.logging import error_message, warning_message @shared_task diff --git a/errata/utils.py b/errata/utils.py index a8d8d424..e0a5e01b 100644 --- a/errata/utils.py +++ b/errata/utils.py @@ -18,11 +18,11 @@ from django.db import connections -from util import tz_aware_datetime from errata.models import Erratum from packages.models import PackageUpdate -from util.logging import warning_message from patchman.signals import pbar_start, pbar_update +from util import tz_aware_datetime +from util.logging import warning_message def get_or_create_erratum(name, e_type, issue_date, synopsis): diff --git a/errata/views.py b/errata/views.py index 42d12f71..8e1c0b2f 100644 --- a/errata/views.py +++ b/errata/views.py @@ -14,16 +14,15 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q - +from django.shortcuts import get_object_or_404, render from rest_framework import viewsets -from operatingsystems.models import OSRelease from errata.models import Erratum from errata.serializers import ErratumSerializer +from operatingsystems.models import OSRelease from util.filterspecs import Filter, FilterBar diff --git a/etc/patchman/local_settings.py b/etc/patchman/local_settings.py index f7b98cf8..40dd2efd 100644 --- a/etc/patchman/local_settings.py +++ b/etc/patchman/local_settings.py @@ -66,8 +66,10 @@ } } -from datetime import timedelta # noqa +from datetime import timedelta # noqa + from celery.schedules import crontab # noqa + CELERY_BEAT_SCHEDULE = { 'process_all_unprocessed_reports': { 'task': 'reports.tasks.process_reports', diff --git a/hooks/yum/patchman.py b/hooks/yum/patchman.py index 343144eb..52f9cc8b 100644 --- a/hooks/yum/patchman.py +++ b/hooks/yum/patchman.py @@ -15,6 +15,7 @@ # along with Patchman. If not, see import os + from yum.plugins import TYPE_CORE requires_api_version = '2.1' diff --git a/hooks/zypper/patchman.py b/hooks/zypper/patchman.py index 14781565..d9d478f3 100755 --- a/hooks/zypper/patchman.py +++ b/hooks/zypper/patchman.py @@ -18,8 +18,9 @@ # # zypp system plugin for patchman -import os import logging +import os + from zypp_plugin import Plugin diff --git a/hosts/admin.py b/hosts/admin.py index 8a42e8cc..43bf31da 100644 --- a/hosts/admin.py +++ b/hosts/admin.py @@ -16,6 +16,7 @@ # along with Patchman. If not, see from django.contrib import admin + from hosts.models import Host, HostRepo diff --git a/hosts/migrations/0001_initial.py b/hosts/migrations/0001_initial.py index 43366684..0037e094 100644 --- a/hosts/migrations/0001_initial.py +++ b/hosts/migrations/0001_initial.py @@ -1,8 +1,9 @@ # Generated by Django 3.2.19 on 2023-12-11 22:15 -from django.db import migrations, models import django.db.models.deletion import django.utils.timezone +from django.db import migrations, models + try: import tagging.fields has_tagging = True diff --git a/hosts/migrations/0002_initial.py b/hosts/migrations/0002_initial.py index cc59a70e..6c453c49 100644 --- a/hosts/migrations/0002_initial.py +++ b/hosts/migrations/0002_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-12-11 22:15 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/hosts/migrations/0004_remove_host_tags_host_tags.py b/hosts/migrations/0004_remove_host_tags_host_tags.py index 84e7affe..bf03a84e 100644 --- a/hosts/migrations/0004_remove_host_tags_host_tags.py +++ b/hosts/migrations/0004_remove_host_tags_host_tags.py @@ -1,8 +1,9 @@ # Generated by Django 4.2.18 on 2025-02-04 23:37 +import taggit.managers from django.apps import apps from django.db import migrations -import taggit.managers + try: import tagging # noqa except ImportError: diff --git a/hosts/migrations/0006_migrate_to_tz_aware.py b/hosts/migrations/0006_migrate_to_tz_aware.py index e36bbf1f..c14ea50b 100644 --- a/hosts/migrations/0006_migrate_to_tz_aware.py +++ b/hosts/migrations/0006_migrate_to_tz_aware.py @@ -1,6 +1,7 @@ from django.db import migrations from django.utils import timezone + def make_datetimes_tz_aware(apps, schema_editor): Host = apps.get_model('hosts', 'Host') for host in Host.objects.all(): diff --git a/hosts/migrations/0007_alter_host_tags.py b/hosts/migrations/0007_alter_host_tags.py index 3858b847..3910a06f 100644 --- a/hosts/migrations/0007_alter_host_tags.py +++ b/hosts/migrations/0007_alter_host_tags.py @@ -1,7 +1,7 @@ # Generated by Django 4.2.19 on 2025-02-28 19:53 -from django.db import migrations import taggit.managers +from django.db import migrations class Migration(migrations.Migration): diff --git a/hosts/models.py b/hosts/models.py index 650544dc..8ea5e3d5 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -24,6 +24,7 @@ from version_utils.rpm import labelCompare except ImportError: from rpm import labelCompare + from taggit.managers import TaggableManager from arch.models import MachineArchitecture @@ -34,9 +35,9 @@ from operatingsystems.models import OSVariant from packages.models import Package, PackageUpdate from packages.utils import get_or_create_package_update -from util.logging import info_message from repos.models import Repository from repos.utils import find_best_repo +from util.logging import info_message class Host(models.Model): diff --git a/hosts/tasks.py b/hosts/tasks.py index 1643901d..226652f6 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 diff --git a/hosts/templatetags/report_alert.py b/hosts/templatetags/report_alert.py index a28c5058..48d8f966 100644 --- a/hosts/templatetags/report_alert.py +++ b/hosts/templatetags/report_alert.py @@ -17,9 +17,9 @@ from datetime import timedelta from django.template import Library -from django.utils.html import format_html from django.templatetags.static import static from django.utils import timezone +from django.utils.html import format_html from util import get_setting_of_type diff --git a/hosts/utils.py b/hosts/utils.py index d6e663cf..44441f9b 100644 --- a/hosts/utils.py +++ b/hosts/utils.py @@ -15,9 +15,9 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from socket import gethostbyaddr, gaierror, herror +from socket import gaierror, gethostbyaddr, herror -from django.db import transaction, IntegrityError +from django.db import IntegrityError, transaction from taggit.models import Tag from util.logging import error_message, info_message diff --git a/hosts/views.py b/hosts/views.py index 0fc83ffa..8f20ab19 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -15,24 +15,23 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render, redirect +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.urls import reverse +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.contrib import messages - -from taggit.models import Tag +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from rest_framework import viewsets +from taggit.models import Tag -from util.filterspecs import Filter, FilterBar -from hosts.models import Host, HostRepo -from domains.models import Domain from arch.models import MachineArchitecture -from operatingsystems.models import OSVariant, OSRelease -from reports.models import Report +from domains.models import Domain from hosts.forms import EditHostForm -from hosts.serializers import HostSerializer, HostRepoSerializer +from hosts.models import Host, HostRepo +from hosts.serializers import HostRepoSerializer, HostSerializer +from operatingsystems.models import OSRelease, OSVariant +from reports.models import Report +from util.filterspecs import Filter, FilterBar @login_required diff --git a/modules/admin.py b/modules/admin.py index 33b94d20..9cf21e6c 100644 --- a/modules/admin.py +++ b/modules/admin.py @@ -15,6 +15,7 @@ # along with Patchman. If not, see from django.contrib import admin + from modules.models import Module admin.site.register(Module) diff --git a/modules/migrations/0001_initial.py b/modules/migrations/0001_initial.py index 12a8e278..9c27d425 100644 --- a/modules/migrations/0001_initial.py +++ b/modules/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-12-11 22:17 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/modules/utils.py b/modules/utils.py index 05c57c80..0d669478 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -15,10 +15,10 @@ # along with Patchman. If not, see from django.db import IntegrityError -from util.logging import error_message, info_message -from modules.models import Module from arch.models import PackageArchitecture +from modules.models import Module +from util.logging import error_message, info_message def get_or_create_module(name, stream, version, context, arch, repo): diff --git a/modules/views.py b/modules/views.py index b897a709..2d017220 100644 --- a/modules/views.py +++ b/modules/views.py @@ -14,12 +14,11 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q - -from rest_framework import viewsets, permissions +from django.shortcuts import get_object_or_404, render +from rest_framework import permissions, viewsets from modules.models import Module from modules.serializers import ModuleSerializer diff --git a/operatingsystems/admin.py b/operatingsystems/admin.py index 15f5e200..4884b5eb 100644 --- a/operatingsystems/admin.py +++ b/operatingsystems/admin.py @@ -16,7 +16,8 @@ # along with Patchman. If not, see from django.contrib import admin -from operatingsystems.models import OSVariant, OSRelease + +from operatingsystems.models import OSRelease, OSVariant class OSReleaseAdmin(admin.ModelAdmin): diff --git a/operatingsystems/forms.py b/operatingsystems/forms.py index 548a7d88..fa319182 100644 --- a/operatingsystems/forms.py +++ b/operatingsystems/forms.py @@ -15,10 +15,10 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.forms import ModelForm, ModelMultipleChoiceField from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms import ModelForm, ModelMultipleChoiceField -from operatingsystems.models import OSVariant, OSRelease +from operatingsystems.models import OSRelease, OSVariant from repos.models import Repository diff --git a/operatingsystems/migrations/0002_initial.py b/operatingsystems/migrations/0002_initial.py index 517a3f9a..04cbb411 100644 --- a/operatingsystems/migrations/0002_initial.py +++ b/operatingsystems/migrations/0002_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-12-11 22:15 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/operatingsystems/migrations/0003_os_arch.py b/operatingsystems/migrations/0003_os_arch.py index 2778ca3f..4d0e0f93 100644 --- a/operatingsystems/migrations/0003_os_arch.py +++ b/operatingsystems/migrations/0003_os_arch.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.25 on 2025-02-07 13:02 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/operatingsystems/serializers.py b/operatingsystems/serializers.py index 8418c720..be178909 100644 --- a/operatingsystems/serializers.py +++ b/operatingsystems/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from operatingsystems.models import OSVariant, OSRelease +from operatingsystems.models import OSRelease, OSVariant class OSVariantSerializer(serializers.HyperlinkedModelSerializer): diff --git a/operatingsystems/views.py b/operatingsystems/views.py index 2b696f92..6009f119 100644 --- a/operatingsystems/views.py +++ b/operatingsystems/views.py @@ -15,19 +15,22 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render, redirect +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.contrib import messages +from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse - from rest_framework import viewsets from hosts.models import Host -from operatingsystems.models import OSVariant, OSRelease -from operatingsystems.forms import AddOSVariantToOSReleaseForm, AddReposToOSReleaseForm, CreateOSReleaseForm -from operatingsystems.serializers import OSVariantSerializer, OSReleaseSerializer +from operatingsystems.forms import ( + AddOSVariantToOSReleaseForm, AddReposToOSReleaseForm, CreateOSReleaseForm, +) +from operatingsystems.models import OSRelease, OSVariant +from operatingsystems.serializers import ( + OSReleaseSerializer, OSVariantSerializer, +) @login_required diff --git a/packages/admin.py b/packages/admin.py index 979ba779..bc4b1aaa 100644 --- a/packages/admin.py +++ b/packages/admin.py @@ -16,6 +16,7 @@ # along with Patchman. If not, see from django.contrib import admin + from packages.models import Package, PackageName, PackageUpdate diff --git a/packages/migrations/0001_initial.py b/packages/migrations/0001_initial.py index 07e7bcb0..bfea3e1a 100644 --- a/packages/migrations/0001_initial.py +++ b/packages/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-12-11 22:15 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/packages/migrations/0002_auto_20250207_1319.py b/packages/migrations/0002_auto_20250207_1319.py index 1563d139..4c744203 100644 --- a/packages/migrations/0002_auto_20250207_1319.py +++ b/packages/migrations/0002_auto_20250207_1319.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.25 on 2025-02-07 13:19 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/packages/serializers.py b/packages/serializers.py index 902cb3e0..b6e3fb83 100644 --- a/packages/serializers.py +++ b/packages/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers -from packages.models import PackageName, Package, PackageUpdate +from packages.models import Package, PackageName, PackageUpdate class PackageNameSerializer(serializers.HyperlinkedModelSerializer): diff --git a/packages/utils.py b/packages/utils.py index f00f6710..87395ff6 100644 --- a/packages/utils.py +++ b/packages/utils.py @@ -21,7 +21,9 @@ from django.db import IntegrityError, transaction from arch.models import PackageArchitecture -from packages.models import PackageName, Package, PackageUpdate, PackageCategory, PackageString +from packages.models import ( + Package, PackageCategory, PackageName, PackageString, PackageUpdate, +) from util.logging import error_message, info_message, warning_message diff --git a/packages/views.py b/packages/views.py index c55a6c72..413faee0 100644 --- a/packages/views.py +++ b/packages/views.py @@ -15,17 +15,18 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q - +from django.shortcuts import get_object_or_404, render from rest_framework import viewsets -from util.filterspecs import Filter, FilterBar -from packages.models import PackageName, Package, PackageUpdate from arch.models import PackageArchitecture -from packages.serializers import PackageNameSerializer, PackageSerializer, PackageUpdateSerializer +from packages.models import Package, PackageName, PackageUpdate +from packages.serializers import ( + PackageNameSerializer, PackageSerializer, PackageUpdateSerializer, +) +from util.filterspecs import Filter, FilterBar @login_required diff --git a/patchman/__init__.py b/patchman/__init__.py index af122cc6..321dd7e5 100644 --- a/patchman/__init__.py +++ b/patchman/__init__.py @@ -14,10 +14,9 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from .receivers import * # noqa - # This will make sure the app is always imported when # Django starts so that shared_task will use this app. from .celery import app as celery_app +from .receivers import * # noqa __all__ = ('celery_app',) diff --git a/patchman/celery.py b/patchman/celery.py index 3c58edc5..c47f994d 100644 --- a/patchman/celery.py +++ b/patchman/celery.py @@ -15,10 +15,11 @@ # along with Patchman. If not, see import os + from celery import Celery os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'patchman.settings') # noqa -from django.conf import settings # noqa +from django.conf import settings # noqa app = Celery('patchman') app.config_from_object('django.conf:settings', namespace='CELERY') diff --git a/patchman/receivers.py b/patchman/receivers.py index 8d8893ca..19312ed5 100644 --- a/patchman/receivers.py +++ b/patchman/receivers.py @@ -15,16 +15,16 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from colorama import init, Fore, Style -from tqdm import tqdm - +from colorama import Fore, Style, init +from django.conf import settings from django.dispatch import receiver +from tqdm import tqdm -from util import create_pbar, update_pbar, get_verbosity -from patchman.signals import pbar_start, pbar_update, \ - info_message_s, warning_message_s, error_message_s, debug_message_s - -from django.conf import settings +from patchman.signals import ( + debug_message_s, error_message_s, info_message_s, pbar_start, pbar_update, + warning_message_s, +) +from util import create_pbar, get_verbosity, update_pbar init(autoreset=True) diff --git a/patchman/urls.py b/patchman/urls.py index ee786566..2ae64f56 100644 --- a/patchman/urls.py +++ b/patchman/urls.py @@ -15,12 +15,11 @@ # You should have received a copy of the GNU General Public License # along with If not, see -from django.conf.urls import include, handler404, handler500 # noqa from django.conf import settings +from django.conf.urls import handler404, handler500, include # noqa from django.contrib import admin from django.urls import path from django.views import static - from rest_framework import routers from arch import views as arch_views diff --git a/patchman/wsgi.py b/patchman/wsgi.py index 9a9b4b7f..16f02d5a 100644 --- a/patchman/wsgi.py +++ b/patchman/wsgi.py @@ -19,7 +19,6 @@ from django.core.wsgi import get_wsgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'patchman.settings') # noqa -from django.conf import settings # noqa - +from django.conf import settings # noqa application = get_wsgi_application() diff --git a/reports/admin.py b/reports/admin.py index a37ec4d0..66e0bf5b 100644 --- a/reports/admin.py +++ b/reports/admin.py @@ -16,6 +16,7 @@ # along with Patchman. If not, see from django.contrib import admin + from reports.models import Report diff --git a/reports/migrations/0004_migrate_to_tz_aware.py b/reports/migrations/0004_migrate_to_tz_aware.py index 98176510..20510dcd 100644 --- a/reports/migrations/0004_migrate_to_tz_aware.py +++ b/reports/migrations/0004_migrate_to_tz_aware.py @@ -1,6 +1,7 @@ from django.db import migrations from django.utils import timezone + def make_datetimes_tz_aware(apps, schema_editor): Report = apps.get_model('reports', 'Report') for report in Report.objects.all(): diff --git a/reports/models.py b/reports/models.py index d529804b..f1e0f6f3 100644 --- a/reports/models.py +++ b/reports/models.py @@ -104,7 +104,7 @@ def process(self, find_updates=True, verbose=False): info_message(text=f'Report {self.id} has already been processed') return - from reports.utils import get_arch, get_os, get_domain + from reports.utils import get_arch, get_domain, get_os arch = get_arch(self.arch) osvariant = get_os(self.os, arch) domain = get_domain(self.domain) @@ -113,7 +113,9 @@ def process(self, find_updates=True, verbose=False): if verbose: info_message(text=f'Processing report {self.id} - {self.host}') - from reports.utils import process_packages, process_repos, process_updates, process_modules + from reports.utils import ( + process_modules, process_packages, process_repos, process_updates, + ) process_repos(report=self, host=host) process_modules(report=self, host=host) process_packages(report=self, host=host) diff --git a/reports/tasks.py b/reports/tasks.py index d2a47e8f..07fb3004 100755 --- a/reports/tasks.py +++ b/reports/tasks.py @@ -16,7 +16,6 @@ # along with Patchman. If not, see from celery import shared_task - from django.core.cache import cache from django.db.utils import OperationalError diff --git a/reports/utils.py b/reports/utils.py index 76b6e09c..8b12f046 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -23,13 +23,18 @@ from domains.models import Domain from hosts.models import HostRepo from modules.utils import get_or_create_module -from operatingsystems.utils import get_or_create_osrelease, get_or_create_osvariant +from operatingsystems.utils import ( + get_or_create_osrelease, get_or_create_osvariant, +) from packages.models import Package, PackageCategory -from packages.utils import find_evr, get_or_create_package, get_or_create_package_update, parse_package_string -from util.logging import info_message +from packages.utils import ( + find_evr, get_or_create_package, get_or_create_package_update, + parse_package_string, +) from patchman.signals import pbar_start, pbar_update -from repos.models import Repository, Mirror, MirrorPackage +from repos.models import Mirror, MirrorPackage, Repository from repos.utils import get_or_create_repo +from util.logging import info_message def process_repos(report, host): diff --git a/reports/views.py b/reports/views.py index ccef1bb2..f247deb2 100644 --- a/reports/views.py +++ b/reports/views.py @@ -15,20 +15,21 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential - -from django.http import HttpResponse, Http404 -from django.views.decorators.csrf import csrf_exempt -from django.shortcuts import get_object_or_404, render, redirect +from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.urls import reverse +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q -from django.contrib import messages from django.db.utils import OperationalError +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt +from tenacity import ( + retry, retry_if_exception_type, stop_after_attempt, wait_exponential, +) -from util.filterspecs import Filter, FilterBar from reports.models import Report +from util.filterspecs import Filter, FilterBar @retry( diff --git a/repos/admin.py b/repos/admin.py index bea87567..a516ff8a 100644 --- a/repos/admin.py +++ b/repos/admin.py @@ -16,7 +16,8 @@ # along with Patchman. If not, see from django.contrib import admin -from repos.models import Repository, Mirror, MirrorPackage + +from repos.models import Mirror, MirrorPackage, Repository class MirrorAdmin(admin.ModelAdmin): diff --git a/repos/forms.py b/repos/forms.py index 0800a5c3..9cb66897 100644 --- a/repos/forms.py +++ b/repos/forms.py @@ -15,10 +15,13 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.forms import ModelForm, ModelMultipleChoiceField, TextInput, Form, ModelChoiceField, ValidationError from django.contrib.admin.widgets import FilteredSelectMultiple +from django.forms import ( + Form, ModelChoiceField, ModelForm, ModelMultipleChoiceField, TextInput, + ValidationError, +) -from repos.models import Repository, Mirror +from repos.models import Mirror, Repository class EditRepoForm(ModelForm): diff --git a/repos/migrations/0001_initial.py b/repos/migrations/0001_initial.py index a99f6878..1ae96a98 100644 --- a/repos/migrations/0001_initial.py +++ b/repos/migrations/0001_initial.py @@ -1,7 +1,7 @@ # Generated by Django 3.2.19 on 2023-12-11 22:15 -from django.db import migrations, models import django.db.models.deletion +from django.db import migrations, models class Migration(migrations.Migration): diff --git a/repos/migrations/0003_migrate_to_tz_aware.py b/repos/migrations/0003_migrate_to_tz_aware.py index dddd78ba..38e30488 100644 --- a/repos/migrations/0003_migrate_to_tz_aware.py +++ b/repos/migrations/0003_migrate_to_tz_aware.py @@ -1,6 +1,7 @@ from django.db import migrations from django.utils import timezone + def make_datetimes_tz_aware(apps, schema_editor): Mirror = apps.get_model('repos', 'Mirror') for mirror in Mirror.objects.all(): diff --git a/repos/models.py b/repos/models.py index a1db2a93..9b9082af 100644 --- a/repos/models.py +++ b/repos/models.py @@ -20,13 +20,12 @@ from arch.models import MachineArchitecture from packages.models import Package -from util import get_setting_of_type - -from repos.repo_types.deb import refresh_deb_repo -from repos.repo_types.rpm import refresh_rpm_repo, refresh_repo_errata from repos.repo_types.arch import refresh_arch_repo +from repos.repo_types.deb import refresh_deb_repo from repos.repo_types.gentoo import refresh_gentoo_repo -from util.logging import info_message, warning_message, error_message +from repos.repo_types.rpm import refresh_repo_errata, refresh_rpm_repo +from util import get_setting_of_type +from util.logging import error_message, info_message, warning_message class Repository(models.Model): diff --git a/repos/repo_types/arch.py b/repos/repo_types/arch.py index 09719428..390b321d 100644 --- a/repos/repo_types/arch.py +++ b/repos/repo_types/arch.py @@ -18,10 +18,13 @@ from io import BytesIO from packages.models import PackageString -from util.logging import info_message, warning_message from patchman.signals import pbar_start, pbar_update -from repos.utils import get_max_mirrors, fetch_mirror_data, find_mirror_url, update_mirror_packages -from util import get_datetime_now, get_checksum, Checksum +from repos.utils import ( + fetch_mirror_data, find_mirror_url, get_max_mirrors, + update_mirror_packages, +) +from util import Checksum, get_checksum, get_datetime_now +from util.logging import info_message, warning_message def refresh_arch_repo(repo): diff --git a/repos/repo_types/deb.py b/repos/repo_types/deb.py index c6c26d78..33d1f2c4 100644 --- a/repos/repo_types/deb.py +++ b/repos/repo_types/deb.py @@ -15,14 +15,17 @@ # along with Patchman. If not, see import re + from debian.deb822 import Packages from debian.debian_support import Version from packages.models import PackageString -from util.logging import error_message, info_message, warning_message from patchman.signals import pbar_start, pbar_update -from repos.utils import fetch_mirror_data, update_mirror_packages, find_mirror_url -from util import get_datetime_now, get_checksum, Checksum, extract +from repos.utils import ( + fetch_mirror_data, find_mirror_url, update_mirror_packages, +) +from util import Checksum, extract, get_checksum, get_datetime_now +from util.logging import error_message, info_message, warning_message def extract_deb_packages(data, url): diff --git a/repos/repo_types/gentoo.py b/repos/repo_types/gentoo.py index e440f0d5..a0584618 100644 --- a/repos/repo_types/gentoo.py +++ b/repos/repo_types/gentoo.py @@ -14,22 +14,28 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -import git import os import shutil import tarfile import tempfile -from defusedxml import ElementTree from fnmatch import fnmatch from io import BytesIO from pathlib import Path +import git +from defusedxml import ElementTree + from packages.models import PackageString from packages.utils import find_evr -from util.logging import info_message, warning_message, error_message from patchman.signals import pbar_start, pbar_update -from repos.utils import add_mirrors_from_urls, mirror_checksum_is_valid, update_mirror_packages -from util import extract, get_url, get_datetime_now, get_checksum, Checksum, fetch_content, response_is_valid +from repos.utils import ( + add_mirrors_from_urls, mirror_checksum_is_valid, update_mirror_packages, +) +from util import ( + Checksum, extract, fetch_content, get_checksum, get_datetime_now, get_url, + response_is_valid, +) +from util.logging import error_message, info_message, warning_message def refresh_gentoo_main_repo(repo): diff --git a/repos/repo_types/rpm.py b/repos/repo_types/rpm.py index 51661809..5ffbb708 100644 --- a/repos/repo_types/rpm.py +++ b/repos/repo_types/rpm.py @@ -16,11 +16,14 @@ from django.db.models import Q -from util.logging import info_message, warning_message from repos.repo_types.yast import refresh_yast_repo from repos.repo_types.yum import refresh_yum_repo -from repos.utils import check_for_metalinks, check_for_mirrorlists, find_mirror_url, get_max_mirrors, fetch_mirror_data +from repos.utils import ( + check_for_metalinks, check_for_mirrorlists, fetch_mirror_data, + find_mirror_url, get_max_mirrors, +) from util import get_datetime_now +from util.logging import info_message, warning_message def refresh_repo_errata(repo): diff --git a/repos/repo_types/yast.py b/repos/repo_types/yast.py index bf594040..e37b9934 100644 --- a/repos/repo_types/yast.py +++ b/repos/repo_types/yast.py @@ -17,10 +17,10 @@ import re from packages.models import PackageString -from util.logging import info_message from patchman.signals import pbar_start, pbar_update from repos.utils import fetch_mirror_data, update_mirror_packages from util import extract +from util.logging import info_message def refresh_yast_repo(mirror, data): diff --git a/repos/repo_types/yum.py b/repos/repo_types/yum.py index bc0fbc4b..1e96db39 100644 --- a/repos/repo_types/yum.py +++ b/repos/repo_types/yum.py @@ -15,17 +15,18 @@ # along with Patchman. If not, see from celery import shared_task - from django.core.cache import cache from repos.models import Repository diff --git a/repos/utils.py b/repos/utils.py index c11c41ad..29e9cdb3 100644 --- a/repos/utils.py +++ b/repos/utils.py @@ -17,17 +17,24 @@ import re from io import BytesIO -from defusedxml import ElementTree -from tenacity import RetryError +from defusedxml import ElementTree from django.db import IntegrityError from django.db.models import Q +from tenacity import RetryError from packages.models import Package -from packages.utils import convert_package_to_packagestring, convert_packagestring_to_package -from util import get_url, fetch_content, response_is_valid, extract, get_checksum, Checksum, get_setting_of_type -from util.logging import info_message, warning_message, error_message, debug_message +from packages.utils import ( + convert_package_to_packagestring, convert_packagestring_to_package, +) from patchman.signals import pbar_start, pbar_update +from util import ( + Checksum, extract, fetch_content, get_checksum, get_setting_of_type, + get_url, response_is_valid, +) +from util.logging import ( + debug_message, error_message, info_message, warning_message, +) def get_or_create_repo(r_name, r_arch, r_type, r_id=None): @@ -176,6 +183,7 @@ def add_mirrors_from_urls(repo, mirror_urls): warning_message(text=text) break from repos.models import Mirror + # FIXME: maybe we should store the mirrorlist url with full path to repomd.xml? # that is what metalink urls return now m, c = Mirror.objects.get_or_create(repo=repo, url=mirror_url.rstrip('/').replace('repodata/repomd.xml', '')) diff --git a/repos/views.py b/repos/views.py index 199c834e..1f0c2bfa 100644 --- a/repos/views.py +++ b/repos/views.py @@ -15,24 +15,27 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render, redirect -from django.http import HttpResponse -from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.urls import reverse -from django.db.models import Q from django.contrib import messages +from django.contrib.auth.decorators import login_required +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db import IntegrityError - +from django.db.models import Q +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from rest_framework import viewsets -from util.filterspecs import Filter, FilterBar +from arch.models import MachineArchitecture from hosts.models import HostRepo -from repos.models import Repository, Mirror, MirrorPackage from operatingsystems.models import OSRelease -from arch.models import MachineArchitecture -from repos.forms import EditRepoForm, LinkRepoForm, CreateRepoForm, EditMirrorForm -from repos.serializers import RepositorySerializer, MirrorSerializer, MirrorPackageSerializer +from repos.forms import ( + CreateRepoForm, EditMirrorForm, EditRepoForm, LinkRepoForm, +) +from repos.models import Mirror, MirrorPackage, Repository +from repos.serializers import ( + MirrorPackageSerializer, MirrorSerializer, RepositorySerializer, +) +from util.filterspecs import Filter, FilterBar @login_required diff --git a/sbin/patchman b/sbin/patchman index 47d89b06..c415abec 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -17,32 +17,37 @@ # along with Patchman. If not, see +import argparse import os import sys -import argparse +from django import setup as django_setup from django.core.exceptions import MultipleObjectsReturned from django.db.models import Count -from django import setup as django_setup os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'patchman.settings') from django.conf import settings # noqa + django_setup() from arch.utils import clean_architectures -from errata.utils import mark_errata_security_updates, enrich_errata, \ - scan_package_updates_for_affected_packages from errata.tasks import update_errata +from errata.utils import ( + enrich_errata, mark_errata_security_updates, + scan_package_updates_for_affected_packages, +) from hosts.models import Host from hosts.utils import clean_tags from modules.utils import clean_modules -from packages.utils import clean_packages, clean_packageupdates, clean_packagenames -from repos.models import Repository -from repos.utils import clean_repos +from packages.utils import ( + clean_packagenames, clean_packages, clean_packageupdates, +) from reports.models import Report from reports.tasks import clean_reports_with_no_hosts +from repos.models import Repository +from repos.utils import clean_repos from security.utils import update_cves, update_cwes -from util import set_verbosity, get_datetime_now +from util import get_datetime_now, set_verbosity from util.logging import info_message diff --git a/security/admin.py b/security/admin.py index 196a9468..aedeaea9 100644 --- a/security/admin.py +++ b/security/admin.py @@ -15,8 +15,8 @@ # along with Patchman. If not, see from django.contrib import admin -from security.models import CWE, CVSS, CVE, Reference +from security.models import CVE, CVSS, CWE, Reference admin.site.register(CWE) admin.site.register(CVSS) diff --git a/security/models.py b/security/models.py index 7f674a0b..0f848260 100644 --- a/security/models.py +++ b/security/models.py @@ -16,14 +16,14 @@ import json import re -from cvss import CVSS2, CVSS3, CVSS4 from time import sleep +from cvss import CVSS2, CVSS3, CVSS4 from django.db import models from django.urls import reverse from security.managers import CVEManager -from util import get_url, fetch_content, tz_aware_datetime, error_message +from util import error_message, fetch_content, get_url, tz_aware_datetime class Reference(models.Model): diff --git a/security/tasks.py b/security/tasks.py index ce60df83..7bff4149 100644 --- a/security/tasks.py +++ b/security/tasks.py @@ -15,7 +15,6 @@ # along with Patchman. If not, see from celery import shared_task - from django.core.cache import cache from security.models import CVE, CWE diff --git a/security/views.py b/security/views.py index 58a686b5..c9e606a6 100644 --- a/security/views.py +++ b/security/views.py @@ -14,17 +14,18 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.shortcuts import get_object_or_404, render from django.contrib.auth.decorators import login_required -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q - +from django.shortcuts import get_object_or_404, render from rest_framework import viewsets -from packages.models import Package from operatingsystems.models import OSRelease +from packages.models import Package from security.models import CVE, CWE, Reference -from security.serializers import CVESerializer, CWESerializer, ReferenceSerializer +from security.serializers import ( + CVESerializer, CWESerializer, ReferenceSerializer, +) from util.filterspecs import Filter, FilterBar diff --git a/setup.py b/setup.py index 6ec6d974..8e18eaf9 100755 --- a/setup.py +++ b/setup.py @@ -17,7 +17,8 @@ # along with Patchman. If not, see import os -from setuptools import setup, find_packages + +from setuptools import find_packages, setup with open('VERSION.txt', 'r', encoding='utf_8') as v: version = v.readline().strip() diff --git a/util/__init__.py b/util/__init__.py index c3dfd6bd..c6c9aa0d 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -15,30 +15,35 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -import requests import bz2 -import magic -import zlib import lzma import os +import zlib + +import magic +import requests + try: # python 3.14+ - can also remove the dependency at that stage from compression import zstd except ImportError: import zstandard as zstd + from datetime import datetime, timezone from enum import Enum from hashlib import md5, sha1, sha256, sha512 -from requests.exceptions import HTTPError, Timeout, ConnectionError -from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_exponential from time import time -from tqdm import tqdm -from django.utils.timezone import make_aware -from django.utils.dateparse import parse_datetime from django.conf import settings +from django.utils.dateparse import parse_datetime +from django.utils.timezone import make_aware +from requests.exceptions import ConnectionError, HTTPError, Timeout +from tenacity import ( + retry, retry_if_exception_type, stop_after_attempt, wait_exponential, +) +from tqdm import tqdm -from util.logging import error_message, info_message, debug_message +from util.logging import debug_message, error_message, info_message pbar = None verbose = None diff --git a/util/filterspecs.py b/util/filterspecs.py index 722b45df..eac0f747 100644 --- a/util/filterspecs.py +++ b/util/filterspecs.py @@ -15,10 +15,11 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.utils.safestring import mark_safe -from django.db.models.query import QuerySet from operator import itemgetter +from django.db.models.query import QuerySet +from django.utils.safestring import mark_safe + def get_query_string(qs): new_qs = [f'{k}={v}' for k, v in list(qs.items())] diff --git a/util/logging.py b/util/logging.py index dd79d296..cb00ccce 100644 --- a/util/logging.py +++ b/util/logging.py @@ -14,12 +14,12 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see + from datetime import datetime -from patchman.signals import info_message_s -from patchman.signals import warning_message_s -from patchman.signals import error_message_s -from patchman.signals import debug_message_s +from patchman.signals import ( + debug_message_s, error_message_s, info_message_s, warning_message_s, +) def info_message(text): diff --git a/util/tasks.py b/util/tasks.py index f650e3e2..bd76bac6 100644 --- a/util/tasks.py +++ b/util/tasks.py @@ -18,7 +18,9 @@ from arch.utils import clean_architectures from modules.utils import clean_modules -from packages.utils import clean_packages, clean_packageupdates, clean_packagenames +from packages.utils import ( + clean_packagenames, clean_packages, clean_packageupdates, +) from repos.utils import clean_repos, remove_mirror_trailing_slashes diff --git a/util/templatetags/common.py b/util/templatetags/common.py index 2aea1e5e..674e1721 100644 --- a/util/templatetags/common.py +++ b/util/templatetags/common.py @@ -15,16 +15,15 @@ # along with Patchman. If not, see import re - -from humanize import naturaltime from datetime import datetime, timedelta from urllib.parse import urlencode +from django.core.paginator import Paginator from django.template import Library from django.template.loader import get_template -from django.utils.html import format_html from django.templatetags.static import static -from django.core.paginator import Paginator +from django.utils.html import format_html +from humanize import naturaltime from util import get_setting_of_type diff --git a/util/views.py b/util/views.py index b66db6b0..fd003bca 100644 --- a/util/views.py +++ b/util/views.py @@ -17,16 +17,16 @@ from datetime import datetime, timedelta -from django.shortcuts import render from django.contrib.auth.decorators import login_required from django.contrib.sites.models import Site from django.db.models import F +from django.shortcuts import render from hosts.models import Host -from operatingsystems.models import OSVariant, OSRelease -from repos.models import Repository, Mirror +from operatingsystems.models import OSRelease, OSVariant from packages.models import Package from reports.models import Report +from repos.models import Mirror, Repository from util import get_setting_of_type From 4c760e263e56a1a4cc63ab9452977c7f5df633d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 6 Nov 2025 00:40:08 +0000 Subject: [PATCH 029/146] Bump django from 4.2.25 to 4.2.26 Bumps [django](https://github.com/django/django) from 4.2.25 to 4.2.26. - [Commits](https://github.com/django/django/compare/4.2.25...4.2.26) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.26 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 788f4241..f67fc2ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.25 +Django==4.2.26 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From 38857deb4f0c09b9504abbaeb914e051934242b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 3 Dec 2025 14:57:03 +0000 Subject: [PATCH 030/146] Bump django from 4.2.26 to 4.2.27 Bumps [django](https://github.com/django/django) from 4.2.26 to 4.2.27. - [Commits](https://github.com/django/django/compare/4.2.26...4.2.27) --- updated-dependencies: - dependency-name: django dependency-version: 4.2.27 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index f67fc2ce..08ce4573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==4.2.26 +Django==4.2.27 django-taggit==4.0.0 django-extensions==3.2.3 django-bootstrap3==23.1 From 206c4157e92e98c8d50c37eb58551626cabf1274 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Jer=C3=B3nimo?= Date: Sat, 20 Dec 2025 08:24:21 +0000 Subject: [PATCH 031/146] Modified tag handling to preserve case --- hosts/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hosts/models.py b/hosts/models.py index 8ea5e3d5..4689ccc5 100644 --- a/hosts/models.py +++ b/hosts/models.py @@ -86,7 +86,7 @@ def show(self): text += f'Packages : {self.get_num_packages()}\n' text += f'Repos : {self.get_num_repos()}\n' text += f'Updates : {self.get_num_updates()}\n' - text += f'Tags : {" ".join(self.tags.slugs())}\n' + text += f'Tags : {" ".join(self.tags.names())}\n' text += f'Needs reboot : {self.reboot_required}\n' text += f'Updated at : {self.updated_at}\n' text += f'Host repos : {self.host_repos_only}\n' From 98306089ddcfb0aec9e8982f7bbfa0e13d91aacb Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 17 Dec 2025 14:11:34 -0500 Subject: [PATCH 032/146] fix same module in different repos --- .../0005_alter_module_unique_together.py | 19 +++++++++++++++++++ modules/models.py | 2 +- modules/utils.py | 5 ++--- 3 files changed, 22 insertions(+), 4 deletions(-) create mode 100644 modules/migrations/0005_alter_module_unique_together.py diff --git a/modules/migrations/0005_alter_module_unique_together.py b/modules/migrations/0005_alter_module_unique_together.py new file mode 100644 index 00000000..046a0c54 --- /dev/null +++ b/modules/migrations/0005_alter_module_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.25 on 2025-11-25 16:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('arch', '0001_initial'), + ('repos', '0006_mirror_errata_checksum_mirror_modules_checksum'), + ('modules', '0004_alter_module_options'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='module', + unique_together={('name', 'stream', 'version', 'context', 'arch', 'repo')}, + ), + ] diff --git a/modules/models.py b/modules/models.py index 931a41c3..e1f8071b 100644 --- a/modules/models.py +++ b/modules/models.py @@ -35,7 +35,7 @@ class Module(models.Model): class Meta: verbose_name = 'Module' verbose_name_plural = 'Modules' - unique_together = ['name', 'stream', 'version', 'context', 'arch'] + unique_together = ['name', 'stream', 'version', 'context', 'arch', 'repo'] ordering = ['name', 'stream'] def __str__(self): diff --git a/modules/utils.py b/modules/utils.py index 0d669478..248b8b45 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -25,10 +25,9 @@ def get_or_create_module(name, stream, version, context, arch, repo): """ Get or create a module object Returns the module """ - created = False - m_arch, c = PackageArchitecture.objects.get_or_create(name=arch) + m_arch, _ = PackageArchitecture.objects.get_or_create(name=arch) try: - module, created = Module.objects.get_or_create( + module, _ = Module.objects.get_or_create( name=name, stream=stream, version=version, From e5ae168aa5d07d613b421432f0e2039c3e67280c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Thu, 1 Jan 2026 17:48:35 -0500 Subject: [PATCH 033/146] add priority queues for tasks (#724) * add priority queues for tasks * Update repos/tasks.py Co-authored-by: code-review-doctor[bot] <72320148+code-review-doctor[bot]@users.noreply.github.com> --- errata/tasks.py | 6 ++--- etc/patchman/local_settings.py | 6 ++--- hosts/tasks.py | 6 ++--- patchman/settings.py | 5 ++++ reports/tasks.py | 47 ++++++++++++++++++++++++++-------- repos/tasks.py | 18 ++++++++++--- sbin/patchman | 4 +-- security/tasks.py | 38 +++++++++++++++++++++------ util/tasks.py | 2 +- 9 files changed, 97 insertions(+), 35 deletions(-) diff --git a/errata/tasks.py b/errata/tasks.py index 8ded3a79..9d1f3ed9 100644 --- a/errata/tasks.py +++ b/errata/tasks.py @@ -29,7 +29,7 @@ from util.logging import error_message, warning_message -@shared_task +@shared_task(priority=1) def update_yum_repo_errata(repo_id=None, force=False): """ Update all yum repos errata """ @@ -41,7 +41,7 @@ def update_yum_repo_errata(repo_id=None, force=False): repo.refresh_errata(force) -@shared_task +@shared_task(priority=1) def update_errata(erratum_type=None, force=False, repo=None): """ Update all distros errata """ @@ -85,7 +85,7 @@ def update_errata(erratum_type=None, force=False, repo=None): warning_message('Already updating Errata, skipping task.') -@shared_task +@shared_task(priority=2) def update_errata_and_cves(): """ Task to update all errata """ diff --git a/etc/patchman/local_settings.py b/etc/patchman/local_settings.py index 40dd2efd..15fcb60a 100644 --- a/etc/patchman/local_settings.py +++ b/etc/patchman/local_settings.py @@ -56,15 +56,15 @@ # Whether to run patchman under the gunicorn web server RUN_GUNICORN = False -# Set the default timeout to e.g. 30 seconds to enable UI caching -# Note that the UI results may be out of date for this amount of time CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.redis.RedisCache', 'LOCATION': 'redis://127.0.0.1:6379', - 'TIMEOUT': 0, } } +# Set the default timeout to e.g. 30 seconds to enable UI caching +# Note that the UI results may be out of date for this amount of time +CACHE_MIDDLEWARE_SECONDS = 0 from datetime import timedelta # noqa diff --git a/hosts/tasks.py b/hosts/tasks.py index 226652f6..f186760f 100755 --- a/hosts/tasks.py +++ b/hosts/tasks.py @@ -22,7 +22,7 @@ from util.logging import info_message -@shared_task +@shared_task(priority=0) def find_host_updates(host_id): """ Task to find updates for a host """ @@ -30,7 +30,7 @@ def find_host_updates(host_id): host.find_updates() -@shared_task +@shared_task(priority=1) def find_all_host_updates(): """ Task to find updates for all hosts """ @@ -38,7 +38,7 @@ def find_all_host_updates(): find_host_updates.delay(host.id) -@shared_task +@shared_task(priority=1) def find_all_host_updates_homogenous(): """ Task to find updates for all hosts where hosts are expected to be homogenous """ diff --git a/patchman/settings.py b/patchman/settings.py index 557e8c68..23e932c4 100644 --- a/patchman/settings.py +++ b/patchman/settings.py @@ -13,6 +13,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.middleware.cache.UpdateCacheMiddleware', + 'patchman.middleware.NeverCacheMiddleware', 'django.middleware.http.ConditionalGetMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -109,6 +110,10 @@ TAGGIT_CASE_INSENSITIVE = True CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' +CELERY_BROKER_TRANSPORT_OPTIONS = { + 'queue_order_strategy': 'priority', +} +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 LOGIN_REDIRECT_URL = '/patchman/' LOGOUT_REDIRECT_URL = '/patchman/login/' diff --git a/reports/tasks.py b/reports/tasks.py index 07fb3004..6bf16e7c 100755 --- a/reports/tasks.py +++ b/reports/tasks.py @@ -24,25 +24,50 @@ from util.logging import info_message, warning_message -@shared_task(bind=True, autoretry_for=(OperationalError,), retry_backoff=True, retry_kwargs={'max_retries': 5}) +@shared_task( + bind=True, + priority=0, + autoretry_for=(OperationalError,), + retry_backoff=True, + retry_kwargs={'max_retries': 5} +) def process_report(self, report_id): """ Task to process a single report """ report = Report.objects.get(id=report_id) - lock_key = f'process_report_lock_{report_id}' - # lock will expire after 1 hour - lock_expire = 60 * 60 + report_id_lock_key = f'process_report_id_lock_{report_id}' + if report.host: + report_host_lock_key = f'process_report_host_lock_{report.host}' + else: + report_host_lock_key = f'process_report_host_lock_{report.report_ip}' + # locks will expire after 2 hours + lock_expire = 60 * 60 * 2 - if cache.add(lock_key, 'true', lock_expire): + if cache.add(report_id_lock_key, 'true', lock_expire): try: - report.process() + processing_report_id = cache.get(report_host_lock_key) + if processing_report_id: + if processing_report_id > report.id: + warning_message(f'Currently processing a newer report for {report.host} or {report.report_ip}, \ + marking report {report.id} as processed.') + report.processed = True + report.save() + else: + warning_message(f'Currently processing an older report for {report.host} or {report.report_ip}, \ + will skip processing this report.') + else: + try: + cache.set(report_host_lock_key, report.id, lock_expire) + report.process() + finally: + cache.delete(report_host_lock_key) finally: - cache.delete(lock_key) + cache.delete(report_id_lock_key) else: warning_message(f'Already processing report {report_id}, skipping task.') -@shared_task +@shared_task(priority=1) def process_reports(): """ Task to process all unprocessed reports """ @@ -51,9 +76,9 @@ def process_reports(): process_report.delay(report.id) -@shared_task -def clean_reports_with_no_hosts(): - """ Task to clean processed reports where the host no longer exists +@shared_task(priority=2) +def remove_reports_with_no_hosts(): + """ Task to remove processed reports where the host no longer exists """ for report in Report.objects.filter(processed=True): if not Host.objects.filter(hostname=report.host).exists(): diff --git a/repos/tasks.py b/repos/tasks.py index bc177653..a9fdd5f4 100644 --- a/repos/tasks.py +++ b/repos/tasks.py @@ -21,15 +21,25 @@ from util.logging import warning_message -@shared_task +@shared_task(priority=0) def refresh_repo(repo_id, force=False): """ Refresh metadata for a single repo """ - repo = Repository.objects.get(id=repo_id) - repo.refresh(force) + repo_id_lock_key = f'refresh_repos_{repo_id}_lock' + # lock will expire after 1 day + lock_expire = 60 * 60 * 24 + + if cache.add(repo_id_lock_key, 'true', lock_expire): + try: + repo = Repository.objects.get(id=repo_id) + repo.refresh(force) + finally: + cache.delete(repo_id_lock_key) + else: + warning_message(f'Already refreshing repo {repo_id}, skipping task.') -@shared_task +@shared_task(priority=1) def refresh_repos(force=False): """ Refresh metadata for all enabled repos """ diff --git a/sbin/patchman b/sbin/patchman index c415abec..b76272a2 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -43,7 +43,7 @@ from packages.utils import ( clean_packagenames, clean_packages, clean_packageupdates, ) from reports.models import Report -from reports.tasks import clean_reports_with_no_hosts +from reports.tasks import remove_reports_with_no_hosts from repos.models import Repository from repos.utils import clean_repos from security.utils import update_cves, update_cwes @@ -156,7 +156,7 @@ def clean_reports(hoststr=None): host.clean_reports() if not hoststr: - clean_reports_with_no_hosts() + remove_reports_with_no_hosts() def host_updates_alt(host=None): diff --git a/security/tasks.py b/security/tasks.py index 7bff4149..0cfbc2f1 100644 --- a/security/tasks.py +++ b/security/tasks.py @@ -21,15 +21,26 @@ from util.logging import warning_message -@shared_task +@shared_task(priority=3) def update_cve(cve_id): """ Task to update a CVE """ - cve = CVE.objects.get(id=cve_id) - cve.fetch_cve_data() + cve_id_lock_key = f'update_cve_id_lock_{cve_id}' + + # lock will expire after 1 week + lock_expire = 60 * 60 * 168 + + if cache.add(cve_id_lock_key, 'true', lock_expire): + try: + cve = CVE.objects.get(id=cve_id) + cve.fetch_cve_data() + finally: + cache.delete(cve_id_lock_key) + else: + warning_message(f'Already updating CVE {cve_id}, skipping task.') -@shared_task +@shared_task(priority=2) def update_cves(): """ Task to update all CVEs """ @@ -47,15 +58,26 @@ def update_cves(): warning_message('Already updating CVEs, skipping task.') -@shared_task +@shared_task(priority=3) def update_cwe(cwe_id): """ Task to update a CWE """ - cwe = CWE.objects.get(id=cwe_id) - cwe.fetch_cwe_data() + cwe_id_lock_key = f'update_cwe_id_lock_{cwe_id}' + + # lock will expire after 1 week + lock_expire = 60 * 60 * 168 + + if cache.add(cwe_id_lock_key, 'true', lock_expire): + try: + cwe = CWE.objects.get(id=cwe_id) + cwe.fetch_cwe_data() + finally: + cache.delete(cwe_id_lock_key) + else: + warning_message(f'Already updating CWE {cwe_id}, skipping task.') -@shared_task +@shared_task(priority=2) def update_cwes(): """ Task to update all CWEs """ diff --git a/util/tasks.py b/util/tasks.py index bd76bac6..12825a8c 100644 --- a/util/tasks.py +++ b/util/tasks.py @@ -24,7 +24,7 @@ from repos.utils import clean_repos, remove_mirror_trailing_slashes -@shared_task +@shared_task(priority=1) def clean_database(remove_duplicate_packages=False): """ Task to check the database and remove orphaned objects Runs all clean_* functions to check database consistency From 0c50d4c29508d6361111a2837531505e44778610 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Wed, 7 Jan 2026 20:46:50 -0500 Subject: [PATCH 034/146] update celery services handling (#726) Signed-off-by: Marcus Furlong --- debian/python3-patchman.install | 3 +- debian/python3-patchman.postinst | 29 +++++++++++++++ etc/patchman/celery.conf | 3 ++ .../system/patchman-celery-beat.service | 19 ++++++++++ .../system/patchman-celery-worker.service | 21 +++++++++++ etc/systemd/system/patchman-celery.service | 14 -------- patchman-client.spec | 17 ++++----- scripts/rpm-post-install.sh | 36 +++++++++++++++---- setup.py | 9 ++++- 9 files changed, 121 insertions(+), 30 deletions(-) create mode 100644 etc/systemd/system/patchman-celery-beat.service create mode 100644 etc/systemd/system/patchman-celery-worker.service delete mode 100644 etc/systemd/system/patchman-celery.service diff --git a/debian/python3-patchman.install b/debian/python3-patchman.install index e13b11ca..71f47b3a 100755 --- a/debian/python3-patchman.install +++ b/debian/python3-patchman.install @@ -1,4 +1,5 @@ #!/usr/bin/dh-exec etc/patchman/apache.conf.example => etc/apache2/conf-available/patchman.conf etc/patchman/local_settings.py etc/patchman -etc/systemd/system/patchman-celery.service => lib/systemd/system/patchman-celery.service +etc/systemd/system/patchman-celery-worker.service => lib/systemd/system/patchman-celery-worker@.service +etc/systemd/system/patchman-celery-beat.service => lib/systemd/system/patchman-celery-beat.service diff --git a/debian/python3-patchman.postinst b/debian/python3-patchman.postinst index b64cb816..bce66010 100644 --- a/debian/python3-patchman.postinst +++ b/debian/python3-patchman.postinst @@ -25,8 +25,37 @@ if [ "$1" = "configure" ] ; then chown -R www-data:www-data /var/lib/patchman adduser --system --group patchman-celery usermod -a -G www-data patchman-celery + chown root:patchman-celery /etc/patchman/celery.conf + chmod 640 /etc/patchman/celery.conf chmod g+w /var/lib/patchman /var/lib/patchman/db /var/lib/patchman/db/patchman.db + WORKER_COUNT=1 + if [ -f /etc/patchman/celery.conf ]; then + . /etc/patchman/celery.conf + WORKER_COUNT=${CELERY_WORKER_COUNT:-1} + fi + + if [ -d /run/systemd/system ]; then + systemctl daemon-reload >/dev/null || true + for i in $(seq 1 "${WORKER_COUNT}"); do + deb-systemd-helper enable "patchman-celery-worker@$i.service" >/dev/null || true + deb-systemd-invoke start "patchman-celery-worker@$i.service" >/dev/null || true + done + + active_instances=$(systemctl list-units --type=service --state=active "patchman-celery-worker@*" --no-legend | awk '{print $1}') + + for service in $active_instances; do + inst_num=$(echo "$service" | cut -d'@' -f2 | cut -d'.' -f1) + if [ "$inst_num" -gt "${WORKER_COUNT}" ]; then + deb-systemd-invoke stop "$service" >/dev/null || true + deb-systemd-helper disable "$service" >/dev/null || true + fi + done + + deb-systemd-helper enable "patchman-celery-beat.service" >/dev/null || true + deb-systemd-invoke start "patchman-celery-beat.service" >/dev/null || true + fi + echo echo "Remember to run 'patchman-manage createsuperuser' to create a user." echo diff --git a/etc/patchman/celery.conf b/etc/patchman/celery.conf index 7afc96ee..2e6f9855 100644 --- a/etc/patchman/celery.conf +++ b/etc/patchman/celery.conf @@ -1,2 +1,5 @@ REDIS_HOST=127.0.0.1 REDIS_PORT=6379 +CELERY_POOL_TYPE=solo +CELERY_WORKER_COUNT=1 +CELERY_CONCURRENCY=1 diff --git a/etc/systemd/system/patchman-celery-beat.service b/etc/systemd/system/patchman-celery-beat.service new file mode 100644 index 00000000..b4ba9f3d --- /dev/null +++ b/etc/systemd/system/patchman-celery-beat.service @@ -0,0 +1,19 @@ +[Unit] +Description=Patchman Celery Beat Scheduler Service +Requires=network-online.target +After=network-online.target + +[Service] +Type=simple +User=patchman-celery +Group=patchman-celery +EnvironmentFile=/etc/patchman/celery.conf +ExecStart=/usr/bin/celery \ + --broker redis://${REDIS_HOST:-127.0.0.1}:${REDIS_PORT:-6379}/0 \ + --app patchman \ + beat \ + --loglevel info \ + --scheduler django_celery_beat.schedulers:DatabaseScheduler + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/patchman-celery-worker.service b/etc/systemd/system/patchman-celery-worker.service new file mode 100644 index 00000000..8807cbff --- /dev/null +++ b/etc/systemd/system/patchman-celery-worker.service @@ -0,0 +1,21 @@ +[Unit] +Description=Patchman Celery Worker Service %i +Requires=network-online.target +After=network-online.target + +[Service] +Type=simple +User=patchman-celery +Group=patchman-celery +EnvironmentFile=/etc/patchman/celery.conf +ExecStart=/usr/bin/celery \ + --broker redis://${REDIS_HOST:-127.0.0.1}:${REDIS_PORT:-6379}/0 \ + --app patchman \ + worker \ + --task-events \ + --pool ${CELERY_POOL_TYPE:-solo} \ + --concurrency ${CELERY_CONCURRENCY:-1} \ + --hostname patchman-celery-worker%i@%%h + +[Install] +WantedBy=multi-user.target diff --git a/etc/systemd/system/patchman-celery.service b/etc/systemd/system/patchman-celery.service deleted file mode 100644 index 6408d818..00000000 --- a/etc/systemd/system/patchman-celery.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Patchman Celery Service -Requires=network-online.target -After=network-onlne.target - -[Service] -Type=simple -User=patchman-celery -Group=patchman-celery -EnvironmentFile=/etc/patchman/celery.conf -ExecStart=/usr/bin/celery --broker redis://${REDIS_HOST}:${REDIS_PORT}/0 --app patchman worker --loglevel info --beat --scheduler django_celery_beat.schedulers:DatabaseScheduler --task-events --pool threads - -[Install] -WantedBy=multi-user.target diff --git a/patchman-client.spec b/patchman-client.spec index 68736038..f2f8279a 100644 --- a/patchman-client.spec +++ b/patchman-client.spec @@ -10,7 +10,7 @@ Source: %{expand:%%(pwd)} BuildArch: noarch Requires: curl which coreutils util-linux gawk -%define binary_payload w9.gzdio +%define _binary_payload w9.gzdio %description patchman-client provides a client that uploads reports to a patchman server @@ -20,14 +20,15 @@ find . -mindepth 1 -delete cp -af %{SOURCEURL0}/. . %install -mkdir -p %{buildroot}/usr/sbin -mkdir -p %{buildroot}/etc/patchman -cp ./client/%{name} %{buildroot}/usr/sbin -cp ./client/%{name}.conf %{buildroot}/etc/patchman +mkdir -p %{buildroot}%{_sbindir} +mkdir -p %{buildroot}%{_sysconfdir}/patchman +install -m 755 client/%{name} %{buildroot}%{_sbindir}/%{name} +install -m 644 client/%{name}.conf %{buildroot}%{_sysconfdir}/patchman/%{name}.conf %files -%defattr(755,root,root) -/usr/sbin/patchman-client -%config(noreplace) /etc/patchman/patchman-client.conf +%defattr(-,root,root) +%{_sbindir}/patchman-client +%dir %{_sysconfdir}/patchman +%config(noreplace) %{_sysconfdir}/patchman/patchman-client.conf %changelog diff --git a/scripts/rpm-post-install.sh b/scripts/rpm-post-install.sh index 24ade8af..798c43ea 100644 --- a/scripts/rpm-post-install.sh +++ b/scripts/rpm-post-install.sh @@ -4,8 +4,9 @@ if [ ! -e /etc/httpd/conf.d/patchman.conf ] ; then cp /etc/patchman/apache.conf.example /etc/httpd/conf.d/patchman.conf fi -if ! grep /usr/lib/python3.9/site-packages /etc/httpd/conf.d/patchman.conf >/dev/null 2>&1 ; then - sed -i -e "s/^\(Define patchman_pythonpath\).*/\1 \/usr\/lib\/python3.9\/site-packages/" \ +PYTHON_SITEPACKAGES=$(python3 -c "import site; print(site.getsitepackages()[0])") +if ! grep "${PYTHON_SITEPACKAGES}" /etc/httpd/conf.d/patchman.conf >/dev/null 2>&1 ; then + sed -i -e "s|^\(Define patchman_pythonpath\).*|\1 ${PYTHON_SITEPACKAGES}|" \ /etc/httpd/conf.d/patchman.conf fi @@ -24,15 +25,38 @@ patchman-manage makemigrations patchman-manage migrate --run-syncdb --fake-initial sqlite3 /var/lib/patchman/db/patchman.db 'PRAGMA journal_mode=WAL;' -chown -R apache:apache /var/lib/patchman adduser --system --group patchman-celery usermod -a -G apache patchman-celery -chmod g+w /var/lib/patchman /var/lib/patchman/db /var/lib/patchman/db/patchman.db -chcon --type httpd_sys_rw_content_t /var/lib/patchman/db/patchman.db -semanage port -a -t http_port_t -p tcp 5672 +chown root:patchman-celery /etc/patchman/celery.conf +chmod 640 /etc/patchman/celery.conf + +chown -R apache:apache /var/lib/patchman +semanage fcontext -a -t httpd_sys_rw_content_t "/var/lib/patchman/db(/.*)?" +restorecon -Rv /var/lib/patchman/db setsebool -P httpd_can_network_memcache 1 setsebool -P httpd_can_network_connect 1 +WORKER_COUNT=1 +if [ -f /etc/patchman/celery.conf ]; then + . /etc/patchman/celery.conf + WORKER_COUNT=${CELERY_WORKER_COUNT:-1} +fi + +for i in $(seq 1 "${WORKER_COUNT}"); do + systemctl enable --now "patchman-celery-worker@$i.service" +done + +active_instances=$(systemctl list-units --type=service --state=active "patchman-celery-worker@*" --no-legend | awk '{print $1}') +for service in $active_instances; do + inst_num=$(echo "$service" | cut -d'@' -f2 | cut -d'.' -f1) + if [ "$inst_num" -gt "${WORKER_COUNT}" ]; then + systemctl stop "$service" + systemctl disable "$service" + fi +done + +systemctl enable --now patchman-celery-beat.service + echo echo "Remember to run 'patchman-manage createsuperuser' to create a user." echo diff --git a/setup.py b/setup.py index 8e18eaf9..a7dbfc68 100755 --- a/setup.py +++ b/setup.py @@ -17,6 +17,7 @@ # along with Patchman. If not, see import os +import sys from setuptools import find_packages, setup @@ -29,8 +30,14 @@ with open('requirements.txt', 'r', encoding='utf_8') as rt: install_requires = rt.read().splitlines() - data_files = [] +if 'bdist_rpm' in sys.argv: + data_files.append( + ('/usr/lib/systemd/system', [ + 'etc/systemd/system/patchman-celery-worker@.service', + 'etc/systemd/system/patchman-celery-beat.service' + ]) + ) for dirpath, dirnames, filenames in os.walk('etc'): # Ignore dirnames that start with '.' From cbbd43b81d503fbb7b389a196ef53cf045be3214 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 9 Jan 2026 17:50:10 -0500 Subject: [PATCH 035/146] remove non-present middleware (#729) --- patchman/settings.py | 1 - 1 file changed, 1 deletion(-) diff --git a/patchman/settings.py b/patchman/settings.py index 23e932c4..c3089caa 100644 --- a/patchman/settings.py +++ b/patchman/settings.py @@ -13,7 +13,6 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.middleware.cache.UpdateCacheMiddleware', - 'patchman.middleware.NeverCacheMiddleware', 'django.middleware.http.ConditionalGetMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', From 7f7976f3abcf39ecd3049fa51274abda826d3cd8 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 9 Jan 2026 17:50:20 -0500 Subject: [PATCH 036/146] fix wsgi so rpm module is only loaded once (#728) --- etc/patchman/apache.conf.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/etc/patchman/apache.conf.example b/etc/patchman/apache.conf.example index 5434cf97..c055eba0 100644 --- a/etc/patchman/apache.conf.example +++ b/etc/patchman/apache.conf.example @@ -1,5 +1,5 @@ Define patchman_pythonpath /srv/patchman/ -WSGIScriptAlias /patchman ${patchman_pythonpath}/patchman/wsgi.py +WSGIScriptAlias /patchman ${patchman_pythonpath}/patchman/wsgi.py application-group=%{GLOBAL} WSGIPythonPath ${patchman_pythonpath} From bbae0b46de037870d76f62017ca874e65a212ba7 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 9 Jan 2026 17:55:12 -0500 Subject: [PATCH 037/146] give systemd units usable defaults (#727) --- etc/systemd/system/patchman-celery-beat.service | 4 +++- etc/systemd/system/patchman-celery-worker.service | 10 +++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/etc/systemd/system/patchman-celery-beat.service b/etc/systemd/system/patchman-celery-beat.service index b4ba9f3d..e219d38d 100644 --- a/etc/systemd/system/patchman-celery-beat.service +++ b/etc/systemd/system/patchman-celery-beat.service @@ -7,9 +7,11 @@ After=network-online.target Type=simple User=patchman-celery Group=patchman-celery +Environment="REDIS_HOST=127.0.0.1" +Environment="REDIS_PORT=6379" EnvironmentFile=/etc/patchman/celery.conf ExecStart=/usr/bin/celery \ - --broker redis://${REDIS_HOST:-127.0.0.1}:${REDIS_PORT:-6379}/0 \ + --broker redis://${REDIS_HOST}:${REDIS_PORT}/0 \ --app patchman \ beat \ --loglevel info \ diff --git a/etc/systemd/system/patchman-celery-worker.service b/etc/systemd/system/patchman-celery-worker.service index 8807cbff..51fa9a0e 100644 --- a/etc/systemd/system/patchman-celery-worker.service +++ b/etc/systemd/system/patchman-celery-worker.service @@ -7,14 +7,18 @@ After=network-online.target Type=simple User=patchman-celery Group=patchman-celery +Environment="REDIS_HOST=127.0.0.1" +Environment="REDIS_PORT=6379" +Environment="CELERY_POOL_TYPE=solo" +Environment="CELERY_CONCURRENCY=1" EnvironmentFile=/etc/patchman/celery.conf ExecStart=/usr/bin/celery \ - --broker redis://${REDIS_HOST:-127.0.0.1}:${REDIS_PORT:-6379}/0 \ + --broker redis://${REDIS_HOST}:${REDIS_PORT}/0 \ --app patchman \ worker \ --task-events \ - --pool ${CELERY_POOL_TYPE:-solo} \ - --concurrency ${CELERY_CONCURRENCY:-1} \ + --pool ${CELERY_POOL_TYPE} \ + --concurrency ${CELERY_CONCURRENCY} \ --hostname patchman-celery-worker%i@%%h [Install] From f5ec1dc1d870cb0d651d9651365a710252bc3e8e Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 9 Jan 2026 18:38:05 -0500 Subject: [PATCH 038/146] use consistent users/groups on rhel/debian (#730) --- debian/python3-patchman.postinst | 8 ++++---- etc/systemd/system/patchman-celery-beat.service | 4 ++-- etc/systemd/system/patchman-celery-worker.service | 4 ++-- scripts/rpm-post-install.sh | 10 ++++------ 4 files changed, 12 insertions(+), 14 deletions(-) diff --git a/debian/python3-patchman.postinst b/debian/python3-patchman.postinst index bce66010..b36a5422 100644 --- a/debian/python3-patchman.postinst +++ b/debian/python3-patchman.postinst @@ -22,12 +22,12 @@ if [ "$1" = "configure" ] ; then patchman-manage migrate --run-syncdb --fake-initial sqlite3 /var/lib/patchman/db/patchman.db 'PRAGMA journal_mode=WAL;' - chown -R www-data:www-data /var/lib/patchman - adduser --system --group patchman-celery - usermod -a -G www-data patchman-celery - chown root:patchman-celery /etc/patchman/celery.conf + adduser --quiet --system --group patchman + adduser --quiet www-data patchman + chown root:patchman /etc/patchman/celery.conf chmod 640 /etc/patchman/celery.conf chmod g+w /var/lib/patchman /var/lib/patchman/db /var/lib/patchman/db/patchman.db + chown -R patchman:patchman /var/lib/patchman WORKER_COUNT=1 if [ -f /etc/patchman/celery.conf ]; then diff --git a/etc/systemd/system/patchman-celery-beat.service b/etc/systemd/system/patchman-celery-beat.service index e219d38d..c9bce722 100644 --- a/etc/systemd/system/patchman-celery-beat.service +++ b/etc/systemd/system/patchman-celery-beat.service @@ -5,8 +5,8 @@ After=network-online.target [Service] Type=simple -User=patchman-celery -Group=patchman-celery +User=patchman +Group=patchman Environment="REDIS_HOST=127.0.0.1" Environment="REDIS_PORT=6379" EnvironmentFile=/etc/patchman/celery.conf diff --git a/etc/systemd/system/patchman-celery-worker.service b/etc/systemd/system/patchman-celery-worker.service index 51fa9a0e..b2d6f6b7 100644 --- a/etc/systemd/system/patchman-celery-worker.service +++ b/etc/systemd/system/patchman-celery-worker.service @@ -5,8 +5,8 @@ After=network-online.target [Service] Type=simple -User=patchman-celery -Group=patchman-celery +User=patchman +Group=patchman Environment="REDIS_HOST=127.0.0.1" Environment="REDIS_PORT=6379" Environment="CELERY_POOL_TYPE=solo" diff --git a/scripts/rpm-post-install.sh b/scripts/rpm-post-install.sh index 798c43ea..451f7d48 100644 --- a/scripts/rpm-post-install.sh +++ b/scripts/rpm-post-install.sh @@ -25,15 +25,13 @@ patchman-manage makemigrations patchman-manage migrate --run-syncdb --fake-initial sqlite3 /var/lib/patchman/db/patchman.db 'PRAGMA journal_mode=WAL;' -adduser --system --group patchman-celery -usermod -a -G apache patchman-celery -chown root:patchman-celery /etc/patchman/celery.conf +adduser --system --shell /sbin/nologin patchman +usermod -a -G patchman apache +chown root:patchman /etc/patchman/celery.conf chmod 640 /etc/patchman/celery.conf - -chown -R apache:apache /var/lib/patchman +chown -R patchman:patchman /var/lib/patchman semanage fcontext -a -t httpd_sys_rw_content_t "/var/lib/patchman/db(/.*)?" restorecon -Rv /var/lib/patchman/db -setsebool -P httpd_can_network_memcache 1 setsebool -P httpd_can_network_connect 1 WORKER_COUNT=1 From b7ec0003a2e4af54427b81f11b66cd9b2201a762 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 10 Jan 2026 15:10:12 -0500 Subject: [PATCH 039/146] fixes for dumping/loading fixtures from sqlite (#731) --- operatingsystems/managers.py | 4 ++-- security/models.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/operatingsystems/managers.py b/operatingsystems/managers.py index 630484a1..b7b9f24f 100644 --- a/operatingsystems/managers.py +++ b/operatingsystems/managers.py @@ -18,5 +18,5 @@ class OSReleaseManager(models.Manager): - def get_by_natural_key(self, name, codename): - return self.get(name=name, codename=codename) + def get_by_natural_key(self, name, codename, cpe_name): + return self.get(name=name, codename=codename, cpe_name=cpe_name) diff --git a/security/models.py b/security/models.py index 0f848260..405c8db6 100644 --- a/security/models.py +++ b/security/models.py @@ -125,6 +125,8 @@ def add_cvss_score(self, vector_string, score=None, severity=None, version=None) score = cvss_score.base_score if not severity: severity = cvss_score.severities()[0] + if isinstance(severity, str): + severity = severity.capitalize() try: cvss, created = CVSS.objects.get_or_create( version=version, From e54ce493f514ed501ce4649a52ec3ab91fbb21b3 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Sat, 10 Jan 2026 16:54:39 -0500 Subject: [PATCH 040/146] update logging to log to console and celery systemd units (#732) --- etc/patchman/local_settings.py | 17 +++++++++ patchman/receivers.py | 25 ++++++++------ reports/utils.py | 4 ++- repos/repo_types/arch.py | 2 +- repos/repo_types/deb.py | 2 +- repos/repo_types/gentoo.py | 4 +-- repos/repo_types/rpm.py | 2 +- repos/utils.py | 2 +- sbin/patchman | 6 ++-- util/__init__.py | 42 +++-------------------- util/logging.py | 63 +++++++++++++++++++++++++++++----- 11 files changed, 103 insertions(+), 66 deletions(-) diff --git a/etc/patchman/local_settings.py b/etc/patchman/local_settings.py index 15fcb60a..cb19e268 100644 --- a/etc/patchman/local_settings.py +++ b/etc/patchman/local_settings.py @@ -96,3 +96,20 @@ 'schedule': timedelta(hours=24), }, } + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'console': { + 'class': 'logging.StreamHandler', + }, + }, + 'root': { + 'handlers': ['console'], + }, + 'loggers': { + 'urllib3': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + 'git': {'level': 'WARNING', 'handlers': ['console'], 'propagate': False}, + } +} diff --git a/patchman/receivers.py b/patchman/receivers.py index 19312ed5..9393d891 100644 --- a/patchman/receivers.py +++ b/patchman/receivers.py @@ -18,13 +18,13 @@ from colorama import Fore, Style, init from django.conf import settings from django.dispatch import receiver -from tqdm import tqdm +from tqdm.contrib.logging import logging_redirect_tqdm from patchman.signals import ( debug_message_s, error_message_s, info_message_s, pbar_start, pbar_update, warning_message_s, ) -from util import create_pbar, get_verbosity, update_pbar +from util.logging import create_pbar, get_quiet_mode, logger, update_pbar init(autoreset=True) @@ -53,8 +53,10 @@ def print_info_message(**kwargs): """ Receiver to handle an info message, no color """ text = str(kwargs.get('text')) - if get_verbosity(): - tqdm.write(Style.RESET_ALL + Fore.RESET + text) + if not get_quiet_mode(): + with logging_redirect_tqdm(loggers=[logger]): + for line in text.splitlines(): + logger.info(Style.RESET_ALL + Fore.RESET + line) @receiver(warning_message_s) @@ -62,8 +64,9 @@ def print_warning_message(**kwargs): """ Receiver to handle a warning message, yellow text """ text = str(kwargs.get('text')) - if get_verbosity(): - tqdm.write(Style.BRIGHT + Fore.YELLOW + text) + if not get_quiet_mode(): + with logging_redirect_tqdm(): + logger.warning(Style.BRIGHT + Fore.YELLOW + text) @receiver(error_message_s) @@ -72,13 +75,15 @@ def print_error_message(**kwargs): """ text = str(kwargs.get('text')) if text: - tqdm.write(Style.BRIGHT + Fore.RED + text) + with logging_redirect_tqdm(): + logger.error(Style.BRIGHT + Fore.RED + text) @receiver(debug_message_s) def print_debug_message(**kwargs): - """ Receiver to handle a debug message, blue text if verbose and DEBUG are set + """ Receiver to handle a debug message, blue text if DEBUG is set """ text = str(kwargs.get('text')) - if get_verbosity() and settings.DEBUG and text: - tqdm.write(Style.BRIGHT + Fore.BLUE + text) + if settings.DEBUG and text: + with logging_redirect_tqdm(loggers=[logger]): + logger.debug(Style.BRIGHT + Fore.BLUE + text) diff --git a/reports/utils.py b/reports/utils.py index 8b12f046..1a08cf3c 100644 --- a/reports/utils.py +++ b/reports/utils.py @@ -34,7 +34,7 @@ from patchman.signals import pbar_start, pbar_update from repos.models import Mirror, MirrorPackage, Repository from repos.utils import get_or_create_repo -from util.logging import info_message +from util.logging import debug_message, info_message def process_repos(report, host): @@ -47,6 +47,7 @@ def process_repos(report, host): pbar_start.send(sender=None, ptext=f'{host} Repos', plen=len(repos)) for i, repo_str in enumerate(repos): + debug_message(f'Processing report {report.id} repo: {repo_str}') repo, priority = process_repo(repo_str, report.arch) if repo: repo_ids.append(repo.id) @@ -93,6 +94,7 @@ def process_packages(report, host): packages = parse_packages(report.packages) pbar_start.send(sender=None, ptext=f'{host} Packages', plen=len(packages)) for i, pkg_str in enumerate(packages): + debug_message(f'Processing report {report.id} package: {pkg_str}') package = process_package(pkg_str, report.protocol) if package: package_ids.append(package.id) diff --git a/repos/repo_types/arch.py b/repos/repo_types/arch.py index 390b321d..b339ee6a 100644 --- a/repos/repo_types/arch.py +++ b/repos/repo_types/arch.py @@ -51,7 +51,7 @@ def refresh_arch_repo(repo): package_data = fetch_mirror_data( mirror=mirror, url=mirror_url, - text='Fetching Repo data') + text='Fetching Arch Repo data') if not package_data: continue diff --git a/repos/repo_types/deb.py b/repos/repo_types/deb.py index 33d1f2c4..eea6593f 100644 --- a/repos/repo_types/deb.py +++ b/repos/repo_types/deb.py @@ -95,7 +95,7 @@ def refresh_deb_repo(repo): package_data = fetch_mirror_data( mirror=mirror, url=mirror_url, - text='Fetching Repo data') + text='Fetching Debian Repo data') if not package_data: continue diff --git a/repos/repo_types/gentoo.py b/repos/repo_types/gentoo.py index a0584618..61966f7a 100644 --- a/repos/repo_types/gentoo.py +++ b/repos/repo_types/gentoo.py @@ -51,7 +51,7 @@ def refresh_gentoo_main_repo(repo): continue res = get_url(mirror.url + '.md5sum') - data = fetch_content(res, 'Fetching Repo checksum') + data = fetch_content(res, 'Fetching Gentoo Repo checksum') if data is None: mirror.fail() continue @@ -72,7 +72,7 @@ def refresh_gentoo_main_repo(repo): mirror.fail() continue - data = fetch_content(res, 'Fetching Repo data') + data = fetch_content(res, 'Fetching Gentoo Repo data') if data is None: mirror.fail() continue diff --git a/repos/repo_types/rpm.py b/repos/repo_types/rpm.py index 5ffbb708..d1482272 100644 --- a/repos/repo_types/rpm.py +++ b/repos/repo_types/rpm.py @@ -84,7 +84,7 @@ def refresh_rpm_repo_mirrors(repo, errata_only=False): repo_data = fetch_mirror_data( mirror=mirror, url=mirror_url, - text='Fetching Repo data') + text='Fetching rpm Repo data') if not repo_data: continue diff --git a/repos/utils.py b/repos/utils.py index 29e9cdb3..13cee149 100644 --- a/repos/utils.py +++ b/repos/utils.py @@ -155,7 +155,7 @@ def get_mirrorlist_urls(url): return if response_is_valid(res): try: - data = fetch_content(res, 'Fetching Repo data') + data = fetch_content(res, 'Fetching Repo data to check for mirrorlist') if data is None: return mirror_urls = re.findall(r'^http[s]*://.*$|^ftp://.*$', data.decode('utf-8'), re.MULTILINE) diff --git a/sbin/patchman b/sbin/patchman index b76272a2..06bef981 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -47,8 +47,8 @@ from reports.tasks import remove_reports_with_no_hosts from repos.models import Repository from repos.utils import clean_repos from security.utils import update_cves, update_cwes -from util import get_datetime_now, set_verbosity -from util.logging import info_message +from util import get_datetime_now +from util.logging import info_message, set_quiet_mode def get_host(host=None, action='Performing action'): @@ -547,7 +547,7 @@ def main(): parser = collect_args() args = parser.parse_args() - set_verbosity(not args.quiet) + set_quiet_mode(args.quiet) showhelp = process_args(args) if showhelp: parser.print_help() diff --git a/util/__init__.py b/util/__init__.py index c6c9aa0d..b85e5e37 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -41,12 +41,14 @@ from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, ) -from tqdm import tqdm -from util.logging import debug_message, error_message, info_message +from util.logging import ( + create_pbar, debug_message, error_message, info_message, quiet_mode, + update_pbar, +) pbar = None -verbose = None +verbose = not quiet_mode Checksum = Enum('Checksum', 'md5 sha sha1 sha256 sha512') http_proxy = os.getenv('http_proxy') @@ -57,40 +59,6 @@ } -def get_verbosity(): - """ Get the global verbosity level - """ - return verbose - - -def set_verbosity(value): - """ Set the global verbosity level - """ - global verbose - verbose = value - - -def create_pbar(ptext, plength, ljust=35, **kwargs): - """ Create a global progress bar if global verbose is True - """ - global pbar - if verbose and plength > 0: - jtext = str(ptext).ljust(ljust) - pbar = tqdm(total=plength, desc=jtext, position=0, leave=True, ascii=' >=') - return pbar - - -def update_pbar(index, **kwargs): - """ Update the global progress bar if global verbose is True - """ - global pbar - if verbose and pbar: - pbar.update(n=index-pbar.n) - if index >= pbar.total: - pbar.close() - pbar = None - - def fetch_content(response, text='', ljust=35): """ Display a progress bar to fetch the request content if verbose is True. Otherwise, just return the request content diff --git a/util/logging.py b/util/logging.py index cb00ccce..bf532e54 100644 --- a/util/logging.py +++ b/util/logging.py @@ -15,28 +15,73 @@ # along with Patchman. If not, see -from datetime import datetime +import logging + +from django.conf import settings +from tqdm import tqdm from patchman.signals import ( debug_message_s, error_message_s, info_message_s, warning_message_s, ) +log_format = '[%(asctime)s] %(levelname)s: %(message)s' +if settings.DEBUG: + logging_level = logging.DEBUG +else: + logging_level = logging.INFO +logging.basicConfig(level=logging_level, format=log_format) +logger = logging.getLogger() +logging.getLogger('git.cmd').setLevel(logging.WARNING) + +quiet_mode = False +pbar = None + + +def get_quiet_mode(): + """ Get the global quiet_mode + """ + return quiet_mode + + +def set_quiet_mode(value): + """ Set the global quiet_mode + """ + global quiet_mode + quiet_mode = value + + +def create_pbar(ptext, plength, ljust=35, **kwargs): + """ Create a global progress bar if global quiet_mode is False + """ + global pbar + if not quiet_mode and plength > 0: + jtext = str(ptext).ljust(ljust) + pbar = tqdm(total=plength, desc=jtext, position=0, leave=True, ascii=' >=') + return pbar + + +def update_pbar(index, **kwargs): + """ Update the global progress bar if global quiet_mode is False + """ + global pbar + if not quiet_mode and pbar: + pbar.update(n=index-pbar.n) + if index >= pbar.total: + pbar.close() + pbar = None + def info_message(text): - ts = datetime.now() - info_message_s.send(sender=None, text=text, ts=ts) + info_message_s.send(sender=None, text=text) def warning_message(text): - ts = datetime.now() - warning_message_s.send(sender=None, text=text, ts=ts) + warning_message_s.send(sender=None, text=text) def debug_message(text): - ts = datetime.now() - debug_message_s.send(sender=None, text=text, ts=ts) + debug_message_s.send(sender=None, text=text) def error_message(text): - ts = datetime.now() - error_message_s.send(sender=None, text=text, ts=ts) + error_message_s.send(sender=None, text=text) From 91f35e1917b4bf92eb002c8af919cc8c001330bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Jer=C3=B3nimo?= Date: Tue, 20 Jan 2026 13:08:57 +0000 Subject: [PATCH 041/146] Replaced exit 1 with fatal() function --- email/patchman-email | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/email/patchman-email b/email/patchman-email index dafc6328..aef9e62c 100755 --- a/email/patchman-email +++ b/email/patchman-email @@ -187,6 +187,9 @@ usage() { echo "-h: Shows this help message and exits" } +proc=$$ +trap 'exit 1' SIGUSR1 + # Checks if there are any parameters and if they start with "-" if [ $# -gt 0 ] && [[ $1 == -* ]]; then CONF="/etc/patchman/patchman-email.conf" @@ -203,8 +206,7 @@ if [ $# -gt 0 ] && [[ $1 == -* ]]; then case $arg in a) if [ ! -f $CONF ]; then - echo "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." - exit 1 + fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else DIR=$(getConfig General dir) DATE=$(date +%Y%m%d) @@ -226,8 +228,7 @@ if [ $# -gt 0 ] && [[ $1 == -* ]]; then ;; H) if [ ! -f $CONF ]; then - echo "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." - exit 1 + fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else DIR=$(getConfig General dir) DATE=$(date +%Y%m%d) @@ -239,8 +240,7 @@ if [ $# -gt 0 ] && [[ $1 == -* ]]; then ;; T) if [ ! -f $CONF ]; then - echo "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." - exit 1 + fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else DIR=$(getConfig General dir) DATE=$(date +%Y%m%d) @@ -254,8 +254,7 @@ if [ $# -gt 0 ] && [[ $1 == -* ]]; then email "$TAG" else - echo "$(date '+%d/%m/%Y %R') [ERROR] ${OPTARG}: Tag not found." - exit 1 + fatal "$(date '+%d/%m/%Y %R') [ERROR] ${OPTARG}: Tag not found." fi fi ;; From 9af7e207aa447ef225d376de45c87f1505d33e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Jer=C3=B3nimo?= Date: Tue, 20 Jan 2026 13:11:53 +0000 Subject: [PATCH 042/146] Implemented dependency check --- email/patchman-email | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/email/patchman-email b/email/patchman-email index aef9e62c..0b7837f0 100755 --- a/email/patchman-email +++ b/email/patchman-email @@ -1,5 +1,15 @@ #!/bin/bash +checkDependencies() { + dependencies=("mysql" "patchman" "sendmail" "uuencode" "uuidgen" "weasyprint") + + for dep in "${dependencies[@]}"; do + if [ ! "$(command -v "$dep")" ]; then + fatal "[ERROR] Command $dep not installed." + fi + done +} + email() { SENDER=$(getConfig General sender) @@ -190,6 +200,8 @@ usage() { proc=$$ trap 'exit 1' SIGUSR1 +checkDependencies + # Checks if there are any parameters and if they start with "-" if [ $# -gt 0 ] && [[ $1 == -* ]]; then CONF="/etc/patchman/patchman-email.conf" From 9e8970a950c94483f52409673081af268ba7ab7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ricardo=20Jer=C3=B3nimo?= Date: Tue, 20 Jan 2026 13:56:13 +0000 Subject: [PATCH 043/146] Changed all internal variables to lowercase --- email/patchman-email | 148 +++++++++++++++++++++---------------------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/email/patchman-email b/email/patchman-email index 0b7837f0..e426f31a 100755 --- a/email/patchman-email +++ b/email/patchman-email @@ -11,23 +11,23 @@ checkDependencies() { } email() { - SENDER=$(getConfig General sender) + sender=$(getConfig General sender) - if [ -n "$SENDER" ]; then - RECIPIENT=$(getConfig "$1" recipient) + if [ -n "$sender" ]; then + recipient=$(getConfig "$1" recipient) - if [ -n "$RECIPIENT" ]; then - ATTACHMENTS=() - BOUNDARY=$(uuidgen) - CC=$(getConfig "$1" cc) - BCC=$(getConfig "$1" bcc) - FULLNAME=$(getConfig General fullname) - SUBJECT=$(echo "[$1] $(getConfig General subject)" | base64) + if [ -n "$recipient" ]; then + attachments=() + boundary=$(uuidgen) + cc=$(getConfig "$1" cc) + bcc=$(getConfig "$1" bcc) + fullname=$(getConfig General fullname) + subject=$(echo "[$1] $(getConfig General subject)" | base64) if [ -s "$custom_html" ]; then - BODY=$(cat $custom_html) + body=$(cat $custom_html) else - BODY="
- $(echo "$HOST" | awk -F ' : ' 'NR==1 {print ""; next} NR==2 || NR==4 || NR==5 || NR==6 || NR==7 || NR==9 || NR==11 { print "" }') + $(echo "$host" | awk -F ' : ' 'NR==1 {print ""; next} NR==2 || NR==4 || NR==5 || NR==6 || NR==7 || NR==9 || NR==11 { print "" }')
"$1"
"$1""$2"
"$1"
"$1""$2"

- $(echo "$QUERY" | sort -n | uniq | sed 's/[()]//g' | awk '{ print "" }') + $(echo "$query" | sort -n | uniq | sed 's/[()]//g' | awk '{ print "" }')
Current VersionNew VersionType
"$1""$3""$4"
"$1""$3""$4"

EOF - if weasyprint "$FILE.html" "$FILE.pdf"; then - rm "$FILE.html" + if weasyprint "$file.html" "$file.pdf"; then + rm "$file.html" fi else - echo "$(date '+%d/%m/%Y %R') [INFO] $TAG $1: Report exists, skipping." + echo "$(date '+%d/%m/%Y %R') [INFO] $tag $1: Report exists, skipping." fi else - echo "$(date '+%d/%m/%Y %R') [INFO] $TAG $1: There are no updates available." + echo "$(date '+%d/%m/%Y %R') [INFO] $tag $1: There are no updates available." fi } @@ -204,67 +204,67 @@ checkDependencies # Checks if there are any parameters and if they start with "-" if [ $# -gt 0 ] && [[ $1 == -* ]]; then - CONF="/etc/patchman/patchman-email.conf" + conf="/etc/patchman/patchman-email.conf" custom_html="/etc/patchman/patchman-email.html" # Database credentials configured in /etc/patchman/local_settings.py mysql_db=$(grep -E "'NAME'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') mysql_user=$(grep -E "'USER'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') mysql_pass=$(grep -E "'PASSWORD'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') - mysql_host=$(grep -E "'HOST'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') + mysql_host=$(grep -E "'host'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') mysql_port=$(grep -E "'PORT'" /etc/patchman/local_settings.py | grep -v "^#" | sed -e "s/'\|,//g" | awk -F " " '{print $2}') while getopts 'aH:T:h' arg; do case $arg in a) - if [ ! -f $CONF ]; then + if [ ! -f $conf ]; then fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else - DIR=$(getConfig General dir) - DATE=$(date +%Y%m%d) - HOSTS=() - TAGS=() - mapfile -t HOSTS < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==1 { print $1 }' | tr -d ":" | sed 's/\x1b\[[0-9;]*m//g') - mapfile -t TAGS < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==12 { print $2 }' | sort -n | uniq | sed 's/\x1b\[[0-9;]*m//g') + dir=$(getConfig General dir) + date=$(date +%Y%m%d) + hosts=() + tags=() + mapfile -t hosts < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==1 { print $1 }' | tr -d ":" | sed 's/\x1b\[[0-9;]*m//g') + mapfile -t tags < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==12 { print $2 }' | sort -n | uniq | sed 's/\x1b\[[0-9;]*m//g') # Generate report - for HOST in "${HOSTS[@]}"; do - report "$HOST" + for host in "${hosts[@]}"; do + report "$host" done # E-mails the report - for TAG in "${TAGS[@]}"; do - email "$TAG" + for tag in "${tags[@]}"; do + email "$tag" done fi ;; H) - if [ ! -f $CONF ]; then + if [ ! -f $conf ]; then fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else - DIR=$(getConfig General dir) - DATE=$(date +%Y%m%d) - HOST=${OPTARG} + dir=$(getConfig General dir) + date=$(date +%Y%m%d) + host=${OPTARG} - report "$HOST" - email "$TAG" "$HOST" + report "$host" + email "$tag" "$host" fi ;; T) - if [ ! -f $CONF ]; then + if [ ! -f $conf ]; then fatal "$(date '+%d/%m/%Y %R') [ERROR] Configuration file not found." else - DIR=$(getConfig General dir) - DATE=$(date +%Y%m%d) - HOSTS=() - mapfile -t HOSTS < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==1 {print $1; next} NR%16==12 {print $2}' | tr -d ":" | paste -d : - - | awk -F ':' '{ if( $2 == "'"${OPTARG}"'" ){ print $1 }}' | sed 's/\x1b\[[0-9;]*m//g') + dir=$(getConfig General dir) + date=$(date +%Y%m%d) + hosts=() + mapfile -t hosts < <(patchman -lh | tail -n +3 | awk -F ' : ' 'NR%16==1 {print $1; next} NR%16==12 {print $2}' | tr -d ":" | paste -d : - - | awk -F ':' '{ if( $2 == "'"${OPTARG}"'" ){ print $1 }}' | sed 's/\x1b\[[0-9;]*m//g') - if [ "${#HOSTS[@]}" -gt 0 ]; then - for HOST in "${HOSTS[@]}"; do - report "$HOST" + if [ "${#hosts[@]}" -gt 0 ]; then + for host in "${hosts[@]}"; do + report "$host" done - email "$TAG" + email "$tag" else fatal "$(date '+%d/%m/%Y %R') [ERROR] ${OPTARG}: Tag not found." fi From 191db74f0f6dc182becb9c819ebabd196cc8d690 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 12 Jan 2026 00:03:24 -0500 Subject: [PATCH 044/146] switch to django-tables2 --- debian/control | 3 +- errata/tables.py | 111 ++++++++ errata/templates/errata/erratum_table.html | 31 --- errata/views.py | 18 +- hosts/tables.py | 110 ++++++++ hosts/templates/hosts/host_list.html | 32 ++- hosts/templates/hosts/host_table.html | 28 -- hosts/templatetags/report_alert.py | 4 +- hosts/urls.py | 1 + hosts/views.py | 121 +++++++- modules/tables.py | 72 +++++ modules/templates/modules/module_table.html | 27 -- modules/views.py | 16 +- operatingsystems/tables.py | 167 +++++++++++ .../operatingsystemrelease_table.html | 27 -- .../operatingsystemvariant_table.html | 25 -- .../operatingsystems/osrelease_list.html | 21 +- .../operatingsystems/osvariant_list.html | 31 ++- operatingsystems/urls.py | 2 + operatingsystems/views.py | 177 ++++++++++-- packages/tables.py | 120 ++++++++ .../packages/package_name_table.html | 17 -- .../templates/packages/package_table.html | 30 -- packages/views.py | 36 +-- patchman/settings.py | 4 + patchman/sqlite3/base.py | 14 + patchman/static/css/base.css | 16 ++ patchman/static/img/icon-alert.gif | Bin 145 -> 0 bytes patchman/static/img/icon-no.gif | Bin 176 -> 0 bytes patchman/static/img/icon-yes.gif | Bin 299 -> 0 bytes patchman/static/js/ajax-jquery.js | 29 -- patchman/static/js/button-post.js | 23 -- patchman/urls.py | 2 +- reports/tables.py | 60 ++++ reports/templates/reports/report_detail.html | 4 +- reports/templates/reports/report_list.html | 32 ++- reports/templates/reports/report_table.html | 23 -- reports/urls.py | 1 + reports/views.py | 101 ++++++- repos/tables.py | 165 +++++++++++ repos/templates/repos/mirror_edit_repo.html | 33 --- repos/templates/repos/mirror_list.html | 21 +- repos/templates/repos/mirror_table.html | 39 --- .../repos/mirror_with_repo_list.html | 4 +- repos/templates/repos/repo_list.html | 32 ++- repos/templates/repos/repository_table.html | 25 -- repos/templatetags/repo_buttons.py | 54 ---- repos/urls.py | 2 + repos/views.py | 262 +++++++++++++++--- requirements.txt | 1 + security/tables.py | 183 ++++++++++++ security/templates/security/cve_table.html | 37 --- security/templates/security/cwe_table.html | 21 -- .../templates/security/reference_table.html | 19 -- security/views.py | 44 +-- setup.cfg | 1 + util/__init__.py | 9 + util/context_processors.py | 105 +++++++ util/tables.py | 26 ++ util/templates/base.html | 2 - util/templates/bulk_actions.html | 90 ++++++ util/templates/dashboard.html | 2 +- util/templates/navbar.html | 47 ++-- util/templates/objectlist.html | 16 +- util/templates/table.html | 118 ++++++++ util/templatetags/common.py | 84 +++--- util/urls.py | 2 +- 67 files changed, 2240 insertions(+), 740 deletions(-) create mode 100644 errata/tables.py delete mode 100644 errata/templates/errata/erratum_table.html create mode 100644 hosts/tables.py delete mode 100644 hosts/templates/hosts/host_table.html create mode 100644 modules/tables.py delete mode 100644 modules/templates/modules/module_table.html create mode 100644 operatingsystems/tables.py delete mode 100644 operatingsystems/templates/operatingsystems/operatingsystemrelease_table.html delete mode 100644 operatingsystems/templates/operatingsystems/operatingsystemvariant_table.html create mode 100644 packages/tables.py delete mode 100644 packages/templates/packages/package_name_table.html delete mode 100644 packages/templates/packages/package_table.html delete mode 100644 patchman/static/img/icon-alert.gif delete mode 100644 patchman/static/img/icon-no.gif delete mode 100644 patchman/static/img/icon-yes.gif delete mode 100644 patchman/static/js/ajax-jquery.js delete mode 100644 patchman/static/js/button-post.js create mode 100644 reports/tables.py delete mode 100644 reports/templates/reports/report_table.html create mode 100644 repos/tables.py delete mode 100644 repos/templates/repos/mirror_edit_repo.html delete mode 100644 repos/templates/repos/mirror_table.html delete mode 100644 repos/templates/repos/repository_table.html delete mode 100644 repos/templatetags/repo_buttons.py create mode 100644 security/tables.py delete mode 100644 security/templates/security/cve_table.html delete mode 100644 security/templates/security/cwe_table.html delete mode 100644 security/templates/security/reference_table.html create mode 100644 util/context_processors.py create mode 100644 util/tables.py create mode 100644 util/templates/bulk_actions.html create mode 100644 util/templates/table.html diff --git a/debian/control b/debian/control index 7bfe320e..cd19139c 100644 --- a/debian/control +++ b/debian/control @@ -20,7 +20,8 @@ Depends: ${misc:Depends}, python3 (>= 3.11), python3-django (>= 4.2), python3-requests, python3-colorama, python3-magic, python3-humanize, python3-yaml, libapache2-mod-wsgi-py3, apache2, sqlite3, celery, python3-celery, python3-django-celery-beat, redis-server, - python3-redis, python3-git, python3-django-taggit, python3-zstandard + python3-redis, python3-git, python3-django-taggit, python3-zstandard, + python3-django-tables2 Suggests: python3-mysqldb, python3-psycopg2, python3-pymemcache, memcached Description: Django-based patch status monitoring tool for linux systems. . diff --git a/errata/tables.py b/errata/tables.py new file mode 100644 index 00000000..c23d25be --- /dev/null +++ b/errata/tables.py @@ -0,0 +1,111 @@ +# 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 + +import django_tables2 as tables + +from errata.models import Erratum +from util.tables import BaseTable + +ERRATUM_NAME_TEMPLATE = '{{ record.name }}' +PACKAGES_AFFECTED_TEMPLATE = ( + '{% with count=record.affected_packages.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) +PACKAGES_FIXED_TEMPLATE = ( + '{% with count=record.fixed_packages.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) +OSRELEASES_TEMPLATE = ( + '{% with count=record.osreleases.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) +ERRATUM_CVES_TEMPLATE = ( + '{% with count=record.cves.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) +REFERENCES_TEMPLATE = ( + '{% with count=record.references.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) + + +class ErratumTable(BaseTable): + erratum_name = tables.TemplateColumn( + ERRATUM_NAME_TEMPLATE, + order_by='name', + verbose_name='ID', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + e_type = tables.Column( + order_by='e_type', + verbose_name='Type', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + issue_date = tables.DateColumn( + order_by='issue_date', + verbose_name='Published Date', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + synopsis = tables.Column( + orderable=False, + verbose_name='Synopsis', + attrs={'th': {'class': 'col-sm-4'}, 'td': {'class': 'col-sm-4'}}, + ) + packages_affected = tables.TemplateColumn( + PACKAGES_AFFECTED_TEMPLATE, + orderable=False, + verbose_name='Packages Affected', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + packages_fixed = tables.TemplateColumn( + PACKAGES_FIXED_TEMPLATE, + orderable=False, + verbose_name='Packages Fixed', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osreleases = tables.TemplateColumn( + OSRELEASES_TEMPLATE, + orderable=False, + verbose_name='OS Releases Affected', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + erratum_cves = tables.TemplateColumn( + ERRATUM_CVES_TEMPLATE, + orderable=False, + verbose_name='CVEs', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + references = tables.TemplateColumn( + REFERENCES_TEMPLATE, + orderable=False, + verbose_name='References', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = Erratum + fields = ( + 'erratum_name', 'e_type', 'issue_date', 'synopsis', 'packages_affected', + 'packages_fixed', 'osreleases', 'erratum_cves', 'references', + ) diff --git a/errata/templates/errata/erratum_table.html b/errata/templates/errata/erratum_table.html deleted file mode 100644 index c319cbb5..00000000 --- a/errata/templates/errata/erratum_table.html +++ /dev/null @@ -1,31 +0,0 @@ -{% load common %} - - - - - - - - - - - - - - - - {% for erratum in object_list %} - - - - - - - - - - - - {% endfor %} - -
IDTypePublished DateSynopsisPackages AffectedPackages FixedOS Releases AffectedCVEsReferences
{{ erratum.name }}{{ erratum.e_type }}{{ erratum.issue_date|date|default_if_none:'' }}{{ erratum.synopsis }}{% with count=erratum.affected_packages.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=erratum.fixed_packages.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=erratum.osreleases.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=erratum.cves.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=erratum.references.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}
diff --git a/errata/views.py b/errata/views.py index 8e1c0b2f..285f7483 100644 --- a/errata/views.py +++ b/errata/views.py @@ -15,13 +15,14 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django_tables2 import RequestConfig from rest_framework import viewsets from errata.models import Erratum from errata.serializers import ErratumSerializer +from errata.tables import ErratumTable from operatingsystems.models import OSRelease from util.filterspecs import Filter, FilterBar @@ -61,16 +62,6 @@ def erratum_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(errata, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'Erratum Type', 'e_type', Erratum.objects.values_list('e_type', flat=True).distinct())) @@ -78,9 +69,12 @@ def erratum_list(request): OSRelease.objects.filter(erratum__in=errata))) filter_bar = FilterBar(request, filter_list) + table = ErratumTable(errata) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + return render(request, 'errata/erratum_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, 'terms': terms}) diff --git a/hosts/tables.py b/hosts/tables.py new file mode 100644 index 00000000..835d8195 --- /dev/null +++ b/hosts/tables.py @@ -0,0 +1,110 @@ +# 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 + +import django_tables2 as tables + +from hosts.models import Host +from util.tables import BaseTable + +CHECKBOX_TEMPLATE = '' +SELECT_ALL_CHECKBOX = '' +HOSTNAME_TEMPLATE = '{{ record.hostname }}' +SEC_UPDATES_TEMPLATE = ( + '{% with count=record.get_num_security_updates %}' + '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% endwith %}' +) +BUG_UPDATES_TEMPLATE = ( + '{% with count=record.get_num_bugfix_updates %}' + '{% if count != 0 %}{{ count }}{% else %}{% endif %}' + '{% endwith %}' +) +AFFECTED_ERRATA_TEMPLATE = ( + '{% with count=record.errata.count %}' + '{% if count != 0 %}' + '{{ count }}' + '{% else %}{% endif %}{% endwith %}' +) +OSVARIANT_TEMPLATE = ( + '{% if record.osvariant %}' + '{{ record.osvariant }}' + '{% endif %}' +) +LASTREPORT_TEMPLATE = ( + '{% load report_alert %}' + '{{ record.lastreport }} {% report_alert record.lastreport %}' +) +REBOOT_TEMPLATE = '{% load common %}{% no_yes_img record.reboot_required %}' + + +class HostTable(BaseTable): + selection = tables.TemplateColumn( + CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + hostname = tables.TemplateColumn( + HOSTNAME_TEMPLATE, + order_by='hostname', + verbose_name='Hostname', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + sec_updates = tables.TemplateColumn( + SEC_UPDATES_TEMPLATE, + order_by='sec_updates_count', + verbose_name='Security Updates', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + bug_updates = tables.TemplateColumn( + BUG_UPDATES_TEMPLATE, + order_by='bug_updates_count', + verbose_name='Bugfix Updates', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + affected_errata = tables.TemplateColumn( + AFFECTED_ERRATA_TEMPLATE, + order_by='errata_count', + verbose_name='Affected by Errata', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + kernel = tables.Column( + verbose_name='Running Kernel', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + osvariant = tables.TemplateColumn( + OSVARIANT_TEMPLATE, + order_by='osvariant__name', + verbose_name='OS Variant', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + lastreport = tables.TemplateColumn( + LASTREPORT_TEMPLATE, + order_by='lastreport', + verbose_name='Last Report', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + reboot_required = tables.TemplateColumn( + REBOOT_TEMPLATE, + order_by='reboot_required', + verbose_name='Reboot Status', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + + class Meta(BaseTable.Meta): + model = Host + fields = ( + 'selection', 'hostname', 'sec_updates', 'bug_updates', 'affected_errata', + 'kernel', 'osvariant', 'lastreport', 'reboot_required', + ) diff --git a/hosts/templates/hosts/host_list.html b/hosts/templates/hosts/host_list.html index 4c7429bc..19c0e308 100644 --- a/hosts/templates/hosts/host_list.html +++ b/hosts/templates/hosts/host_list.html @@ -1,7 +1,37 @@ -{% extends "objectlist.html" %} +{% extends "base.html" %} +{% load django_tables2 common %} {% block page_title %}Hosts{% endblock %} {% block content_title %} Hosts {% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Hosts
  • {% endblock %} + +{% block content %} +
    +
    + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    + + {% if filter_bar %} +
    +
    +
    Filter by...
    +
    + {{ filter_bar|safe }} +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/hosts/templates/hosts/host_table.html b/hosts/templates/hosts/host_table.html deleted file mode 100644 index bebb7723..00000000 --- a/hosts/templates/hosts/host_table.html +++ /dev/null @@ -1,28 +0,0 @@ -{% load common report_alert %} - - - - - - - - - - - - - - {% for host in object_list %} - - - - - - - - - - - {% endfor %} - -
    HostnameUpdatesAffected by ErrataRunning KernelOS VariantLast ReportReboot Status
    {{ host }}{% with count=host.get_num_security_updates %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=host.get_num_bugfix_updates %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{% with count=host.errata.count %}{% if count != 0 %}{{ count }}{% else %} {% endif %}{% endwith %}{{ host.kernel }}{{ host.osvariant }}{{ host.lastreport }}{% report_alert host.lastreport %}{% no_yes_img host.reboot_required %}
    diff --git a/hosts/templatetags/report_alert.py b/hosts/templatetags/report_alert.py index 48d8f966..09b906b3 100644 --- a/hosts/templatetags/report_alert.py +++ b/hosts/templatetags/report_alert.py @@ -17,7 +17,6 @@ from datetime import timedelta from django.template import Library -from django.templatetags.static import static from django.utils import timezone from django.utils.html import format_html @@ -29,12 +28,11 @@ @register.simple_tag def report_alert(lastreport): html = '' - alert_icon = static('img/icon-alert.gif') days = get_setting_of_type( setting_name='DAYS_WITHOUT_REPORT', setting_type=int, default=14, ) if lastreport < (timezone.now() - timedelta(days=days)): - html = f'Outdated Report' + html = '' return format_html(html) diff --git a/hosts/urls.py b/hosts/urls.py index b1521135..b3db0d54 100644 --- a/hosts/urls.py +++ b/hosts/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('', views.host_list, name='host_list'), + path('bulk_action/', views.host_bulk_action, name='host_bulk_action'), path('/', views.host_detail, name='host_detail'), path('/delete/', views.host_delete, name='host_delete'), path('/edit/', views.host_edit, name='host_edit'), diff --git a/hosts/views.py b/hosts/views.py index 8f20ab19..7d969888 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -17,10 +17,10 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Q +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django_tables2 import RequestConfig from rest_framework import viewsets from taggit.models import Tag @@ -29,14 +29,57 @@ from hosts.forms import EditHostForm from hosts.models import Host, HostRepo from hosts.serializers import HostRepoSerializer, HostSerializer +from hosts.tables import HostTable from operatingsystems.models import OSRelease, OSVariant from reports.models import Report +from util import sanitize_filter_params from util.filterspecs import Filter, FilterBar +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() + + if 'domain_id' in params: + hosts = hosts.filter(domain=params['domain_id'][0]) + if 'package_id' in params: + hosts = hosts.filter(packages=params['package_id'][0]) + if 'package' in params: + hosts = hosts.filter(packages__name__name=params['package'][0]) + if 'repo_id' in params: + hosts = hosts.filter(repos=params['repo_id'][0]) + if 'arch_id' in params: + hosts = hosts.filter(arch=params['arch_id'][0]) + if 'osvariant_id' in params: + hosts = hosts.filter(osvariant=params['osvariant_id'][0]) + if 'osrelease_id' in params: + hosts = hosts.filter(osvariant__osrelease=params['osrelease_id'][0]) + if 'tag' in params: + hosts = hosts.filter(tags__name__in=[params['tag'][0]]) + if 'reboot_required' in params: + reboot_required = params['reboot_required'][0] == 'true' + hosts = hosts.filter(reboot_required=reboot_required) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(hostname__icontains=term) + query = query & q + hosts = hosts.filter(query) + + return hosts + + @login_required def host_list(request): - hosts = Host.objects.select_related() + hosts = Host.objects.select_related().annotate( + sec_updates_count=Count('updates', filter=Q(updates__security=True)), + bug_updates_count=Count('updates', filter=Q(updates__security=False)), + errata_count=Count('errata'), + ) if 'domain_id' in request.GET: hosts = hosts.filter(domain=request.GET['domain_id']) @@ -76,16 +119,6 @@ def host_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(hosts, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] tags = {} for tag in Tag.objects.all(): @@ -99,11 +132,23 @@ def host_list(request): filter_list.append(Filter(request, 'Reboot Required', 'reboot_required', {'true': 'Yes', 'false': 'No'})) filter_bar = FilterBar(request, filter_list) + table = HostTable(hosts) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + filter_params = sanitize_filter_params(request.GET.urlencode()) + bulk_actions = [ + {'value': 'find_updates', 'label': 'Find Updates'}, + {'value': 'delete', 'label': 'Delete'}, + ] + return render(request, 'hosts/host_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, - 'terms': terms}) + 'terms': terms, + 'total_count': hosts.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -178,6 +223,52 @@ def host_find_updates(request, hostname): return redirect(host.get_absolute_url()) +@login_required +def host_bulk_action(request): + """Handle bulk actions on hosts.""" + if request.method != 'POST': + return redirect('hosts:host_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = sanitize_filter_params(request.POST.get('filter_params', '')) + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('hosts:host_list')}?{filter_params}") + return redirect('hosts:host_list') + + if select_all_filtered: + hosts = _get_filtered_hosts(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No hosts selected') + if filter_params: + return redirect(f"{reverse('hosts:host_list')}?{filter_params}") + return redirect('hosts:host_list') + hosts = Host.objects.filter(id__in=selected_ids) + + count = hosts.count() + 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') + elif action == 'delete': + hosts.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + if filter_params: + return redirect(f"{reverse('hosts:host_list')}?{filter_params}") + return redirect('hosts:host_list') + + class HostViewSet(viewsets.ModelViewSet): """ API endpoint that allows hosts to be viewed or edited. diff --git a/modules/tables.py b/modules/tables.py new file mode 100644 index 00000000..90811c78 --- /dev/null +++ b/modules/tables.py @@ -0,0 +1,72 @@ +# 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 + +import django_tables2 as tables + +from modules.models import Module +from util.tables import BaseTable + +MODULE_NAME_TEMPLATE = '{{ record.name }}' +REPO_TEMPLATE = '{{ record.repo }}' +PACKAGES_TEMPLATE = ( + '' + '{{ record.packages.count }}' +) + + +class ModuleTable(BaseTable): + module_name = tables.TemplateColumn( + MODULE_NAME_TEMPLATE, + order_by='name', + verbose_name='Name', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + stream = tables.Column( + verbose_name='Stream', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + module_version = tables.Column( + accessor='version', + verbose_name='Version', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + context = tables.Column( + verbose_name='Context', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + repo = tables.TemplateColumn( + REPO_TEMPLATE, + verbose_name='Repo', + orderable=False, + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + module_packages = tables.TemplateColumn( + PACKAGES_TEMPLATE, + verbose_name='Packages', + orderable=False, + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + enabled_on_hosts = tables.TemplateColumn( + '{{ record.host_set.count }}', + verbose_name='Enabled on Hosts', + orderable=False, + attrs={'th': {'class': 'col-md-auto'}, 'td': {'class': 'col-md-auto'}}, + ) + + class Meta(BaseTable.Meta): + model = Module + fields = ( + 'module_name', 'stream', 'module_version', 'context', + 'repo', 'module_packages', 'enabled_on_hosts', + ) diff --git a/modules/templates/modules/module_table.html b/modules/templates/modules/module_table.html deleted file mode 100644 index cda47ea3..00000000 --- a/modules/templates/modules/module_table.html +++ /dev/null @@ -1,27 +0,0 @@ -{% load common %} - - - - - - - - - - - - - - {% for module in object_list %} - - - - - - - - - - {% endfor %} - -
    NameStreamVersionContextRepoPackagesEnabled on Hosts
    {{ module.name }}{{ module.stream }}{{ module.version }}{{ module.context }}{{ module.repo }}{{ module.packages.count }}{{ module.host_set.count }}
    diff --git a/modules/views.py b/modules/views.py index 2d017220..45703f29 100644 --- a/modules/views.py +++ b/modules/views.py @@ -15,13 +15,14 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django_tables2 import RequestConfig from rest_framework import permissions, viewsets from modules.models import Module from modules.serializers import ModuleSerializer +from modules.tables import ModuleTable @login_required @@ -39,19 +40,12 @@ def module_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(modules, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) + table = ModuleTable(modules) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'modules/module_list.html', - {'page': page, + {'table': table, 'terms': terms}) diff --git a/operatingsystems/tables.py b/operatingsystems/tables.py new file mode 100644 index 00000000..0be5b77d --- /dev/null +++ b/operatingsystems/tables.py @@ -0,0 +1,167 @@ +# 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 + +import django_tables2 as tables + +from operatingsystems.models import OSRelease, OSVariant +from util.tables import BaseTable + +CHECKBOX_TEMPLATE = '' +SELECT_ALL_CHECKBOX = '' + +# OSReleaseTable templates +OSRELEASE_NAME_TEMPLATE = '{{ record.name }}' +OSRELEASE_REPOS_TEMPLATE = ( + '' + '{{ record.repos.count }}' +) +OSVARIANTS_TEMPLATE = ( + '' + '{{ record.osvariant_set.count }}' +) +OSRELEASE_HOSTS_TEMPLATE = ( + '{% load common %}' + '{% host_count record %}' +) +OSRELEASE_ERRATA_TEMPLATE = ( + '' + '{{ record.erratum_set.count }}' +) + +# OSVariantTable templates +OSVARIANT_NAME_TEMPLATE = '{{ record }}' +OSVARIANT_CODENAME_TEMPLATE = ( + '{% if record.codename %}{{ record.codename }}' + '{% else %}{% if record.osrelease %}{{ record.osrelease.codename }}{% endif %}{% endif %}' +) +OSVARIANT_HOSTS_TEMPLATE = ( + '' + '{{ record.host_set.count }}' +) +OSVARIANT_OSRELEASE_TEMPLATE = ( + '{% if record.osrelease %}' + '{{ record.osrelease }}' + '{% endif %}' +) +REPOS_OSRELEASE_TEMPLATE = ( + '{% if record.osrelease.repos.count != None %}{{ record.osrelease.repos.count }}{% else %}0{% endif %}' +) + + +class OSReleaseTable(BaseTable): + selection = tables.TemplateColumn( + CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + osrelease_name = tables.TemplateColumn( + OSRELEASE_NAME_TEMPLATE, + order_by='name', + verbose_name='OS Release', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + cpe_name = tables.Column( + verbose_name='CPE Name', + default='', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + osrelease_codename = tables.Column( + accessor='codename', + verbose_name='Codename', + default='', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osrelease_repos = tables.TemplateColumn( + OSRELEASE_REPOS_TEMPLATE, + verbose_name='Repos', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osvariants = tables.TemplateColumn( + OSVARIANTS_TEMPLATE, + verbose_name='OS Variants', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osrelease_hosts = tables.TemplateColumn( + OSRELEASE_HOSTS_TEMPLATE, + verbose_name='Hosts', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osrelease_errata = tables.TemplateColumn( + OSRELEASE_ERRATA_TEMPLATE, + verbose_name='Errata', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = OSRelease + fields = ( + 'selection', 'osrelease_name', 'cpe_name', 'osrelease_codename', 'osrelease_repos', + 'osvariants', 'osrelease_hosts', 'osrelease_errata', + ) + + +class OSVariantTable(BaseTable): + selection = tables.TemplateColumn( + CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + osvariant_name = tables.TemplateColumn( + OSVARIANT_NAME_TEMPLATE, + order_by='name', + verbose_name='Name', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + osvariant_arch = tables.Column( + accessor='arch__name', + verbose_name='Architecture', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osvariant_codename = tables.TemplateColumn( + OSVARIANT_CODENAME_TEMPLATE, + order_by='codename', + verbose_name='Codename', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osvariant_hosts = tables.TemplateColumn( + OSVARIANT_HOSTS_TEMPLATE, + verbose_name='Hosts', + order_by='hosts_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + osrelease = tables.TemplateColumn( + OSVARIANT_OSRELEASE_TEMPLATE, + order_by='osrelease__name', + verbose_name='OS Release', + attrs={'th': {'class': 'col-sm-4'}, 'td': {'class': 'col-sm-4'}}, + ) + repos_osrelease = tables.TemplateColumn( + REPOS_OSRELEASE_TEMPLATE, + verbose_name='Repos (OS Release)', + order_by='repos_count', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = OSVariant + fields = ( + 'selection', 'osvariant_name', 'osvariant_arch', 'osvariant_codename', + 'osvariant_hosts', 'osrelease', 'repos_osrelease', + ) diff --git a/operatingsystems/templates/operatingsystems/operatingsystemrelease_table.html b/operatingsystems/templates/operatingsystems/operatingsystemrelease_table.html deleted file mode 100644 index 6a7eae13..00000000 --- a/operatingsystems/templates/operatingsystems/operatingsystemrelease_table.html +++ /dev/null @@ -1,27 +0,0 @@ -{% load common %} - - - - - - - - - - - - - - {% for osrelease in object_list %} - - - - - - - - - - {% endfor %} - -
    OS ReleaseCPE NameCodenameReposOS VariantsHostsErrata
    {{ osrelease.name }}{% if osrelease.cpe_name %}{{ osrelease.cpe_name }}{% endif %}{% if osrelease.codename %}{{ osrelease.codename }}{% endif %}{{ osrelease.repos.count }}{{ osrelease.osvariant_set.count }}{% host_count osrelease %}{{ osrelease.erratum_set.count }}
    diff --git a/operatingsystems/templates/operatingsystems/operatingsystemvariant_table.html b/operatingsystems/templates/operatingsystems/operatingsystemvariant_table.html deleted file mode 100644 index 3ef8403f..00000000 --- a/operatingsystems/templates/operatingsystems/operatingsystemvariant_table.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load common %} - - - - - - - - - - - - - {% for osvariant in object_list %} - - - - - - - - - {% endfor %} - -
    NameArchitectureCodenameHostsOS ReleaseRepos (OS Release)
    {{ osvariant }}{{ osvariant.arch }}{% if osvariant.codename %}{{ osvariant.codename }}{% else %}{% if osvariant.osrelease %}{{ osvariant.osrelease.codename }}{% endif %}{% endif %}{{ osvariant.host_set.count }}{% if osvariant.osrelease %}{{ osvariant.osrelease }}{% endif %}{% if osvariant.osrelease.repos.count != None %}{{ osvariant.osrelease.repos.count }}{% else %}0{% endif %}
    diff --git a/operatingsystems/templates/operatingsystems/osrelease_list.html b/operatingsystems/templates/operatingsystems/osrelease_list.html index 1dfc80e1..95b570e0 100644 --- a/operatingsystems/templates/operatingsystems/osrelease_list.html +++ b/operatingsystems/templates/operatingsystems/osrelease_list.html @@ -1,7 +1,26 @@ -{% extends "objectlist.html" %} +{% extends "base.html" %} +{% load django_tables2 common %} {% block page_title %}OS Releases{% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Operating Systems
  • OS Releases
  • {% endblock %} {% block content_title %} OS Releases {% endblock %} + +{% block content %} +
    +
    + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    +
    +{% endblock %} diff --git a/operatingsystems/templates/operatingsystems/osvariant_list.html b/operatingsystems/templates/operatingsystems/osvariant_list.html index b83ede5f..f72274d4 100644 --- a/operatingsystems/templates/operatingsystems/osvariant_list.html +++ b/operatingsystems/templates/operatingsystems/osvariant_list.html @@ -1,6 +1,5 @@ -{% extends "objectlist.html" %} - -{% load common bootstrap3 %} +{% extends "base.html" %} +{% load django_tables2 common bootstrap3 %} {% block page_title %}OS Variants{% endblock %} @@ -8,12 +7,26 @@ {% block breadcrumbs %} {{ block.super }}
  • Operating Systems
  • OS Variants
  • {% endblock %} -{% block objectlist_actions %} +{% block content %} +
    +
    + {% if user.is_authenticated and perms.is_admin and nohost_osvariants %} + + {% endif %} -{% if user.is_authenticated and perms.is_admin and nohost_osvariants %} - -{% endif %} + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    +
    {% endblock %} diff --git a/operatingsystems/urls.py b/operatingsystems/urls.py index df194c9d..f58ed5ec 100644 --- a/operatingsystems/urls.py +++ b/operatingsystems/urls.py @@ -24,10 +24,12 @@ urlpatterns = [ path('', views.os_landing, name='os_landing'), path('variants/', views.osvariant_list, name='osvariant_list'), + path('variants/bulk_action/', views.osvariant_bulk_action, name='osvariant_bulk_action'), path('variants//', views.osvariant_detail, name='osvariant_detail'), path('variants//delete/', views.osvariant_delete, name='osvariant_delete'), path('variants/no_host/delete/', views.delete_nohost_osvariants, name='delete_nohost_osvariants'), path('releases/', views.osrelease_list, name='osrelease_list'), + path('releases/bulk_action/', views.osrelease_bulk_action, name='osrelease_bulk_action'), path('releases//', views.osrelease_detail, name='osrelease_detail'), path('releases//delete/', views.osrelease_delete, name='osrelease_delete'), ] diff --git a/operatingsystems/views.py b/operatingsystems/views.py index 6009f119..7dd4fcb5 100644 --- a/operatingsystems/views.py +++ b/operatingsystems/views.py @@ -17,10 +17,10 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator -from django.db.models import Q +from django.db.models import Count, Q from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django_tables2 import RequestConfig from rest_framework import viewsets from hosts.models import Host @@ -31,11 +31,56 @@ from operatingsystems.serializers import ( OSReleaseSerializer, OSVariantSerializer, ) +from operatingsystems.tables import OSReleaseTable, OSVariantTable +from util import sanitize_filter_params + + +def _get_filtered_osvariants(filter_params): + """Helper to reconstruct filtered queryset from filter params.""" + from urllib.parse import parse_qs + params = parse_qs(filter_params) + + osvariants = OSVariant.objects.select_related() + + if 'osrelease_id' in params: + osvariants = osvariants.filter(osrelease=params['osrelease_id'][0]) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(name__icontains=term) + query = query & q + osvariants = osvariants.filter(query) + + return osvariants + + +def _get_filtered_osreleases(filter_params): + """Helper to reconstruct filtered queryset from filter params.""" + from urllib.parse import parse_qs + params = parse_qs(filter_params) + + osreleases = OSRelease.objects.select_related() + + if 'erratum_id' in params: + osreleases = osreleases.filter(erratum=params['erratum_id'][0]) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(name__icontains=term) + query = query & q + osreleases = osreleases.filter(query) + + return osreleases @login_required def osvariant_list(request): - osvariants = OSVariant.objects.select_related() + osvariants = OSVariant.objects.select_related().annotate( + hosts_count=Count('host'), + repos_count=Count('osrelease__repos'), + ) if 'osrelease_id' in request.GET: osvariants = osvariants.filter(osrelease=request.GET['osrelease_id']) @@ -50,23 +95,24 @@ def osvariant_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(osvariants, 50) + nohost_osvariants = OSVariant.objects.filter(host__isnull=True).exists() - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) + table = OSVariantTable(osvariants) + RequestConfig(request, paginate={'per_page': 50}).configure(table) - nohost_osvariants = OSVariant.objects.filter(host__isnull=True).exists() + filter_params = sanitize_filter_params(request.GET.urlencode()) + bulk_actions = [ + {'value': 'delete', 'label': 'Delete'}, + ] return render(request, 'operatingsystems/osvariant_list.html', - {'page': page, + {'table': table, 'terms': terms, - 'nohost_osvariants': nohost_osvariants}) + 'nohost_osvariants': nohost_osvariants, + 'total_count': osvariants.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -151,20 +197,21 @@ def osrelease_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(osreleases, 50) + table = OSReleaseTable(osreleases) + RequestConfig(request, paginate={'per_page': 50}).configure(table) - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) + filter_params = sanitize_filter_params(request.GET.urlencode()) + bulk_actions = [ + {'value': 'delete', 'label': 'Delete'}, + ] return render(request, 'operatingsystems/osrelease_list.html', - {'page': page, - 'terms': terms}) + {'table': table, + 'terms': terms, + 'total_count': osreleases.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -214,6 +261,88 @@ def os_landing(request): return render(request, 'operatingsystems/os_landing.html') +@login_required +def osvariant_bulk_action(request): + """Handle bulk actions on OS variants.""" + if request.method != 'POST': + return redirect('operatingsystems:osvariant_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = request.POST.get('filter_params', '') + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('operatingsystems:osvariant_list')}?{filter_params}") + return redirect('operatingsystems:osvariant_list') + + if select_all_filtered: + osvariants = _get_filtered_osvariants(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No OS Variants selected') + if filter_params: + return redirect(f"{reverse('operatingsystems:osvariant_list')}?{filter_params}") + return redirect('operatingsystems:osvariant_list') + osvariants = OSVariant.objects.filter(id__in=selected_ids) + + count = osvariants.count() + name = OSVariant._meta.verbose_name if count == 1 else OSVariant._meta.verbose_name_plural + + if action == 'delete': + osvariants.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + if filter_params: + return redirect(f"{reverse('operatingsystems:osvariant_list')}?{filter_params}") + return redirect('operatingsystems:osvariant_list') + + +@login_required +def osrelease_bulk_action(request): + """Handle bulk actions on OS releases.""" + if request.method != 'POST': + return redirect('operatingsystems:osrelease_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = request.POST.get('filter_params', '') + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('operatingsystems:osrelease_list')}?{filter_params}") + return redirect('operatingsystems:osrelease_list') + + if select_all_filtered: + osreleases = _get_filtered_osreleases(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No OS Releases selected') + if filter_params: + return redirect(f"{reverse('operatingsystems:osrelease_list')}?{filter_params}") + return redirect('operatingsystems:osrelease_list') + osreleases = OSRelease.objects.filter(id__in=selected_ids) + + count = osreleases.count() + name = OSRelease._meta.verbose_name if count == 1 else OSRelease._meta.verbose_name_plural + + if action == 'delete': + osreleases.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + if filter_params: + return redirect(f"{reverse('operatingsystems:osrelease_list')}?{filter_params}") + return redirect('operatingsystems:osrelease_list') + + class OSVariantViewSet(viewsets.ModelViewSet): """ API endpoint that allows operating system variants to be viewed or edited. diff --git a/packages/tables.py b/packages/tables.py new file mode 100644 index 00000000..633c79a2 --- /dev/null +++ b/packages/tables.py @@ -0,0 +1,120 @@ +# 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 + +import django_tables2 as tables + +from packages.models import Package, PackageName +from util.tables import BaseTable + +PACKAGE_NAME_TEMPLATE = '{{ record }}' +PACKAGE_REPOS_TEMPLATE = ( + '' + 'Available from {{ record.repo_count }} Repositories' +) +PACKAGE_HOSTS_TEMPLATE = ( + '' + 'Installed on {{ record.host_set.count }} Hosts' +) +AFFECTED_TEMPLATE = ( + '' + 'Affected by {{ record.affected_by_erratum.count }} Errata' +) +FIXED_TEMPLATE = ( + '' + 'Provides fix in {{ record.provides_fix_in_erratum.count }} Errata' +) + + +class PackageTable(BaseTable): + package_name = tables.TemplateColumn( + PACKAGE_NAME_TEMPLATE, + order_by='name__name', + verbose_name='Package', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + epoch = tables.Column( + verbose_name='Epoch', + default='', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + package_version = tables.Column( + accessor='version', + verbose_name='Version', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + release = tables.Column( + verbose_name='Release', + default='', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + package_arch = tables.Column( + accessor='arch__name', + verbose_name='Arch', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + packagetype = tables.Column( + accessor='packagetype', + verbose_name='Type', + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + package_repos = tables.TemplateColumn( + PACKAGE_REPOS_TEMPLATE, + verbose_name='Repositories', + orderable=False, + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + package_hosts = tables.TemplateColumn( + PACKAGE_HOSTS_TEMPLATE, + verbose_name='Hosts', + orderable=False, + attrs={'th': {'class': 'col-sm-auto'}, 'td': {'class': 'col-sm-auto'}}, + ) + affected = tables.TemplateColumn( + AFFECTED_TEMPLATE, + verbose_name='Affected', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + fixed = tables.TemplateColumn( + FIXED_TEMPLATE, + verbose_name='Fixed', + orderable=False, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = Package + fields = ( + 'package_name', 'epoch', 'package_version', 'release', 'package_arch', + 'packagetype', 'package_repos', 'package_hosts', 'affected', 'fixed', + ) + + +class PackageNameTable(BaseTable): + packagename_name = tables.TemplateColumn( + PACKAGE_NAME_TEMPLATE, + order_by='name', + verbose_name='Package', + attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + ) + versions = tables.TemplateColumn( + '{{ record.package_set.count }}', + orderable=False, + verbose_name='Versions available', + attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + ) + + class Meta(BaseTable.Meta): + model = PackageName + fields = ('packagename_name', 'versions') diff --git a/packages/templates/packages/package_name_table.html b/packages/templates/packages/package_name_table.html deleted file mode 100644 index 39977d96..00000000 --- a/packages/templates/packages/package_name_table.html +++ /dev/null @@ -1,17 +0,0 @@ -{% load common %} - - - - - - - - - {% for packagename in object_list %} - - - - - {% endfor %} - -
    PackageVersions available
    {{ packagename }}{{ packagename.package_set.count }}
    diff --git a/packages/templates/packages/package_table.html b/packages/templates/packages/package_table.html deleted file mode 100644 index 06316521..00000000 --- a/packages/templates/packages/package_table.html +++ /dev/null @@ -1,30 +0,0 @@ -{% load common %} - - - - - - - - - - - - - - - - {% for package in object_list %} - - - - - - - - - - - {% endfor %} - -
    PackageEpochVersionReleaseArchTypeRepositoriesHostsErrata
    {{ package }} {{ package.epoch }} {{ package.version }} {{ package.release }} {{ package.arch }} {{ package.get_packagetype_display }} Available from {{ package.repo_count }} Repositories Installed on {{ package.host_set.count }} Hosts Affected by {{ package.affected_by_erratum.count }} Errata Provides fix in {{ package.provides_fix_in_erratum.count }} Errata
    diff --git a/packages/views.py b/packages/views.py index 413faee0..287c033d 100644 --- a/packages/views.py +++ b/packages/views.py @@ -16,9 +16,9 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django_tables2 import RequestConfig from rest_framework import viewsets from arch.models import PackageArchitecture @@ -26,6 +26,7 @@ from packages.serializers import ( PackageNameSerializer, PackageSerializer, PackageUpdateSerializer, ) +from packages.tables import PackageNameTable, PackageTable from util.filterspecs import Filter, FilterBar @@ -98,16 +99,6 @@ def package_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(packages, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'Affected by Errata', 'affected_by_errata', {'true': 'Yes', 'false': 'No'})) filter_list.append(Filter(request, 'Provides Fix in Errata', 'provides_fix_in_erratum', @@ -118,9 +109,12 @@ def package_list(request): filter_list.append(Filter(request, 'Architecture', 'arch_id', PackageArchitecture.objects.all())) filter_bar = FilterBar(request, filter_list) + table = PackageTable(packages) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + return render(request, 'packages/package_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, 'terms': terms}) @@ -145,27 +139,19 @@ def package_name_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(packages, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'Package Type', 'packagetype', Package.PACKAGE_TYPES)) filter_list.append(Filter(request, 'Architecture', 'arch_id', PackageArchitecture.objects.all())) filter_bar = FilterBar(request, filter_list) + table = PackageNameTable(packages) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + return render(request, 'packages/package_name_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, - 'terms': terms, - 'table_template': 'packages/package_name_table.html'}) + 'terms': terms}) @login_required diff --git a/patchman/settings.py b/patchman/settings.py index c3089caa..1553b247 100644 --- a/patchman/settings.py +++ b/patchman/settings.py @@ -48,6 +48,7 @@ 'django.template.context_processors.static', 'django.template.context_processors.tz', 'django.contrib.messages.context_processors.messages', + 'util.context_processors.issues_count', ], 'debug': DEBUG, }, @@ -79,6 +80,7 @@ 'django_extensions', 'taggit', 'bootstrap3', + 'django_tables2', 'rest_framework', 'django_filters', 'celery', @@ -108,6 +110,8 @@ TAGGIT_CASE_INSENSITIVE = True +DJANGO_TABLES2_TEMPLATE = 'table.html' + CELERY_BROKER_URL = 'redis://127.0.0.1:6379/0' CELERY_BROKER_TRANSPORT_OPTIONS = { 'queue_order_strategy': 'priority', diff --git a/patchman/sqlite3/base.py b/patchman/sqlite3/base.py index 308e0563..c7ba0c6f 100644 --- a/patchman/sqlite3/base.py +++ b/patchman/sqlite3/base.py @@ -1,3 +1,17 @@ +# 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 + # temporary fix for 'database is locked' error on sqlite3 # can be removed when using django 5.1 and BEGIN IMMEDIATE in OPTIONS # see https://blog.pecar.me/django-sqlite-dblock for more details diff --git a/patchman/static/css/base.css b/patchman/static/css/base.css index 2ae0e5e1..0db76909 100644 --- a/patchman/static/css/base.css +++ b/patchman/static/css/base.css @@ -9,5 +9,21 @@ .label-brick { font-size: 12px; font-weight: normal; margin-bottom: 3px; padding-top: 5px; border-radius: 4px; height:25px; border-style:solid; border-width:thin; border-color: #222; white-space: normal; display: inline-block; } .breadcrumb { font-size: 12px; background-color: #222; border-radius: 0; margin-bottom: 3px; } .navbar { margin-bottom: 0; padding-bottom: 0; border-radius: 0; } +.navbar-inverse .dropdown-menu { background-color: #222; } +.navbar-inverse .dropdown-menu > li > a { color: #9d9d9d; } +.navbar-inverse .dropdown-menu > li > a:hover { background-color: #333; color: #fff; } .well-sm { margin-bottom: 0; } .centered { text-align: center; } +th.min-width-col, td.min-width-col { width: 1%; white-space: nowrap; padding: 5px !important; } +.table td { vertical-align: bottom !important; } + +/* Center pagination controls produced by django-tables2 without centering table cell contents */ +.django-tables2 .pagination { + text-align: center; +} +/* Ensure pagination lists are centered and horizontally aligned in all contexts */ +.pagination { + display: flex; + justify-content: center; + align-items: center; +} diff --git a/patchman/static/img/icon-alert.gif b/patchman/static/img/icon-alert.gif deleted file mode 100644 index a1dde2625445b76d041ae02ccfcb83481ca63c5e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 145 zcmV;C0B-+BNk%w1VGsZi0J9GO|G@+Q!3O`;RR7pu|IkAJ%Ps%YPXF0v|INcdJ{u&=}=IXLDhr+J%S1nrq(gCL;wIgri4F* diff --git a/patchman/static/img/icon-no.gif b/patchman/static/img/icon-no.gif deleted file mode 100644 index 1b4ee5814570885705399533f1182f8b0491c5fb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 176 zcmZ?wbhEHb`H-TFR%!C^)o_GDj!gPtK1gc27Dv@$SQ0{~`FJvsmY diff --git a/patchman/static/img/icon-yes.gif b/patchman/static/img/icon-yes.gif deleted file mode 100644 index 73992827403791d6c1a75a079880e41dce7e0214..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 299 zcmZ?wbhEHbb?NhTQ$x_deWPc4O)NkN2|oXRf%p{M+wuUw(Z# z`TWGXJ8Mf07p=Or^7yl3mtJ2C+~V)C-fh~&DX}}E_C4PF@Y93ee}B)tGUw-?pC_Il zZ#vO%{oS?y|Nqw=uUUR`+4?){5_iQh&Q{xM6OkFieY2o T4)tf0@^WEj=4)bdWUvMRbX#E6 diff --git a/patchman/static/js/ajax-jquery.js b/patchman/static/js/ajax-jquery.js deleted file mode 100644 index af4c0729..00000000 --- a/patchman/static/js/ajax-jquery.js +++ /dev/null @@ -1,29 +0,0 @@ -function getCookie(name) { - var cookieValue = null; - if (document.cookie && document.cookie != '') { - var cookies = document.cookie.split(';'); - for (var i = 0; i < cookies.length; i++) { - var cookie = jQuery.trim(cookies[i]); - // Does this cookie string begin with the name we want? - if (cookie.substring(0, name.length + 1) == (name + '=')) { - cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); - break; - } - } - } - return cookieValue; -} - -var csrftoken = getCookie('csrftoken'); - -function csrfSafeMethod(method) { - // these HTTP methods do not require CSRF protection - return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); -} -$.ajaxSetup({ - beforeSend: function(xhr, settings) { - if (!csrfSafeMethod(settings.type) && !this.crossDomain) { - xhr.setRequestHeader("X-CSRFToken", csrftoken); - } - } -}); diff --git a/patchman/static/js/button-post.js b/patchman/static/js/button-post.js deleted file mode 100644 index ad19698f..00000000 --- a/patchman/static/js/button-post.js +++ /dev/null @@ -1,23 +0,0 @@ -function repo_toggle_enabled(id, element, e) { - e.preventDefault(); - var url = id + "toggle_enabled/"; - $.post(url); - if (element.innerHTML.indexOf("icon-no.gif") > -1) { - var newHTML = element.innerHTML.replace("icon-no.gif", "icon-yes.gif").replace("Disabled", "Enabled"); - } else { - var newHTML = element.innerHTML.replace("icon-yes.gif", "icon-no.gif").replace("Enabled", "Disabled"); - } - element.innerHTML = newHTML; -} - -function repo_toggle_security(id, element, e) { - e.preventDefault(); - var url = id + "toggle_security/"; - $.post(url); - if (element.innerHTML.indexOf("icon-no.gif") > -1) { - var newHTML = element.innerHTML.replace("icon-no.gif", "icon-yes.gif").replace("Non-Security", "Security"); - } else { - var newHTML = element.innerHTML.replace("icon-yes.gif", "icon-no.gif").replace("Security", "Non-Security"); - } - element.innerHTML = newHTML; -} diff --git a/patchman/urls.py b/patchman/urls.py index 2ae64f56..2b9a9787 100644 --- a/patchman/urls.py +++ b/patchman/urls.py @@ -1,7 +1,7 @@ # Copyright 2012 VPAC, http://www.vpac.org # Copyright 2013-2021 Marcus Furlong # -# This file is part of patchman +# 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 diff --git a/reports/tables.py b/reports/tables.py new file mode 100644 index 00000000..52f077e0 --- /dev/null +++ b/reports/tables.py @@ -0,0 +1,60 @@ +# 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 + +import django_tables2 as tables + +from reports.models import Report +from util.tables import BaseTable + +CHECKBOX_TEMPLATE = '' +SELECT_ALL_CHECKBOX = '' +REPORT_ID_TEMPLATE = '{{ record.id }}' +PROCESSED_TEMPLATE = '{% load common %}{% yes_no_img record.processed %}' + + +class ReportTable(BaseTable): + selection = tables.TemplateColumn( + CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + report_id = tables.TemplateColumn( + REPORT_ID_TEMPLATE, + order_by='id', + verbose_name='ID', + attrs={'th': {'class': 'min-width-col'}, 'td': {'class': 'min-width-col'}}, + ) + host = tables.Column( + accessor='host', + verbose_name='Host', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + created = tables.Column( + verbose_name='Created', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + report_ip = tables.Column( + verbose_name='IP Address', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + processed = tables.TemplateColumn( + PROCESSED_TEMPLATE, + verbose_name='Processed', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'centered col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = Report + fields = ('selection', 'report_id', 'host', 'created', 'report_ip', 'processed') diff --git a/reports/templates/reports/report_detail.html b/reports/templates/reports/report_detail.html index 247128bb..08d5005f 100644 --- a/reports/templates/reports/report_detail.html +++ b/reports/templates/reports/report_detail.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% load bootstrap3 %} +{% load bootstrap3 common %} {% block page_title %}Report - {{ report }} {% endblock %} @@ -39,7 +39,7 @@ Tags {{ report.tags }} Client Protocol {{ report.protocol }} User Agent {{ report.useragent }} - Has Been Processed? {{ report.processed }} + Has Been Processed? {% yes_no_img report.processed %} {% if user.is_authenticated and perms.is_admin %} {% bootstrap_icon "trash" %} Delete this Report diff --git a/reports/templates/reports/report_list.html b/reports/templates/reports/report_list.html index 8ca6b1a6..a66464b2 100644 --- a/reports/templates/reports/report_list.html +++ b/reports/templates/reports/report_list.html @@ -1,7 +1,37 @@ -{% extends "objectlist.html" %} +{% extends "base.html" %} +{% load django_tables2 common %} {% block page_title %}Reports{% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Reports
  • {% endblock %} {% block content_title %} Reports {% endblock %} + +{% block content %} +
    +
    + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    + + {% if filter_bar %} +
    +
    +
    Filter by...
    +
    + {{ filter_bar|safe }} +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/reports/templates/reports/report_table.html b/reports/templates/reports/report_table.html deleted file mode 100644 index 747c8613..00000000 --- a/reports/templates/reports/report_table.html +++ /dev/null @@ -1,23 +0,0 @@ -{% load common %} - - - - - - - - - - - - {% for report in object_list %} - - - - - - - - {% endfor %} - -
    IDHostTimeIP AddressProcessed
    {{ report.id }} {{ report.host }} {{ report.created }} {{ report.report_ip }} {% yes_no_img report.processed 'Processed' 'Not Processed' %}
    diff --git a/reports/urls.py b/reports/urls.py index 8826cc82..b419317d 100644 --- a/reports/urls.py +++ b/reports/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('', views.report_list, name='report_list'), + path('bulk_action/', views.report_bulk_action, name='report_bulk_action'), path('upload/', views.upload), path('/', views.report_detail, name='report_detail'), path('/delete/', views.report_delete, name='report_delete'), diff --git a/reports/views.py b/reports/views.py index f247deb2..c46b7a41 100644 --- a/reports/views.py +++ b/reports/views.py @@ -17,21 +17,46 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.db.utils import OperationalError from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.views.decorators.csrf import csrf_exempt +from django_tables2 import RequestConfig from tenacity import ( retry, retry_if_exception_type, stop_after_attempt, wait_exponential, ) from reports.models import Report +from reports.tables import ReportTable +from util import sanitize_filter_params from util.filterspecs import Filter, FilterBar +def _get_filtered_reports(filter_params): + """Helper to reconstruct filtered queryset from filter params.""" + from urllib.parse import parse_qs + params = parse_qs(filter_params) + + reports = Report.objects.select_related() + + if 'host_id' in params: + reports = reports.filter(hostname=params['host_id'][0]) + if 'processed' in params: + processed = params['processed'][0] == 'true' + reports = reports.filter(processed=processed) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(host__icontains=term) + query = query & q + reports = reports.filter(query) + + return reports + + @retry( retry=retry_if_exception_type(OperationalError), stop=stop_after_attempt(5), @@ -96,25 +121,27 @@ def report_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(reports, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'Processed', 'processed', {'true': 'Yes', 'false': 'No'})) filter_bar = FilterBar(request, filter_list) + table = ReportTable(reports) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + filter_params = sanitize_filter_params(request.GET.urlencode()) + bulk_actions = [ + {'value': 'process', 'label': 'Process'}, + {'value': 'delete', 'label': 'Delete'}, + ] + return render(request, 'reports/report_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, - 'terms': terms}) + 'terms': terms, + 'total_count': reports.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -158,3 +185,51 @@ def report_delete(request, report_id): return render(request, 'reports/report_delete.html', {'report': report}) + + +@login_required +def report_bulk_action(request): + """Handle bulk actions on reports.""" + if request.method != 'POST': + return redirect('reports:report_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = request.POST.get('filter_params', '') + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('reports:report_list')}?{filter_params}") + return redirect('reports:report_list') + + if select_all_filtered: + reports = _get_filtered_reports(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No reports selected') + if filter_params: + return redirect(f"{reverse('reports:report_list')}?{filter_params}") + return redirect('reports:report_list') + reports = Report.objects.filter(id__in=selected_ids) + + count = reports.count() + name = Report._meta.verbose_name if count == 1 else Report._meta.verbose_name_plural + + if action == 'process': + from reports.tasks import process_report + for report in reports: + report.processed = False + report.save() + process_report.delay(report.id) + messages.success(request, f'Queued {count} {name} for processing') + elif action == 'delete': + reports.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + if filter_params: + return redirect(f"{reverse('reports:report_list')}?{filter_params}") + return redirect('reports:report_list') diff --git a/repos/tables.py b/repos/tables.py new file mode 100644 index 00000000..8551822f --- /dev/null +++ b/repos/tables.py @@ -0,0 +1,165 @@ +# 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 + +import django_tables2 as tables + +from repos.models import Mirror, Repository +from util.tables import BaseTable + +# RepositoryTable templates +CHECKBOX_TEMPLATE = '' +SELECT_ALL_CHECKBOX = '' +REPO_NAME_TEMPLATE = '{{ record }}' +MIRRORS_TEMPLATE = ( + '' + '{{ record.mirror_set.count }}' +) +REPO_ENABLED_TEMPLATE = '{% load common %}{% yes_no_img record.enabled %}' +SECURITY_TEMPLATE = '{% load common %}{% yes_no_img record.security %}' +AUTH_REQUIRED_TEMPLATE = '{% if record.auth_required %}Yes{% else %}No{% endif %}' + +# MirrorTable templates +MIRROR_CHECKBOX_TEMPLATE = '' +MIRROR_ID_TEMPLATE = '{{ record.id }}' +MIRROR_URL_TEMPLATE = '{{ record.url|truncatechars:25 }}' +MIRROR_PACKAGES_TEMPLATE = ( + '{% if not record.mirrorlist %}' + '' + '{{ record.packages.count }}{% endif %}' +) +MIRROR_ENABLED_TEMPLATE = '{% load common %}{% yes_no_img record.enabled %}' +REFRESH_TEMPLATE = '{% load common %}{% yes_no_img record.refresh %}' +MIRRORLIST_TEMPLATE = '{% load common %}{% yes_no_img record.mirrorlist %}' +LAST_ACCESS_OK_TEMPLATE = '{% load common %}{% yes_no_img record.last_access_ok %}' +CHECKSUM_TEMPLATE = '{% if not record.mirrorlist %}{{ record.packages_checksum|truncatechars:16 }}{% endif %}' + + +class RepositoryTable(BaseTable): + selection = tables.TemplateColumn( + CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + repo_name = tables.TemplateColumn( + REPO_NAME_TEMPLATE, + order_by='name', + verbose_name='Repo Name', + attrs={'th': {'class': 'col-sm-4'}, 'td': {'class': 'col-sm-4'}}, + ) + repo_id = tables.Column( + verbose_name='Repo ID', + default='', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + mirrors = tables.TemplateColumn( + MIRRORS_TEMPLATE, + orderable=False, + verbose_name='Mirrors', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + repo_enabled = tables.TemplateColumn( + REPO_ENABLED_TEMPLATE, + orderable=False, + verbose_name='Enabled', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + security = tables.TemplateColumn( + SECURITY_TEMPLATE, + orderable=False, + verbose_name='Security', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + auth_required = tables.TemplateColumn( + AUTH_REQUIRED_TEMPLATE, + orderable=False, + verbose_name='Auth Required', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + + class Meta(BaseTable.Meta): + model = Repository + fields = ( + 'selection', 'repo_name', 'repo_id', 'mirrors', + 'repo_enabled', 'security', 'auth_required', + ) + + +class MirrorTable(BaseTable): + selection = tables.TemplateColumn( + MIRROR_CHECKBOX_TEMPLATE, + orderable=False, + verbose_name=SELECT_ALL_CHECKBOX, + attrs={'th': {'class': 'min-width-col centered'}, 'td': {'class': 'min-width-col centered'}}, + ) + mirror_id = tables.TemplateColumn( + MIRROR_ID_TEMPLATE, + order_by='id', + verbose_name='ID', + attrs={'th': {'class': 'min-width-col'}, 'td': {'class': 'min-width-col'}}, + ) + mirror_url = tables.TemplateColumn( + MIRROR_URL_TEMPLATE, + orderable=False, + verbose_name='URL', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + mirror_packages = tables.TemplateColumn( + MIRROR_PACKAGES_TEMPLATE, + order_by='packages_count', + verbose_name='Packages', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + mirror_enabled = tables.TemplateColumn( + MIRROR_ENABLED_TEMPLATE, + orderable=False, + verbose_name='Enabled', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + refresh = tables.TemplateColumn( + REFRESH_TEMPLATE, + orderable=False, + verbose_name='Refresh', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + mirrorlist = tables.TemplateColumn( + MIRRORLIST_TEMPLATE, + orderable=False, + verbose_name='Mirrorlist/Metalink', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + last_access_ok = tables.TemplateColumn( + LAST_ACCESS_OK_TEMPLATE, + orderable=False, + verbose_name='Last Access OK', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, + ) + timestamp = tables.Column( + order_by='timestamp', + verbose_name='Timestamp', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + checksum = tables.TemplateColumn( + CHECKSUM_TEMPLATE, + order_by='packages_checksum', + verbose_name='Checksum', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = Mirror + fields = ( + 'selection', 'mirror_id', 'mirror_url', 'mirror_packages', 'mirror_enabled', 'refresh', + 'mirrorlist', 'last_access_ok', 'timestamp', 'checksum', + ) diff --git a/repos/templates/repos/mirror_edit_repo.html b/repos/templates/repos/mirror_edit_repo.html deleted file mode 100644 index 1a785538..00000000 --- a/repos/templates/repos/mirror_edit_repo.html +++ /dev/null @@ -1,33 +0,0 @@ -{% load common %} - - - - - - - - - - - - - - - - - {% for mirror in object_list %} - - - - - - - - - - - - - {% endfor %} - -
    RepoIDURLPackagesEnabledRefreshMirrorlist/MetalinkLast Access OKTimestampChecksum
    {{ mirror.repo }}{{ mirror.id }}{{ mirror.url|truncatechars:25 }}{{ mirror.packages.count }}{% yes_no_img mirror.enabled 'Enabled' 'Not Enabled' %}{% yes_no_img mirror.refresh 'Yes' 'No' %}{% yes_no_img mirror.mirrorlist 'Yes' 'No' %}{% yes_no_img mirror.last_access_ok 'Yes' 'No' %}{{ mirror.timestamp }}{{ mirror.packages_checksum|truncatechars:16 }}
    diff --git a/repos/templates/repos/mirror_list.html b/repos/templates/repos/mirror_list.html index 5f6c32d4..e5143a2b 100644 --- a/repos/templates/repos/mirror_list.html +++ b/repos/templates/repos/mirror_list.html @@ -1,7 +1,26 @@ -{% extends "objectlist.html" %} +{% extends "base.html" %} +{% load django_tables2 common %} {% block page_title %}Mirrors{% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Repositories
  • Mirrors
  • {% endblock %} {% block content_title %} Mirrors {% endblock %} + +{% block content %} +
    +
    + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    +
    +{% endblock %} diff --git a/repos/templates/repos/mirror_table.html b/repos/templates/repos/mirror_table.html deleted file mode 100644 index e5b40129..00000000 --- a/repos/templates/repos/mirror_table.html +++ /dev/null @@ -1,39 +0,0 @@ -{% load common bootstrap3 %} - - - - - - - - - - - - - - - - - - {% for mirror in object_list %} - - - - - - - - - - - - - - {% endfor %} - -
    IDURLPackagesEnabledRefreshMirrorlist/MetalinkLast Access OKTimestampChecksumDeleteEdit
    {{ mirror.id }}{{ mirror.url|truncatechars:25 }} - {% if not mirror.mirrorlist %} - {{ mirror.packages.count }} - {% endif %} - {% yes_no_img mirror.enabled 'Enabled' 'Not Enabled' %}{% yes_no_img mirror.refresh 'Yes' 'No' %}{% yes_no_img mirror.mirrorlist 'Yes' 'No' %}{% yes_no_img mirror.last_access_ok 'Yes' 'No' %}{{ mirror.timestamp }}{% if not mirror.mirrorlist %}{{ mirror.packages_checksum|truncatechars:16 }}{% endif %}{% bootstrap_icon "trash" %} Delete this Mirror{% bootstrap_icon "edit" %} Edit this Mirror
    diff --git a/repos/templates/repos/mirror_with_repo_list.html b/repos/templates/repos/mirror_with_repo_list.html index ac3b5edf..352ecb3b 100644 --- a/repos/templates/repos/mirror_with_repo_list.html +++ b/repos/templates/repos/mirror_with_repo_list.html @@ -1,10 +1,10 @@ {% extends "repos/mirror_list.html" %} -{% load common bootstrap3 %} +{% load common bootstrap3 django_tables2 %} {% block content %} -{% gen_table page.object_list 'repos/mirror_edit_repo.html' %} +{% render_table table %} {% if user.is_authenticated and perms.is_admin %} {% if link_form and create_form %} diff --git a/repos/templates/repos/repo_list.html b/repos/templates/repos/repo_list.html index e3eab847..f32900b6 100644 --- a/repos/templates/repos/repo_list.html +++ b/repos/templates/repos/repo_list.html @@ -1,7 +1,37 @@ -{% extends "objectlist.html" %} +{% extends "base.html" %} +{% load django_tables2 common %} {% block page_title %}Repositories{% endblock %} {% block breadcrumbs %} {{ block.super }}
  • Repositories
  • {% endblock %} {% block content_title %} Repositories {% endblock %} + +{% block content %} +
    +
    + {% get_querydict request as querydict %} + {% searchform terms querydict %} + +
    + {% csrf_token %} + + + {% include "bulk_actions.html" %} + + {% render_table table %} +
    +
    + + {% if filter_bar %} +
    +
    +
    Filter by...
    +
    + {{ filter_bar|safe }} +
    +
    +
    + {% endif %} +
    +{% endblock %} diff --git a/repos/templates/repos/repository_table.html b/repos/templates/repos/repository_table.html deleted file mode 100644 index bcd7e721..00000000 --- a/repos/templates/repos/repository_table.html +++ /dev/null @@ -1,25 +0,0 @@ -{% load common repo_buttons %} - - - - - - - - - - - - - {% for repo in object_list %} - - - - - - - - - {% endfor %} - -
    Repo NameRepo IDMirrorsEnabledSecurityAuth Required
    {{ repo }}{% if repo.repo_id %} {{ repo.repo_id }} {% endif %} {{ repo.mirror_set.count }}
    {% yes_no_button_repo_en repo %}
    {% yes_no_button_repo_sec repo %}
    {% yes_no_img repo.auth_required %}
    diff --git a/repos/templatetags/repo_buttons.py b/repos/templatetags/repo_buttons.py deleted file mode 100644 index 3689c8b7..00000000 --- a/repos/templatetags/repo_buttons.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2012 VPAC, http://www.vpac.org -# Copyright 2013-2021 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.template import Library -from django.templatetags.static import static -from django.utils.html import format_html - -register = Library() - - -@register.simple_tag -def yes_no_button_repo_en(repo): - - repo_url = repo.get_absolute_url() - yes_icon = static('img/icon-yes.gif') - no_icon = static('img/icon-no.gif') - html = '' - return format_html(html) - - -@register.simple_tag -def yes_no_button_repo_sec(repo): - - repo_url = repo.get_absolute_url() - yes_icon = static('img/icon-yes.gif') - no_icon = static('img/icon-no.gif') - html = '' - return format_html(html) diff --git a/repos/urls.py b/repos/urls.py index 176f9a13..b736e5be 100644 --- a/repos/urls.py +++ b/repos/urls.py @@ -23,6 +23,7 @@ urlpatterns = [ path('', views.repo_list, name='repo_list'), + path('bulk_action/', views.repo_bulk_action, name='repo_bulk_action'), path('/', views.repo_detail, name='repo_detail'), path('/toggle_enabled/', views.repo_toggle_enabled, name='repo_toggle_enabled'), path('/toggle_security/', views.repo_toggle_security, name='repo_toggle_security'), @@ -30,6 +31,7 @@ path('/delete/', views.repo_delete, name='repo_delete'), path('/refresh/', views.repo_refresh, name='repo_refresh'), path('mirrors/', views.mirror_list, name='mirror_list'), + path('mirrors/bulk_action/', views.mirror_bulk_action, name='mirror_bulk_action'), path('mirrors/mirror//', views.mirror_detail, name='mirror_detail'), path('mirrors/mirror//edit/', views.mirror_edit, name='mirror_edit'), path('mirrors/mirror//delete/', views.mirror_delete, name='mirror_delete'), diff --git a/repos/views.py b/repos/views.py index 1f0c2bfa..2a45b668 100644 --- a/repos/views.py +++ b/repos/views.py @@ -17,12 +17,12 @@ from django.contrib import messages from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db import IntegrityError -from django.db.models import Q +from django.db.models import Count, Q from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django_tables2 import RequestConfig from rest_framework import viewsets from arch.models import MachineArchitecture @@ -35,6 +35,8 @@ from repos.serializers import ( MirrorPackageSerializer, MirrorSerializer, RepositorySerializer, ) +from repos.tables import MirrorTable, RepositoryTable +from util import sanitize_filter_params from util.filterspecs import Filter, FilterBar @@ -75,16 +77,6 @@ def repo_list(request): repos = repos.distinct() - page_no = request.GET.get('page') - paginator = Paginator(repos, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'OS Release', 'osrelease_id', OSRelease.objects.filter(repos__in=repos))) filter_list.append(Filter(request, 'Enabled', 'enabled', {'true': 'Yes', 'false': 'No'})) @@ -94,11 +86,29 @@ def repo_list(request): MachineArchitecture.objects.filter(repository__in=repos))) filter_bar = FilterBar(request, filter_list) + table = RepositoryTable(repos) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + # Build filter params string for "select all filtered" option + filter_params = sanitize_filter_params(request.GET.urlencode()) + + bulk_actions = [ + {'value': 'enable', 'label': 'Enable'}, + {'value': 'disable', 'label': 'Disable'}, + {'value': 'mark_security', 'label': 'Mark as Security'}, + {'value': 'mark_non_security', 'label': 'Mark as Non-Security'}, + {'value': 'refresh', 'label': 'Refresh'}, + {'value': 'delete', 'label': 'Delete'}, + ] + return render(request, 'repos/repo_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, - 'terms': terms}) + 'terms': terms, + 'total_count': repos.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -110,15 +120,19 @@ def pre_reqs(arch, repotype): text = 'Not all mirror architectures are the same,' text += ' cannot link to or create repos' messages.info(request, text) - return render(request, 'repos/mirror_with_repo_list.html', {'page': page, 'checksum': checksum}) + table = MirrorTable(mirrors) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + return render(request, 'repos/mirror_with_repo_list.html', {'table': table, 'checksum': checksum}) if mirror.repo.repotype != repotype: text = 'Not all mirror repotypes are the same,' text += ' cannot link to or create repos' messages.info(request, text) + table = MirrorTable(mirrors) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'repos/mirror_with_repo_list.html', - {'page': page, 'checksum': checksum}) + {'table': table, 'checksum': checksum}) return True def move_mirrors(repo): @@ -135,7 +149,9 @@ def move_mirrors(repo): if oldrepo.mirror_set.count() == 0: oldrepo.delete() - mirrors = Mirror.objects.select_related().order_by('packages_checksum') + mirrors = Mirror.objects.select_related().annotate( + packages_count=Count('packages'), + ).order_by('packages_checksum') checksum = None if 'checksum' in request.GET: @@ -160,16 +176,6 @@ def move_mirrors(repo): mirrors = mirrors.distinct() - page_no = request.GET.get('page') - paginator = Paginator(mirrors, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - if request.method == 'POST': arch = mirrors[0].repo.arch repotype = mirrors[0].repo.repotype @@ -208,15 +214,35 @@ def move_mirrors(repo): else: link_form = LinkRepoForm(prefix='link') create_form = CreateRepoForm(prefix='create') + table = MirrorTable(mirrors) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'repos/mirror_with_repo_list.html', - {'page': page, + {'table': table, 'link_form': link_form, 'create_form': create_form, 'checksum': checksum}) + + table = MirrorTable(mirrors) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + + filter_params = sanitize_filter_params(request.GET.urlencode()) + bulk_actions = [ + {'value': 'edit', 'label': 'Edit'}, + {'value': 'enable', 'label': 'Enable'}, + {'value': 'disable', 'label': 'Disable'}, + {'value': 'enable_refresh', 'label': 'Enable Refresh'}, + {'value': 'disable_refresh', 'label': 'Disable Refresh'}, + {'value': 'delete', 'label': 'Delete'}, + ] + return render(request, 'repos/mirror_list.html', - {'page': page}) + {'table': table, + 'terms': terms, + 'total_count': mirrors.count(), + 'filter_params': filter_params, + 'bulk_actions': bulk_actions}) @login_required @@ -348,7 +374,7 @@ def repo_toggle_enabled(request, repo_id): repo.enabled = True status = 'enabled' repo.save() - if request.is_ajax(): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return HttpResponse(status=204) else: text = f'Repository {repo} has been {status}' @@ -367,7 +393,7 @@ def repo_toggle_security(request, repo_id): repo.security = True sectype = 'security' repo.save() - if request.is_ajax(): + if request.headers.get('X-Requested-With') == 'XMLHttpRequest': return HttpResponse(status=204) else: text = f'Repository {repo} has been marked' @@ -388,6 +414,180 @@ def repo_refresh(request, repo_id): return redirect(repo.get_absolute_url()) +def _get_filtered_repos(filter_params): + """Helper to reconstruct filtered queryset from filter params.""" + from urllib.parse import parse_qs + params = parse_qs(filter_params) + + repos = Repository.objects.select_related().order_by('name') + + if 'repotype' in params: + repos = repos.filter(repotype=params['repotype'][0]) + if 'arch_id' in params: + repos = repos.filter(arch=params['arch_id'][0]) + if 'osrelease_id' in params: + repos = repos.filter(osrelease=params['osrelease_id'][0]) + if 'security' in params: + security = params['security'][0] == 'true' + repos = repos.filter(security=security) + if 'enabled' in params: + enabled = params['enabled'][0] == 'true' + repos = repos.filter(enabled=enabled) + if 'package_id' in params: + repos = repos.filter(mirror__packages=params['package_id'][0]) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(name__icontains=term) + query = query & q + repos = repos.filter(query) + + return repos.distinct() + + +@login_required +def repo_bulk_action(request): + """Handle bulk actions on repositories.""" + if request.method != 'POST': + return redirect('repos:repo_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = request.POST.get('filter_params', '') + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('repos:repo_list')}?{filter_params}") + return redirect('repos:repo_list') + + if select_all_filtered: + repos = _get_filtered_repos(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No repositories selected') + if filter_params: + return redirect(f"{reverse('repos:repo_list')}?{filter_params}") + return redirect('repos:repo_list') + repos = Repository.objects.filter(id__in=selected_ids) + + count = repos.count() + name = Repository._meta.verbose_name if count == 1 else Repository._meta.verbose_name_plural + + if action == 'enable': + repos.update(enabled=True) + messages.success(request, f'Enabled {count} {name}') + elif action == 'disable': + repos.update(enabled=False) + messages.success(request, f'Disabled {count} {name}') + elif action == 'mark_security': + repos.update(security=True) + messages.success(request, f'Marked {count} {name} as security') + elif action == 'mark_non_security': + repos.update(security=False) + messages.success(request, f'Marked {count} {name} as non-security') + elif action == 'refresh': + from repos.tasks import refresh_repo + for repo in repos: + refresh_repo.delay(repo.id) + messages.success(request, f'Queued {count} {name} for refresh') + elif action == 'delete': + repos.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + # Preserve filter params when redirecting + if filter_params: + return redirect(f"{reverse('repos:repo_list')}?{filter_params}") + return redirect('repos:repo_list') + + +def _get_filtered_mirrors(filter_params): + """Helper to reconstruct filtered queryset from filter params.""" + from urllib.parse import parse_qs + params = parse_qs(filter_params) + + mirrors = Mirror.objects.select_related().order_by('packages_checksum') + + if 'checksum' in params: + mirrors = mirrors.filter(packages_checksum=params['checksum'][0]) + if 'repo_id' in params: + mirrors = mirrors.filter(repo=params['repo_id'][0]) + if 'search' in params: + terms = params['search'][0].lower() + query = Q() + for term in terms.split(' '): + q = Q(url__icontains=term) + query = query & q + mirrors = mirrors.filter(query) + + return mirrors.distinct() + + +@login_required +def mirror_bulk_action(request): + """Handle bulk actions on mirrors.""" + if request.method != 'POST': + return redirect('repos:mirror_list') + + action = request.POST.get('action', '') + select_all_filtered = request.POST.get('select_all_filtered') == '1' + filter_params = request.POST.get('filter_params', '') + + if not action: + messages.warning(request, 'Please select an action') + if filter_params: + return redirect(f"{reverse('repos:mirror_list')}?{filter_params}") + return redirect('repos:mirror_list') + + if select_all_filtered: + mirrors = _get_filtered_mirrors(filter_params) + else: + selected_ids = request.POST.getlist('selected_ids') + if not selected_ids: + messages.warning(request, 'No mirrors selected') + if filter_params: + return redirect(f"{reverse('repos:mirror_list')}?{filter_params}") + return redirect('repos:mirror_list') + mirrors = Mirror.objects.filter(id__in=selected_ids) + + count = mirrors.count() + name = Mirror._meta.verbose_name if count == 1 else Mirror._meta.verbose_name_plural + + if action == 'edit': + if count != 1: + messages.warning(request, 'Please select exactly one mirror to edit') + if filter_params: + return redirect(f"{reverse('repos:mirror_list')}?{filter_params}") + return redirect('repos:mirror_list') + mirror = mirrors.first() + return redirect('repos:mirror_edit', mirror_id=mirror.id) + elif action == 'enable': + mirrors.update(enabled=True) + messages.success(request, f'Enabled {count} {name}') + elif action == 'disable': + mirrors.update(enabled=False) + messages.success(request, f'Disabled {count} {name}') + elif action == 'enable_refresh': + mirrors.update(refresh=True) + messages.success(request, f'Enabled refresh for {count} {name}') + elif action == 'disable_refresh': + mirrors.update(refresh=False) + messages.success(request, f'Disabled refresh for {count} {name}') + elif action == 'delete': + mirrors.delete() + messages.success(request, f'Deleted {count} {name}') + else: + messages.warning(request, 'Invalid action') + + if filter_params: + return redirect(f"{reverse('repos:mirror_list')}?{filter_params}") + return redirect('repos:mirror_list') + + class RepositoryViewSet(viewsets.ModelViewSet): """ API endpoint that allows repositories to be viewed or edited. diff --git a/requirements.txt b/requirements.txt index 08ce4573..92e421e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ django-celery-beat==2.7.0 tqdm==4.67.1 cvss==3.4 zstandard==0.25.0 +django-tables2==2.8.0 diff --git a/security/tables.py b/security/tables.py new file mode 100644 index 00000000..4d7272af --- /dev/null +++ b/security/tables.py @@ -0,0 +1,183 @@ +# 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 + +import django_tables2 as tables + +from security.models import CVE, CWE, Reference +from util.tables import BaseTable + +# CVETable templates +CVE_ID_TEMPLATE = '{{ record.cve_id }}' +CVE_LINKS_TEMPLATE = ( + '{% load bootstrap3 %}' + 'NIST {% bootstrap_icon "link" %}' + '  ' + 'MITRE {% bootstrap_icon "link" %}' + '  ' + 'osv.dev {% bootstrap_icon "link" %}' +) +CVE_DESCRIPTION_TEMPLATE = ( + '' + '{{ record.description|truncatechars:60 }}' +) +CVSS_SCORES_TEMPLATE = '{% for score in record.cvss_scores.all %} {{ score.score }} {% endfor %}' +CWES_TEMPLATE = '{% for cwe in record.cwes.all %} {{ cwe.cwe_id }} {% endfor %}' +CVE_ERRATA_TEMPLATE = ( + '' + '{{ record.erratum_set.count }}' +) + +# CWETable templates +CWE_ID_TEMPLATE = '{{ record.cwe_id }}' +CWE_DESCRIPTION_TEMPLATE = ( + '' + '{{ record.description|truncatechars:120 }}' +) +CWE_CVES_TEMPLATE = ( + '' + '{{ record.cve_set.count }}' +) + +# ReferenceTable templates +REFERENCE_URL_TEMPLATE = '{{ record.url }}' +LINKED_ERRATA_TEMPLATE = ( + '' + '{{ record.erratum_set.count }}' +) + + +class CVETable(BaseTable): + cve_id = tables.TemplateColumn( + CVE_ID_TEMPLATE, + order_by='cve_id', + verbose_name='CVE ID', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + links = tables.TemplateColumn( + CVE_LINKS_TEMPLATE, + orderable=False, + verbose_name='Links', + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + ) + cve_description = tables.TemplateColumn( + CVE_DESCRIPTION_TEMPLATE, + orderable=False, + verbose_name='Description', + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + ) + cvss_scores = tables.TemplateColumn( + CVSS_SCORES_TEMPLATE, + orderable=False, + verbose_name='CVSS Scores', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + cwes = tables.TemplateColumn( + CWES_TEMPLATE, + orderable=False, + verbose_name='CWEs', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + reserved_date = tables.DateColumn( + order_by='reserved_date', + verbose_name='Reserved', + default='', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + rejected_date = tables.DateColumn( + order_by='rejected_date', + verbose_name='Rejected', + default='', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + published_date = tables.DateColumn( + order_by='published_date', + verbose_name='Published', + default='', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + updated_date = tables.DateColumn( + order_by='updated_date', + verbose_name='Updated', + default='', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + cve_errata = tables.TemplateColumn( + CVE_ERRATA_TEMPLATE, + orderable=False, + verbose_name='Errata', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = CVE + fields = ( + 'cve_id', 'links', 'cve_description', 'cvss_scores', 'cwes', + 'reserved_date', 'rejected_date', 'published_date', 'updated_date', 'cve_errata', + ) + + +class CWETable(BaseTable): + cwe_id = tables.TemplateColumn( + CWE_ID_TEMPLATE, + order_by='cwe_id', + verbose_name='CWE ID', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + cwe_name = tables.Column( + accessor='name', + order_by='name', + verbose_name='Name', + default='', + attrs={'th': {'class': 'col-sm-4'}, 'td': {'class': 'col-sm-4'}}, + ) + cwe_description = tables.TemplateColumn( + CWE_DESCRIPTION_TEMPLATE, + orderable=False, + verbose_name='Description', + attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + ) + cwe_cves = tables.TemplateColumn( + CWE_CVES_TEMPLATE, + orderable=False, + verbose_name='CVEs', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = CWE + fields = ('cwe_id', 'cwe_name', 'cwe_description', 'cwe_cves') + + +class ReferenceTable(BaseTable): + ref_type = tables.Column( + order_by='ref_type', + verbose_name='Type', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + reference_url = tables.TemplateColumn( + REFERENCE_URL_TEMPLATE, + orderable=False, + verbose_name='URL', + attrs={'th': {'class': 'col-sm-10'}, 'td': {'class': 'col-sm-10'}}, + ) + linked_errata = tables.TemplateColumn( + LINKED_ERRATA_TEMPLATE, + orderable=False, + verbose_name='Linked Errata', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + + class Meta(BaseTable.Meta): + model = Reference + fields = ('ref_type', 'reference_url', 'linked_errata') diff --git a/security/templates/security/cve_table.html b/security/templates/security/cve_table.html deleted file mode 100644 index 63347b1e..00000000 --- a/security/templates/security/cve_table.html +++ /dev/null @@ -1,37 +0,0 @@ -{% load common bootstrap3 %} - - - - - - - - - - - - - - - - - {% for cve in object_list %} - - - - - - - - - - - - - {% endfor %} - -
    CVE IDLinksDescriptionCVSS ScoresCWEsReservedRejectedPublishedUpdatedErrata
    {{ cve.cve_id }} - NIST {% bootstrap_icon "link" %}   - MITRE {% bootstrap_icon "link" %}   - osv.dev {% bootstrap_icon "link" %} - {{ cve.description|truncatechars:60 }}{% for score in cve.cvss_scores.all %} {{ score.score }} {% endfor %}{% for cwe in cve.cwes.all %} {{ cwe.cwe_id }} {% endfor %}{{ cve.reserved_date|date|default_if_none:'' }}{{ cve.rejected_date|date|default_if_none:'' }}{{ cve.published_date|date|default_if_none:'' }}{{ cve.updated_date|date|default_if_none:'' }}{{ cve.erratum_set.count }}
    diff --git a/security/templates/security/cwe_table.html b/security/templates/security/cwe_table.html deleted file mode 100644 index 85ccd118..00000000 --- a/security/templates/security/cwe_table.html +++ /dev/null @@ -1,21 +0,0 @@ -{% load common %} - - - - - - - - - - - {% for cwe in object_list %} - - - - - - - {% endfor %} - -
    CWE IDNameDescriptionCVEs
    {{ cwe.cwe_id }}{{ cwe.name }}{{ cwe.description|truncatechars:120 }}{{ cwe.cve_set.count }}
    diff --git a/security/templates/security/reference_table.html b/security/templates/security/reference_table.html deleted file mode 100644 index a28ff719..00000000 --- a/security/templates/security/reference_table.html +++ /dev/null @@ -1,19 +0,0 @@ -{% load common %} - - - - - - - - - - {% for eref in object_list %} - - - - - - {% endfor %} - -
    TypeURLLinked Errata
    {{ eref.ref_type }}{{ eref.url }}{{ eref.erratum_set.count }}
    diff --git a/security/views.py b/security/views.py index c9e606a6..ae56a82b 100644 --- a/security/views.py +++ b/security/views.py @@ -15,9 +15,9 @@ # along with Patchman. If not, see from django.contrib.auth.decorators import login_required -from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator from django.db.models import Q from django.shortcuts import get_object_or_404, render +from django_tables2 import RequestConfig from rest_framework import viewsets from operatingsystems.models import OSRelease @@ -26,6 +26,7 @@ from security.serializers import ( CVESerializer, CWESerializer, ReferenceSerializer, ) +from security.tables import CVETable, CWETable, ReferenceTable from util.filterspecs import Filter, FilterBar @@ -45,19 +46,12 @@ def cwe_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(cwes, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) + table = CWETable(cwes) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'security/cwe_list.html', - {'page': page, + {'table': table, 'terms': terms}) @@ -95,19 +89,12 @@ def cve_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(cves, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) + table = CVETable(cves) + RequestConfig(request, paginate={'per_page': 50}).configure(table) return render(request, 'security/cve_list.html', - {'page': page, + {'table': table, 'terms': terms}) @@ -148,24 +135,17 @@ def reference_list(request): else: terms = '' - page_no = request.GET.get('page') - paginator = Paginator(refs, 50) - - try: - page = paginator.page(page_no) - except PageNotAnInteger: - page = paginator.page(1) - except EmptyPage: - page = paginator.page(paginator.num_pages) - filter_list = [] filter_list.append(Filter(request, 'Reference Type', 'ref_type', Reference.objects.values_list('ref_type', flat=True).distinct())) filter_bar = FilterBar(request, filter_list) + table = ReferenceTable(refs) + RequestConfig(request, paginate={'per_page': 50}).configure(table) + return render(request, 'security/reference_list.html', - {'page': page, + {'table': table, 'filter_bar': filter_bar, 'terms': terms}) diff --git a/setup.cfg b/setup.cfg index b1d5ee4e..a523584d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -9,6 +9,7 @@ requires = /usr/bin/python3 python3-django-bootstrap3 python3-django-rest-framework python3-django-filter + python3-django-tables2 python3-debian python3-rpm python3-tqdm diff --git a/util/__init__.py b/util/__init__.py index b85e5e37..4261804d 100644 --- a/util/__init__.py +++ b/util/__init__.py @@ -33,6 +33,7 @@ from enum import Enum from hashlib import md5, sha1, sha256, sha512 from time import time +from urllib.parse import parse_qs, urlencode from django.conf import settings from django.utils.dateparse import parse_datetime @@ -59,6 +60,14 @@ } +def sanitize_filter_params(filter_params): + """Sanitize filter_params to prevent query string injection.""" + if not filter_params: + return '' + parsed = parse_qs(filter_params) + return urlencode(parsed, doseq=True) + + def fetch_content(response, text='', ljust=35): """ Display a progress bar to fetch the request content if verbose is True. Otherwise, just return the request content diff --git a/util/context_processors.py b/util/context_processors.py new file mode 100644 index 00000000..f26c2748 --- /dev/null +++ b/util/context_processors.py @@ -0,0 +1,105 @@ +# Copyright 2013-2025 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 datetime import datetime, timedelta + +from django.db.models import F + +from hosts.models import Host +from operatingsystems.models import OSRelease, OSVariant +from reports.models import Report +from repos.models import Repository +from util import get_setting_of_type + + +def issues_count(request): + """Context processor to provide issues count for navbar.""" + if not request.user.is_authenticated: + return {'issues_count': 0} + + hosts = Host.objects.all() + osvariants = OSVariant.objects.all() + osreleases = OSRelease.objects.all() + repos = Repository.objects.all() + + # host issues + days = get_setting_of_type( + setting_name='DAYS_WITHOUT_REPORT', + setting_type=int, + default=14, + ) + last_report_delta = datetime.now() - timedelta(days=days) + stale_hosts = hosts.filter(lastreport__lt=last_report_delta) + norepo_hosts = hosts.filter(repos__isnull=True, osvariant__osrelease__repos__isnull=True) + reboot_hosts = hosts.filter(reboot_required=True) + secupdate_hosts = hosts.filter(updates__security=True, updates__isnull=False).distinct() + bugupdate_hosts = hosts.exclude( + updates__security=True, updates__isnull=False + ).distinct().filter( + updates__security=False, updates__isnull=False + ).distinct() + diff_rdns_hosts = hosts.exclude(reversedns=F('hostname')).filter(check_dns=True) + + # os variant issues + noosrelease_osvariants = osvariants.filter(osrelease__isnull=True) + nohost_osvariants = osvariants.filter(host__isnull=True) + + # os release issues + norepo_osreleases_count = 0 + if hosts.filter(host_repos_only=False).exists(): + norepo_osreleases_count = osreleases.filter(repos__isnull=True).count() + + # mirror issues + failed_mirrors = repos.filter( + auth_required=False, mirror__last_access_ok=False + ).filter(mirror__last_access_ok=True).distinct() + disabled_mirrors = repos.filter( + auth_required=False, mirror__enabled=False, mirror__mirrorlist=False + ).distinct() + norefresh_mirrors = repos.filter(auth_required=False, mirror__refresh=False).distinct() + + # repo issues + failed_repos = repos.filter( + auth_required=False, mirror__last_access_ok=False + ).exclude(id__in=[x.id for x in failed_mirrors]).distinct() + unused_repos = repos.filter(host__isnull=True, osrelease__isnull=True) + nomirror_repos = repos.filter(mirror__isnull=True) + nohost_repos = repos.filter(host__isnull=True) + + # report issues + unprocessed_reports = Report.objects.filter(processed=False) + + count = ( + (1 if stale_hosts.exists() else 0) + + (1 if norepo_hosts.exists() else 0) + + (1 if reboot_hosts.exists() else 0) + + (1 if secupdate_hosts.exists() else 0) + + (1 if bugupdate_hosts.exists() else 0) + + (1 if diff_rdns_hosts.exists() else 0) + + (1 if noosrelease_osvariants.exists() else 0) + + (1 if nohost_osvariants.exists() else 0) + + (1 if norepo_osreleases_count > 0 else 0) + + (1 if failed_mirrors.exists() else 0) + + (1 if disabled_mirrors.exists() else 0) + + (1 if norefresh_mirrors.exists() else 0) + + (1 if failed_repos.exists() else 0) + + (1 if unused_repos.exists() else 0) + + (1 if nomirror_repos.exists() else 0) + + (1 if nohost_repos.exists() else 0) + + (1 if unprocessed_reports.exists() else 0) + ) + + return {'issues_count': count} diff --git a/util/tables.py b/util/tables.py new file mode 100644 index 00000000..f755bb4b --- /dev/null +++ b/util/tables.py @@ -0,0 +1,26 @@ +# 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 + +import django_tables2 as tables + + +class BaseTable(tables.Table): + """Base table class with common settings for all patchman tables.""" + + class Meta: + abstract = True + template_name = 'table.html' + attrs = { + "class": "table table-striped table-bordered table-hover table-condensed table-responsive", + } diff --git a/util/templates/base.html b/util/templates/base.html index d732263c..17e952f3 100644 --- a/util/templates/base.html +++ b/util/templates/base.html @@ -8,8 +8,6 @@ {% load static %} {% block page_title %}{% endblock %} - - {% block extrahead %}{% endblock %} diff --git a/util/templates/bulk_actions.html b/util/templates/bulk_actions.html new file mode 100644 index 00000000..5dea6de9 --- /dev/null +++ b/util/templates/bulk_actions.html @@ -0,0 +1,90 @@ +{% load bootstrap3 %} +{% load common %} +{# Include this in list templates that support bulk actions #} +{# Required context: table, total_count, filter_params, bulk_actions (list of dicts with 'value' and 'label') #} + +
    +
    +
    + + +
    +
    + +
    +
    + + +
    +
    +
    + + diff --git a/util/templates/dashboard.html b/util/templates/dashboard.html index 631fea36..b1dea384 100644 --- a/util/templates/dashboard.html +++ b/util/templates/dashboard.html @@ -4,7 +4,7 @@ {% block page_title %}Patchman Dashboard{% endblock %} -{% block content_title %} Patch Management Dashboard for {{ site.name }} {% endblock %} +{% block content_title %} Issues Dashboard for {{ site.name }} {% endblock %} {% block content %} diff --git a/util/templates/navbar.html b/util/templates/navbar.html index 2a2edc0b..206f09fd 100644 --- a/util/templates/navbar.html +++ b/util/templates/navbar.html @@ -10,31 +10,46 @@ diff --git a/util/templates/objectlist.html b/util/templates/objectlist.html index f2b4fcf9..7d0601e7 100644 --- a/util/templates/objectlist.html +++ b/util/templates/objectlist.html @@ -1,24 +1,14 @@ {% extends "base.html" %} -{% load common bootstrap3 static %} +{% load common bootstrap3 static django_tables2 %} {% block content %}
    -
    +
    {% get_querydict request as querydict %} {% searchform terms querydict %} - {% gen_table page.object_list table_template %} -
    - {% object_count page %} -
    -
    - {% get_querystring request as querystring %} - {% bootstrap_pagination page size='small' extra=querystring %} -
    -
    - Page {{ page.number }} of {{ page.paginator.num_pages }} -
    + {% render_table table %}
    {% if filter_bar %} diff --git a/util/templates/table.html b/util/templates/table.html new file mode 100644 index 00000000..6084c681 --- /dev/null +++ b/util/templates/table.html @@ -0,0 +1,118 @@ +{% load django_tables2 %} +{% load i18n l10n %} +{% load common %} +{% block table-wrapper %} +
    + {% block table %} + + {% block table.thead %} + {% if table.show_header %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.thead %} + {% block table.tbody %} + + {% for row in table.paginated_rows %} + {% block table.tbody.row %} + + {% for column, cell in row.items %} + + {% endfor %} + + {% endblock table.tbody.row %} + {% empty %} + {% if table.empty_text %} + {% block table.tbody.empty_text %} + + {% endblock table.tbody.empty_text %} + {% endif %} + {% endfor %} + + {% endblock table.tbody %} + {% block table.tfoot %} + {% if table.has_footer %} + + + {% for column in table.columns %} + + {% endfor %} + + + {% endif %} + {% endblock table.tfoot %} +
    + {% if column.orderable %} + {{ column.header }}{% if column.is_ordered %}{% if column.order_by_alias.is_descending %} {% else %} {% endif %}{% endif %} + {% else %} + {{ column.header|safe }} + {% endif %} +
    {% if column.localize == None %}{{ cell }}{% else %}{% if column.localize %}{{ cell|localize }}{% else %}{{ cell|unlocalize }}{% endif %}{% endif %}
    {{ table.empty_text }}
    {{ column.footer }}
    + {% endblock table %} + + {% block object_count %} + {% if table.page %} +
    + {% object_count table %} +
    + {% endif %} + {% endblock object_count %} + + {% block pagination %} + {% if table.page %} +
    + +
    + {% endif %} + {% endblock pagination %} + + {% block page_info %} + {% if table.page %} +
    + Page {{ table.page.number }} of {{ table.paginator.num_pages }} +
    + {% endif %} + {% endblock page_info %} +
    +{% endblock table-wrapper %} diff --git a/util/templatetags/common.py b/util/templatetags/common.py index 674e1721..38d478d6 100644 --- a/util/templatetags/common.py +++ b/util/templatetags/common.py @@ -14,15 +14,14 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -import re +import importlib from datetime import datetime, timedelta from urllib.parse import urlencode -from django.core.paginator import Paginator from django.template import Library from django.template.loader import get_template -from django.templatetags.static import static from django.utils.html import format_html +from django_tables2 import RequestConfig from humanize import naturaltime from util import get_setting_of_type @@ -30,56 +29,75 @@ register = Library() -@register.simple_tag -def active(request, pattern): - if re.search(fr"^{request.META['SCRIPT_NAME']}/{pattern}", request.path): - return 'active' - return '' - - @register.simple_tag def yes_no_img(boolean, alt_yes='Active', alt_no='Not Active'): - yes_icon = static('img/icon-yes.gif') - no_icon = static('img/icon-no.gif') if boolean: - html = f'{alt_yes}' + html = f'' else: - html = f'{alt_no}' + html = f'' return format_html(html) @register.simple_tag def no_yes_img(boolean, alt_yes='Not Required', alt_no='Required'): - yes_icon = static('img/icon-yes.gif') - no_icon = static('img/icon-no.gif') if not boolean: - html = f'{alt_yes}' + html = f'' else: - html = f'{alt_no}' + html = f'' return format_html(html) -@register.simple_tag -def gen_table(object_list, template_name=None): +@register.simple_tag(takes_context=True) +def gen_table(context, object_list, template_name=None): + """Generate a django-tables2 table for non-paginated contexts (e.g., dashboard).""" if not object_list: return '' - if not template_name: - app_label = object_list.model._meta.app_label - model_name = object_list.model._meta.verbose_name.replace(' ', '') - template_name = f'{app_label}/{model_name.lower()}_table.html' - template = get_template(template_name) - html = template.render({'object_list': object_list}) - return html + + request = context.get('request') + + app_label = object_list.model._meta.app_label + model_name = object_list.model.__name__ + + app_mod = importlib.import_module(f"{app_label}.tables") + TableClass = getattr(app_mod, f"{model_name}Table") + + table = TableClass(object_list) + + # Exclude selection column for embedded tables (dashboard, detail pages) + if 'selection' in table.columns: + table.columns.hide('selection') + + # No pagination for dashboard/detail page tables + if request: + RequestConfig(request, paginate=False).configure(table) + + # Render using the table's configured template + from django.template import engines + django_engine = engines['django'] + template = django_engine.from_string('{% load django_tables2 %}{% render_table table %}') + return template.render({'table': table, 'request': request}) @register.simple_tag -def object_count(page): - if isinstance(page.paginator, Paginator): - if page.paginator.count == 1: - name = page.paginator.object_list.model._meta.verbose_name +def object_count(table): + """Return object count string for django-tables2 table.""" + if hasattr(table, 'paginator') and table.paginator: + count = table.paginator.count + if count == 1: + name = table.data.data.model._meta.verbose_name.title() else: - name = page.paginator.object_list.model._meta.verbose_name_plural - return f'{page.paginator.count} {name}' + name = table.data.data.model._meta.verbose_name_plural.title() + return f'{count} {name}' + return '' + + +@register.filter +def verbose_name_plural(table): + """Return the verbose_name_plural from a django-tables2 table's model.""" + try: + return table.data.data.model._meta.verbose_name_plural.title() + except AttributeError: + return '' @register.simple_tag diff --git a/util/urls.py b/util/urls.py index ea0b77fc..f5ceaed3 100644 --- a/util/urls.py +++ b/util/urls.py @@ -23,6 +23,6 @@ app_name = 'util' urlpatterns = [ - path('', RedirectView.as_view(pattern_name='util:dashboard', permanent=True)), # noqa + path('', RedirectView.as_view(pattern_name='hosts:host_list', permanent=True)), # noqa path('dashboard/', views.dashboard, name='dashboard'), ] From cd173bc30f4be2b72bf0206d2e82a2e87421404a Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:02:03 -0500 Subject: [PATCH 045/146] add django2-select2 functionality --- debian/control | 2 +- patchman/settings.py | 1 + patchman/static/css/base.css | 1 + patchman/urls.py | 1 + repos/forms.py | 25 +++++++++++++++++-------- repos/templates/repos/repo_edit.html | 4 ---- requirements.txt | 1 + setup.cfg | 1 + 8 files changed, 23 insertions(+), 13 deletions(-) diff --git a/debian/control b/debian/control index cd19139c..34610549 100644 --- a/debian/control +++ b/debian/control @@ -21,7 +21,7 @@ Depends: ${misc:Depends}, python3 (>= 3.11), python3-django (>= 4.2), python3-yaml, libapache2-mod-wsgi-py3, apache2, sqlite3, celery, python3-celery, python3-django-celery-beat, redis-server, python3-redis, python3-git, python3-django-taggit, python3-zstandard, - python3-django-tables2 + python3-django-tables2, python3-django-select2 Suggests: python3-mysqldb, python3-psycopg2, python3-pymemcache, memcached Description: Django-based patch status monitoring tool for linux systems. . diff --git a/patchman/settings.py b/patchman/settings.py index 1553b247..76c28b3f 100644 --- a/patchman/settings.py +++ b/patchman/settings.py @@ -81,6 +81,7 @@ 'taggit', 'bootstrap3', 'django_tables2', + 'django_select2', 'rest_framework', 'django_filters', 'celery', diff --git a/patchman/static/css/base.css b/patchman/static/css/base.css index 0db76909..45ed77c7 100644 --- a/patchman/static/css/base.css +++ b/patchman/static/css/base.css @@ -16,6 +16,7 @@ .centered { text-align: center; } th.min-width-col, td.min-width-col { width: 1%; white-space: nowrap; padding: 5px !important; } .table td { vertical-align: bottom !important; } +.select2-results__options { max-height: 400px !important; } /* Center pagination controls produced by django-tables2 without centering table cell contents */ .django-tables2 .pagination { diff --git a/patchman/urls.py b/patchman/urls.py index 2b9a9787..b66a0de3 100644 --- a/patchman/urls.py +++ b/patchman/urls.py @@ -56,6 +56,7 @@ path('admin/', admin.site.urls), path('api/', include(router.urls)), path('api-auth/', include('rest_framework.urls', namespace='rest_framework')), # noqa + path('select2/', include('django_select2.urls')), path('', include('util.urls', namespace='util')), path('errata/', include('errata.urls', namespace='errata')), path('reports/', include('reports.urls', namespace='reports')), diff --git a/repos/forms.py b/repos/forms.py index 9cb66897..d776cd00 100644 --- a/repos/forms.py +++ b/repos/forms.py @@ -15,26 +15,35 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from django.contrib.admin.widgets import FilteredSelectMultiple from django.forms import ( Form, ModelChoiceField, ModelForm, ModelMultipleChoiceField, TextInput, ValidationError, ) +from django_select2.forms import ModelSelect2MultipleWidget from repos.models import Mirror, Repository -class EditRepoForm(ModelForm): - class Media: - css = { - 'all': ('admin/css/widgets.css',) - } +class MirrorSelect2Widget(ModelSelect2MultipleWidget): + model = Mirror + search_fields = ['url__icontains', 'repo__name__icontains'] + max_results = 50 + def __init__(self, *args, **kwargs): + kwargs.setdefault('attrs', {}) + kwargs['attrs'].setdefault('data-minimum-input-length', 0) + super().__init__(*args, **kwargs) + + def label_from_instance(self, obj): + return f"{obj.repo.name} - {obj.url}" + + +class EditRepoForm(ModelForm): mirrors = ModelMultipleChoiceField( queryset=Mirror.objects.select_related(), required=False, - label=None, - widget=FilteredSelectMultiple('Mirrors', is_stacked=False)) + widget=MirrorSelect2Widget(attrs={'style': 'width: 100%'}), + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/repos/templates/repos/repo_edit.html b/repos/templates/repos/repo_edit.html index 0a160d8e..5a968896 100644 --- a/repos/templates/repos/repo_edit.html +++ b/repos/templates/repos/repo_edit.html @@ -3,10 +3,6 @@ {% load common bootstrap3 static %} {% block extrahead %} - - - - {{ edit_form.media }} {% endblock %} diff --git a/requirements.txt b/requirements.txt index 92e421e1..1bad175b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,4 @@ tqdm==4.67.1 cvss==3.4 zstandard==0.25.0 django-tables2==2.8.0 +django-select2==8.3.0 diff --git a/setup.cfg b/setup.cfg index a523584d..48415b86 100644 --- a/setup.cfg +++ b/setup.cfg @@ -10,6 +10,7 @@ requires = /usr/bin/python3 python3-django-rest-framework python3-django-filter python3-django-tables2 + python3-django-select2 python3-debian python3-rpm python3-tqdm From 83f96f3995a48e145179ee1b893940b46c631089 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:16:31 -0500 Subject: [PATCH 046/146] update host and package templates --- errata/templates/errata/erratum_detail.html | 16 ++-- hosts/tables.py | 18 ++++- hosts/templates/hosts/host_delete.html | 15 ++-- hosts/templates/hosts/host_detail.html | 85 ++++++++++++--------- hosts/views.py | 18 ++++- patchman/static/css/base.css | 6 +- repos/forms.py | 9 +-- security/templates/security/cve_detail.html | 20 ++--- util/context_processors.py | 5 +- util/templates/dashboard.html | 28 ++++--- 10 files changed, 123 insertions(+), 97 deletions(-) diff --git a/errata/templates/errata/erratum_detail.html b/errata/templates/errata/erratum_detail.html index 4738154e..2dbfceaf 100644 --- a/errata/templates/errata/erratum_detail.html +++ b/errata/templates/errata/erratum_detail.html @@ -61,24 +61,20 @@
    -
    +
    +
    -
    +
    +
    diff --git a/hosts/tables.py b/hosts/tables.py index 835d8195..e527af7b 100644 --- a/hosts/tables.py +++ b/hosts/tables.py @@ -41,6 +41,10 @@ '{{ record.osvariant }}' '{% endif %}' ) +PACKAGES_TEMPLATE = ( + '' + '{{ record.packages_count }}' +) LASTREPORT_TEMPLATE = ( '{% load report_alert %}' '{{ record.lastreport }} {% report_alert record.lastreport %}' @@ -87,13 +91,19 @@ class HostTable(BaseTable): OSVARIANT_TEMPLATE, order_by='osvariant__name', verbose_name='OS Variant', - attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + ) + packages_installed = tables.TemplateColumn( + PACKAGES_TEMPLATE, + order_by='packages_count', + verbose_name='Packages', + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) lastreport = tables.TemplateColumn( LASTREPORT_TEMPLATE, order_by='lastreport', verbose_name='Last Report', - attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) reboot_required = tables.TemplateColumn( REBOOT_TEMPLATE, @@ -105,6 +115,6 @@ class HostTable(BaseTable): class Meta(BaseTable.Meta): model = Host fields = ( - 'selection', 'hostname', 'sec_updates', 'bug_updates', 'affected_errata', - 'kernel', 'osvariant', 'lastreport', 'reboot_required', + 'selection', 'hostname', 'packages_installed', 'sec_updates', 'bug_updates', + 'affected_errata', 'kernel', 'osvariant', 'lastreport', 'reboot_required', ) diff --git a/hosts/templates/hosts/host_delete.html b/hosts/templates/hosts/host_delete.html index 5f37d8ab..2b665f24 100644 --- a/hosts/templates/hosts/host_delete.html +++ b/hosts/templates/hosts/host_delete.html @@ -30,18 +30,19 @@ Updated {{ host.updated_at }} Last Report {{ host.lastreport }} - Updates Available {{ host.updates.count }} + Packages Installed {{ host.packages.count}} + Updates Available {{ host.updates.count }} + Errata{{ host.errata.count }} Reboot Required {{ host.reboot_required }} - Packages Installed {{ host.packages.count}} Repos In Use{% if host.host_repos_only %}Host Repos{% else %}Host and OS Release Repos{% endif %} Last 3 reports - {% for report in reports %} - - {{ report.created }} - - {% endfor %} + diff --git a/hosts/templates/hosts/host_detail.html b/hosts/templates/hosts/host_detail.html index f12bf22b..01a5c546 100644 --- a/hosts/templates/hosts/host_detail.html +++ b/hosts/templates/hosts/host_detail.html @@ -12,10 +12,9 @@
    @@ -41,18 +40,18 @@ Updated {{ host.updated_at }} Last Report {{ host.lastreport }} Packages Installed {{ host.packages.count}} - Updates Available {{ host.updates.count }} + Updates Available {{ host.updates.count }} Errata{{ host.errata.count }} Reboot Required {{ host.reboot_required }} Repos In Use{% if host.host_repos_only %}Host Repos{% else %}Host and OS Release Repos{% endif %} Last 3 reports - {% for report in reports %} - - {{ report.created }} - - {% endfor %} + @@ -64,30 +63,38 @@
    -
    +
    - +
    + +
    +
    - + - {% for update in host.updates.select_related %} - - + {% endfor %} @@ -141,21 +148,27 @@ {% gen_table host.modules.all %} - -
    -
    - -
    -
    - {% for package in host.packages.select_related %} - - {{ package }} - - {% endfor %} -
    -
    -
    -
    + + {% endblock %} diff --git a/hosts/views.py b/hosts/views.py index 7d969888..b50f021c 100644 --- a/hosts/views.py +++ b/hosts/views.py @@ -76,9 +76,10 @@ 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)), - bug_updates_count=Count('updates', filter=Q(updates__security=False)), - errata_count=Count('errata'), + 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), ) if 'domain_id' in request.GET: @@ -156,11 +157,20 @@ def host_detail(request, hostname): host = get_object_or_404(Host, hostname=hostname) reports = Report.objects.filter(host=hostname).order_by('-created')[:3] 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()} + packages_with_updates = [] + for package in host.packages.select_related('name', 'arch').order_by('name__name'): + package.update = updates_by_package.get(package.id) + packages_with_updates.append(package) + return render(request, 'hosts/host_detail.html', {'host': host, 'reports': reports, - 'hostrepos': hostrepos}) + 'hostrepos': hostrepos, + 'packages_with_updates': packages_with_updates}) @login_required diff --git a/patchman/static/css/base.css b/patchman/static/css/base.css index 45ed77c7..6c54a642 100644 --- a/patchman/static/css/base.css +++ b/patchman/static/css/base.css @@ -5,8 +5,6 @@ .panel-body { padding: 5px; font-size: 12px; } .panel { margin-bottom: 5px; font-size: 12px; } .panel-heading { padding: 5px; font-size: 13px; } -.brick { border-radius: 5px; margin: 3px 3px; padding: 2px 5px; height: 25px; border-style:solid; border-width:thin; } -.label-brick { font-size: 12px; font-weight: normal; margin-bottom: 3px; padding-top: 5px; border-radius: 4px; height:25px; border-style:solid; border-width:thin; border-color: #222; white-space: normal; display: inline-block; } .breadcrumb { font-size: 12px; background-color: #222; border-radius: 0; margin-bottom: 3px; } .navbar { margin-bottom: 0; padding-bottom: 0; border-radius: 0; } .navbar-inverse .dropdown-menu { background-color: #222; } @@ -17,6 +15,10 @@ th.min-width-col, td.min-width-col { width: 1%; white-space: nowrap; padding: 5px !important; } .table td { vertical-align: bottom !important; } .select2-results__options { max-height: 400px !important; } +.package-list { display: flex; flex-wrap: wrap; gap: 6px; list-style: none; padding: 10px; margin: 0; } +.package-list li { padding: 4px 8px; white-space: nowrap; border: 1px solid #ddd; border-radius: 4px; } +.package-list li:nth-child(odd) { background: #f0f7ff; } +.package-list li:nth-child(even) { background: #f5f0ff; } /* Center pagination controls produced by django-tables2 without centering table cell contents */ .django-tables2 .pagination { diff --git a/repos/forms.py b/repos/forms.py index d776cd00..f9795b51 100644 --- a/repos/forms.py +++ b/repos/forms.py @@ -28,6 +28,7 @@ class MirrorSelect2Widget(ModelSelect2MultipleWidget): model = Mirror search_fields = ['url__icontains', 'repo__name__icontains'] max_results = 50 + queryset = Mirror.objects.select_related().order_by('repo__name', 'url') def __init__(self, *args, **kwargs): kwargs.setdefault('attrs', {}) @@ -40,7 +41,7 @@ def label_from_instance(self, obj): class EditRepoForm(ModelForm): mirrors = ModelMultipleChoiceField( - queryset=Mirror.objects.select_related(), + queryset=Mirror.objects.select_related().order_by('repo__name', 'url'), required=False, widget=MirrorSelect2Widget(attrs={'style': 'width: 100%'}), ) @@ -93,12 +94,6 @@ def clean_repotype(self): class EditMirrorForm(ModelForm): - class Media: - css = { - 'all': ('admin/css/widgets.css',) - } - js = ('animations.js', 'actions.js') - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['url'].widget = TextInput(attrs={'size': 150},) diff --git a/security/templates/security/cve_detail.html b/security/templates/security/cve_detail.html index 6c86197a..a41ea6c7 100644 --- a/security/templates/security/cve_detail.html +++ b/security/templates/security/cve_detail.html @@ -82,20 +82,20 @@
    - {% for package in affected_packages %} - - {{ package }} - - {% endfor %} +
      + {% for package in affected_packages %} +
    • {{ package }}
    • + {% endfor %} +
    - {% for package in fixed_packages %} - - {{ package }} - - {% endfor %} +
      + {% for package in fixed_packages %} +
    • {{ package }}
    • + {% endfor %} +
    diff --git a/util/context_processors.py b/util/context_processors.py index f26c2748..ca3fa427 100644 --- a/util/context_processors.py +++ b/util/context_processors.py @@ -14,9 +14,10 @@ # You should have received a copy of the GNU General Public License # along with Patchman. If not, see -from datetime import datetime, timedelta +from datetime import timedelta from django.db.models import F +from django.utils import timezone from hosts.models import Host from operatingsystems.models import OSRelease, OSVariant @@ -41,7 +42,7 @@ def issues_count(request): setting_type=int, default=14, ) - last_report_delta = datetime.now() - timedelta(days=days) + last_report_delta = timezone.now() - timedelta(days=days) stale_hosts = hosts.filter(lastreport__lt=last_report_delta) norepo_hosts = hosts.filter(repos__isnull=True, osvariant__osrelease__repos__isnull=True) reboot_hosts = hosts.filter(reboot_required=True) diff --git a/util/templates/dashboard.html b/util/templates/dashboard.html index b1dea384..699ecc56 100644 --- a/util/templates/dashboard.html +++ b/util/templates/dashboard.html @@ -189,11 +189,11 @@
    - {% for checksum in possible_mirrors %} - - {{ checksum }} - - {% endfor %} +
      + {% for checksum in possible_mirrors %} +
    • {{ checksum }}
    • + {% endfor %} +
    {% endif %} @@ -204,13 +204,11 @@
    -
    +
    +
    {% endif %} @@ -221,11 +219,11 @@
    - {% for package in orphaned_packages %} - - {{ package }} - - {% endfor %} +
      + {% for package in orphaned_packages %} +
    • {{ package }}
    • + {% endfor %} +
    {% endif %} From fa2c3e8a0cd2a0ceb17679f29d36db1e67e7e77a Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:18:02 -0500 Subject: [PATCH 047/146] prefer triangles over chevrons --- util/templates/table.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/util/templates/table.html b/util/templates/table.html index 6084c681..bc63d651 100644 --- a/util/templates/table.html +++ b/util/templates/table.html @@ -12,7 +12,7 @@ {% for column in table.columns %}
    - +
    InstalledAvailableInstalled PackageUpdate Available
    - {% if update.security %} - - {% else %} - + {% for package in packages_with_updates %} +
    + {% if package.update %} + {% if package.update.security %} + + {% else %} + + {% endif %} {% endif %} -   - {{ update.oldpackage }} + {{ package }} - {{ update.newpackage }} + {% if package.update %} + {{ package.update.newpackage }} + {% endif %}
    {% if column.orderable %} - {{ column.header }}{% if column.is_ordered %}{% if column.order_by_alias.is_descending %} {% else %} {% endif %}{% endif %} + {{ column.header }}{% if column.is_ordered %}{% if column.order_by_alias.is_descending %} {% else %} {% endif %}{% endif %} {% else %} {{ column.header|safe }} {% endif %} From 26c74a31bc7f9011437535fb91c2252920c13f52 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:23:44 -0500 Subject: [PATCH 048/146] fix table column alignments and cell contents --- errata/tables.py | 14 ++++++------- patchman/static/css/base.css | 4 ++++ patchman/static/js/expandable-text.js | 2 +- repos/tables.py | 4 ++-- security/tables.py | 30 +++++++++++---------------- 5 files changed, 26 insertions(+), 28 deletions(-) diff --git a/errata/tables.py b/errata/tables.py index c23d25be..d70be2cd 100644 --- a/errata/tables.py +++ b/errata/tables.py @@ -55,7 +55,7 @@ class ErratumTable(BaseTable): ERRATUM_NAME_TEMPLATE, order_by='name', verbose_name='ID', - attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) e_type = tables.Column( order_by='e_type', @@ -65,7 +65,7 @@ class ErratumTable(BaseTable): issue_date = tables.DateColumn( order_by='issue_date', verbose_name='Published Date', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) synopsis = tables.Column( orderable=False, @@ -76,31 +76,31 @@ class ErratumTable(BaseTable): PACKAGES_AFFECTED_TEMPLATE, orderable=False, verbose_name='Packages Affected', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) packages_fixed = tables.TemplateColumn( PACKAGES_FIXED_TEMPLATE, orderable=False, verbose_name='Packages Fixed', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) osreleases = tables.TemplateColumn( OSRELEASES_TEMPLATE, orderable=False, verbose_name='OS Releases Affected', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) erratum_cves = tables.TemplateColumn( ERRATUM_CVES_TEMPLATE, orderable=False, verbose_name='CVEs', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) references = tables.TemplateColumn( REFERENCES_TEMPLATE, orderable=False, verbose_name='References', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) class Meta(BaseTable.Meta): diff --git a/patchman/static/css/base.css b/patchman/static/css/base.css index 6c54a642..76c7dbc4 100644 --- a/patchman/static/css/base.css +++ b/patchman/static/css/base.css @@ -19,6 +19,10 @@ th.min-width-col, td.min-width-col { width: 1%; white-space: nowrap; padding: 5p .package-list li { padding: 4px 8px; white-space: nowrap; border: 1px solid #ddd; border-radius: 4px; } .package-list li:nth-child(odd) { background: #f0f7ff; } .package-list li:nth-child(even) { background: #f5f0ff; } +td.truncate-cell { max-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +td.truncate-cell a { display: block; overflow: hidden; text-overflow: ellipsis; } +.expandable-text { cursor: pointer; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.expandable-text.expanded { white-space: normal; overflow: visible; } /* Center pagination controls produced by django-tables2 without centering table cell contents */ .django-tables2 .pagination { diff --git a/patchman/static/js/expandable-text.js b/patchman/static/js/expandable-text.js index 0f5861ce..d881e73c 100644 --- a/patchman/static/js/expandable-text.js +++ b/patchman/static/js/expandable-text.js @@ -2,7 +2,7 @@ document.addEventListener('DOMContentLoaded', function() { const expandableTexts = document.querySelectorAll('.expandable-text'); expandableTexts.forEach(text => { text.addEventListener('click', function() { - this.textContent = this.dataset.fullText; + this.classList.toggle('expanded'); }); }); }); diff --git a/repos/tables.py b/repos/tables.py index 8551822f..d4c8bb9b 100644 --- a/repos/tables.py +++ b/repos/tables.py @@ -32,7 +32,7 @@ # MirrorTable templates MIRROR_CHECKBOX_TEMPLATE = '' MIRROR_ID_TEMPLATE = '{{ record.id }}' -MIRROR_URL_TEMPLATE = '{{ record.url|truncatechars:25 }}' +MIRROR_URL_TEMPLATE = '{{ record.url }}' MIRROR_PACKAGES_TEMPLATE = ( '{% if not record.mirrorlist %}' '' @@ -113,7 +113,7 @@ class MirrorTable(BaseTable): MIRROR_URL_TEMPLATE, orderable=False, verbose_name='URL', - attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2 truncate-cell'}}, ) mirror_packages = tables.TemplateColumn( MIRROR_PACKAGES_TEMPLATE, diff --git a/security/tables.py b/security/tables.py index 4d7272af..ba209829 100644 --- a/security/tables.py +++ b/security/tables.py @@ -27,10 +27,7 @@ '  ' 'osv.dev {% bootstrap_icon "link" %}' ) -CVE_DESCRIPTION_TEMPLATE = ( - '' - '{{ record.description|truncatechars:60 }}' -) +CVE_DESCRIPTION_TEMPLATE = '{{ record.description }}' CVSS_SCORES_TEMPLATE = '{% for score in record.cvss_scores.all %} {{ score.score }} {% endfor %}' CWES_TEMPLATE = '{% for cwe in record.cwes.all %} {{ cwe.cwe_id }} {% endfor %}' CVE_ERRATA_TEMPLATE = ( @@ -40,10 +37,7 @@ # CWETable templates CWE_ID_TEMPLATE = '{{ record.cwe_id }}' -CWE_DESCRIPTION_TEMPLATE = ( - '' - '{{ record.description|truncatechars:120 }}' -) +CWE_DESCRIPTION_TEMPLATE = '{{ record.description }}' CWE_CVES_TEMPLATE = ( '' '{{ record.cve_set.count }}' @@ -68,13 +62,13 @@ class CVETable(BaseTable): CVE_LINKS_TEMPLATE, orderable=False, verbose_name='Links', - attrs={'th': {'class': 'col-sm-2'}, 'td': {'class': 'col-sm-2'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, ) cve_description = tables.TemplateColumn( CVE_DESCRIPTION_TEMPLATE, orderable=False, verbose_name='Description', - attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3'}}, + attrs={'th': {'class': 'col-sm-3'}, 'td': {'class': 'col-sm-3 truncate-cell'}}, ) cvss_scores = tables.TemplateColumn( CVSS_SCORES_TEMPLATE, @@ -92,25 +86,25 @@ class CVETable(BaseTable): order_by='reserved_date', verbose_name='Reserved', default='', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) rejected_date = tables.DateColumn( order_by='rejected_date', verbose_name='Rejected', default='', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) published_date = tables.DateColumn( order_by='published_date', verbose_name='Published', default='', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) updated_date = tables.DateColumn( order_by='updated_date', verbose_name='Updated', default='', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) cve_errata = tables.TemplateColumn( CVE_ERRATA_TEMPLATE, @@ -145,13 +139,13 @@ class CWETable(BaseTable): CWE_DESCRIPTION_TEMPLATE, orderable=False, verbose_name='Description', - attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6'}}, + attrs={'th': {'class': 'col-sm-6'}, 'td': {'class': 'col-sm-6 truncate-cell'}}, ) cwe_cves = tables.TemplateColumn( CWE_CVES_TEMPLATE, orderable=False, verbose_name='CVEs', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) class Meta(BaseTable.Meta): @@ -169,13 +163,13 @@ class ReferenceTable(BaseTable): REFERENCE_URL_TEMPLATE, orderable=False, verbose_name='URL', - attrs={'th': {'class': 'col-sm-10'}, 'td': {'class': 'col-sm-10'}}, + attrs={'th': {'class': 'col-sm-8'}, 'td': {'class': 'col-sm-8'}}, ) linked_errata = tables.TemplateColumn( LINKED_ERRATA_TEMPLATE, orderable=False, verbose_name='Linked Errata', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) class Meta(BaseTable.Meta): From a730a4fa77e4b92202146cc4607b292361727f29 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:29:58 -0500 Subject: [PATCH 049/146] fix OS tables and add dropdown menu --- operatingsystems/tables.py | 12 ++++++------ util/templates/navbar.html | 8 +++++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/operatingsystems/tables.py b/operatingsystems/tables.py index 0be5b77d..8e3acb8c 100644 --- a/operatingsystems/tables.py +++ b/operatingsystems/tables.py @@ -87,25 +87,25 @@ class OSReleaseTable(BaseTable): OSRELEASE_REPOS_TEMPLATE, verbose_name='Repos', orderable=False, - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) osvariants = tables.TemplateColumn( OSVARIANTS_TEMPLATE, verbose_name='OS Variants', orderable=False, - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) osrelease_hosts = tables.TemplateColumn( OSRELEASE_HOSTS_TEMPLATE, verbose_name='Hosts', orderable=False, - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) osrelease_errata = tables.TemplateColumn( OSRELEASE_ERRATA_TEMPLATE, verbose_name='Errata', orderable=False, - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) class Meta(BaseTable.Meta): @@ -144,7 +144,7 @@ class OSVariantTable(BaseTable): OSVARIANT_HOSTS_TEMPLATE, verbose_name='Hosts', order_by='hosts_count', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) osrelease = tables.TemplateColumn( OSVARIANT_OSRELEASE_TEMPLATE, @@ -156,7 +156,7 @@ class OSVariantTable(BaseTable): REPOS_OSRELEASE_TEMPLATE, verbose_name='Repos (OS Release)', order_by='repos_count', - attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1'}}, + attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}}, ) class Meta(BaseTable.Meta): diff --git a/util/templates/navbar.html b/util/templates/navbar.html index 206f09fd..6effcaad 100644 --- a/util/templates/navbar.html +++ b/util/templates/navbar.html @@ -29,7 +29,13 @@
  • Security References
  • -
  • Operating Systems
  • +
  • Reports
  • Issues{% if issues_count %} {{ issues_count }}{% endif %}
  • From 7bdf1f21164ab33117ff30bb40ff91e330cd8b5c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:37:26 -0500 Subject: [PATCH 050/146] fix broken rocky links --- errata/sources/distros/rocky.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/errata/sources/distros/rocky.py b/errata/sources/distros/rocky.py index 2d805985..272ac2a7 100644 --- a/errata/sources/distros/rocky.py +++ b/errata/sources/distros/rocky.py @@ -203,8 +203,8 @@ def process_rocky_erratum(advisory): def add_rocky_erratum_references(e, advisory): """ Add Rocky Linux errata references """ - e.add_reference('Rocky Advisory', 'https://apollo.build.resf.org/{e.name}') - e.add_reference('Rocky Advisory', 'https://errata.rockylinux.org/{e.name}') + e.add_reference('Rocky Advisory', f'https://apollo.build.resf.org/{e.name}') + e.add_reference('Rocky Advisory', f'https://errata.rockylinux.org/{e.name}') advisory_cves = advisory.get('cves') for a_cve in advisory_cves: cve_id = a_cve.get('cve') From 4c8af9e778f927a7c1197c811b57375d282444a7 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:44:05 -0500 Subject: [PATCH 051/146] move navbar to side --- patchman/static/css/base.css | 20 ++++- patchman/static/js/expandable-text.js | 32 ++++++++ util/templates/base.html | 28 ++++--- util/templates/dashboard.html | 4 + util/templates/navbar.html | 105 ++++++++++++-------------- 5 files changed, 115 insertions(+), 74 deletions(-) diff --git a/patchman/static/css/base.css b/patchman/static/css/base.css index 76c7dbc4..3acb3ff5 100644 --- a/patchman/static/css/base.css +++ b/patchman/static/css/base.css @@ -5,7 +5,7 @@ .panel-body { padding: 5px; font-size: 12px; } .panel { margin-bottom: 5px; font-size: 12px; } .panel-heading { padding: 5px; font-size: 13px; } -.breadcrumb { font-size: 12px; background-color: #222; border-radius: 0; margin-bottom: 3px; } +.breadcrumb { font-size: 14px; background-color: #222; border-radius: 0; margin-bottom: 3px; position: sticky; top: 0; z-index: 100; } .navbar { margin-bottom: 0; padding-bottom: 0; border-radius: 0; } .navbar-inverse .dropdown-menu { background-color: #222; } .navbar-inverse .dropdown-menu > li > a { color: #9d9d9d; } @@ -24,6 +24,24 @@ td.truncate-cell a { display: block; overflow: hidden; text-overflow: ellipsis; .expandable-text { cursor: pointer; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .expandable-text.expanded { white-space: normal; overflow: visible; } +/* Sidebar layout */ +.sidebar-layout { display: flex; min-height: 100vh; } +.sidebar-nav { width: 200px; background-color: #222; display: flex; flex-direction: column; position: fixed; height: 100vh; } +.sidebar-header { padding: 15px; border-bottom: 1px solid #333; } +.sidebar-brand { color: #9d9d9d; font-size: 18px; font-weight: bold; text-decoration: none; } +.sidebar-brand:hover { color: #fff; text-decoration: none; } +.sidebar-menu { list-style: none; padding: 0; margin: 0; } +.sidebar-menu li a { display: block; padding: 10px 15px; color: #9d9d9d; text-decoration: none; font-size: 14px; } +.sidebar-menu li a:hover { background-color: #333; color: #fff; } +.sidebar-menu li.active > a { background-color: #333; color: #fff; } +.sidebar-menu .submenu { list-style: none; padding: 0; margin: 0; display: none; background-color: #1a1a1a; } +.sidebar-menu .submenu li a { padding-left: 30px; font-size: 13px; } +.sidebar-menu .has-submenu.open > .submenu { display: block; } +.sidebar-menu .has-submenu > a .caret { float: right; margin-top: 6px; transition: transform 0.2s; } +.sidebar-menu .has-submenu.open > a .caret { transform: rotate(180deg); } +.sidebar-bottom { margin-top: auto; border-top: 1px solid #333; } +.main-content { margin-left: 200px; flex: 1; } + /* Center pagination controls produced by django-tables2 without centering table cell contents */ .django-tables2 .pagination { text-align: center; diff --git a/patchman/static/js/expandable-text.js b/patchman/static/js/expandable-text.js index d881e73c..fa803106 100644 --- a/patchman/static/js/expandable-text.js +++ b/patchman/static/js/expandable-text.js @@ -5,4 +5,36 @@ document.addEventListener('DOMContentLoaded', function() { this.classList.toggle('expanded'); }); }); + + // Sidebar submenu state from localStorage + const STORAGE_KEY = 'patchman_sidebar_state'; + const savedState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + + // Apply saved state to submenus + const submenuItems = document.querySelectorAll('.has-submenu'); + submenuItems.forEach((item, index) => { + const menuId = item.querySelector('a').textContent.trim(); + if (savedState[menuId] !== undefined) { + if (savedState[menuId]) { + item.classList.add('open'); + } else { + item.classList.remove('open'); + } + } + }); + + // Toggle submenu and save state + const submenuLinks = document.querySelectorAll('.has-submenu > a'); + submenuLinks.forEach(link => { + link.addEventListener('click', function(e) { + e.preventDefault(); + const parent = this.parentElement; + parent.classList.toggle('open'); + // Save state to localStorage + const menuId = this.textContent.trim(); + const currentState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'); + currentState[menuId] = parent.classList.contains('open'); + localStorage.setItem(STORAGE_KEY, JSON.stringify(currentState)); + }); + }); }); diff --git a/util/templates/base.html b/util/templates/base.html index 17e952f3..3e780fcc 100644 --- a/util/templates/base.html +++ b/util/templates/base.html @@ -12,23 +12,21 @@ {% block extrahead %}{% endblock %} -
    +
    Name {{ osvariant.name }}
    Architecture {{ osvariant.arch }}
    Codename {{ osvariant.codename }}
    Hosts{{ osvariant.host_set.count }}
    Hosts{{ osvariant.hosts_count }}
    OS Release{% if osvariant.osrelease != None %} {{ osvariant.osrelease }} {% else %}No OS Release{% endif %}
    {% if user.is_authenticated and perms.is_admin %} diff --git a/operatingsystems/views.py b/operatingsystems/views.py index 91b273aa..063a231c 100644 --- a/operatingsystems/views.py +++ b/operatingsystems/views.py @@ -40,7 +40,7 @@ def _get_filtered_osvariants(filter_params): from urllib.parse import parse_qs params = parse_qs(filter_params) - osvariants = OSVariant.objects.select_related() + osvariants = OSVariant.objects.select_related('osrelease', 'arch') if 'osrelease_id' in params: osvariants = osvariants.filter(osrelease=params['osrelease_id'][0]) @@ -60,7 +60,7 @@ def _get_filtered_osreleases(filter_params): from urllib.parse import parse_qs params = parse_qs(filter_params) - osreleases = OSRelease.objects.select_related() + osreleases = OSRelease.objects.all() if 'erratum_id' in params: osreleases = osreleases.filter(erratum=params['erratum_id'][0]) @@ -78,7 +78,7 @@ def _get_filtered_osreleases(filter_params): @login_required def osvariant_list(request): # Use cached hosts_count instead of expensive annotation - osvariants = OSVariant.objects.select_related().annotate( + osvariants = OSVariant.objects.select_related('osrelease', 'arch').annotate( repos_count=Count('osrelease__repos'), ) @@ -182,7 +182,7 @@ def delete_nohost_osvariants(request): @login_required def osrelease_list(request): - osreleases = OSRelease.objects.select_related() + osreleases = OSRelease.objects.all() if 'erratum_id' in request.GET: osreleases = osreleases.filter(erratum=request.GET['erratum_id']) @@ -347,7 +347,7 @@ class OSVariantViewSet(viewsets.ModelViewSet): """ API endpoint that allows operating system variants to be viewed or edited. """ - queryset = OSVariant.objects.all() + queryset = OSVariant.objects.select_related('osrelease', 'arch').all() serializer_class = OSVariantSerializer filterset_fields = ['name'] diff --git a/packages/managers.py b/packages/managers.py index c268f5cf..3a64e7ab 100644 --- a/packages/managers.py +++ b/packages/managers.py @@ -20,4 +20,4 @@ class PackageManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset().select_related('name', 'arch') diff --git a/packages/utils.py b/packages/utils.py index 87395ff6..619407d2 100644 --- a/packages/utils.py +++ b/packages/utils.py @@ -281,7 +281,7 @@ def clean_packageupdates(): """ package_updates = list(PackageUpdate.objects.all()) for update in package_updates: - if update.host_set.count() == 0: + if not update.host_set.exists(): text = f'Removing unused PackageUpdate {update}' info_message(text=text) update.delete() @@ -325,7 +325,8 @@ def clean_packages(remove_duplicates=False): packagetype=package.packagetype, category=package.category, ) - if potential_duplicates.count() > 1: + potential_duplicates = list(potential_duplicates) + if len(potential_duplicates) > 1: for dupe in potential_duplicates: if dupe.id != package.id: info_message(text=f'Removing duplicate Package {dupe}') diff --git a/packages/views.py b/packages/views.py index 287c033d..9f75b415 100644 --- a/packages/views.py +++ b/packages/views.py @@ -32,7 +32,7 @@ @login_required def package_list(request): - packages = Package.objects.select_related() + packages = Package.objects.select_related('name', 'arch') if 'arch_id' in request.GET: packages = packages.filter(arch=request.GET['arch_id']).distinct() @@ -121,7 +121,7 @@ def package_list(request): @login_required def package_name_list(request): - packages = PackageName.objects.select_related() + packages = PackageName.objects.all() if 'arch_id' in request.GET: packages = packages.filter(package__arch=request.GET['arch_id']).distinct() @@ -165,7 +165,7 @@ def package_detail(request, package_id): @login_required def package_name_detail(request, packagename): package = get_object_or_404(PackageName, name=packagename) - allversions = Package.objects.select_related().filter(name=package.id) + allversions = Package.objects.select_related('name', 'arch').filter(name=package.id) return render(request, 'packages/package_name_detail.html', {'package': package, @@ -185,7 +185,7 @@ class PackageViewSet(viewsets.ModelViewSet): """ API endpoint that allows packages to be viewed or edited. """ - queryset = Package.objects.all() + queryset = Package.objects.select_related('name', 'arch').all() serializer_class = PackageSerializer filterset_fields = [ 'name', @@ -201,6 +201,6 @@ class PackageUpdateViewSet(viewsets.ModelViewSet): """ API endpoint that allows packages updates to be viewed or edited. """ - queryset = PackageUpdate.objects.all() + queryset = PackageUpdate.objects.select_related('oldpackage', 'newpackage').all() serializer_class = PackageUpdateSerializer filterset_fields = ['oldpackage', 'newpackage', 'security'] diff --git a/reports/views.py b/reports/views.py index 18f60aa6..1aa23913 100644 --- a/reports/views.py +++ b/reports/views.py @@ -45,7 +45,7 @@ def _get_filtered_reports(filter_params): from urllib.parse import parse_qs params = parse_qs(filter_params) - reports = Report.objects.select_related() + reports = Report.objects.all() if 'host_id' in params: reports = reports.filter(hostname=params['host_id'][0]) @@ -108,7 +108,7 @@ def upload(request): @login_required def report_list(request): - reports = Report.objects.select_related() + reports = Report.objects.all() if 'host_id' in request.GET: reports = reports.filter(hostname=request.GET['host_id']) diff --git a/repos/forms.py b/repos/forms.py index f9795b51..57ebae2a 100644 --- a/repos/forms.py +++ b/repos/forms.py @@ -28,7 +28,7 @@ class MirrorSelect2Widget(ModelSelect2MultipleWidget): model = Mirror search_fields = ['url__icontains', 'repo__name__icontains'] max_results = 50 - queryset = Mirror.objects.select_related().order_by('repo__name', 'url') + queryset = Mirror.objects.select_related('repo').order_by('repo__name', 'url') def __init__(self, *args, **kwargs): kwargs.setdefault('attrs', {}) @@ -41,7 +41,7 @@ def label_from_instance(self, obj): class EditRepoForm(ModelForm): mirrors = ModelMultipleChoiceField( - queryset=Mirror.objects.select_related().order_by('repo__name', 'url'), + queryset=Mirror.objects.select_related('repo').order_by('repo__name', 'url'), required=False, widget=MirrorSelect2Widget(attrs={'style': 'width: 100%'}), ) diff --git a/repos/managers.py b/repos/managers.py index 78f37a46..739c7ca3 100644 --- a/repos/managers.py +++ b/repos/managers.py @@ -20,4 +20,4 @@ class RepositoryManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset().select_related('arch') diff --git a/repos/models.py b/repos/models.py index 421bca00..eec5f566 100644 --- a/repos/models.py +++ b/repos/models.py @@ -82,11 +82,11 @@ def refresh(self, force=False): force can be set to force a reset of all the mirrors metadata """ if force: - for mirror in self.mirror_set.all(): - mirror.packages_checksum = None - mirror.modules_checksum = None - mirror.errata_checksum = None - mirror.save() + self.mirror_set.all().update( + packages_checksum=None, + modules_checksum=None, + errata_checksum=None + ) if not self.auth_required: if self.repotype == Repository.DEB: @@ -108,9 +108,7 @@ def refresh_errata(self, force=False): """ Refresh errata metadata for all of a repos mirrors """ if force: - for mirror in self.mirror_set.all(): - mirror.errata_checksum = None - mirror.save() + self.mirror_set.all().update(errata_checksum=None) if self.repotype == Repository.RPM: refresh_repo_errata(self) @@ -120,10 +118,7 @@ def disable(self): each mirror so that it doesn't try to update its package metadata. """ self.enabled = False - for mirror in self.mirror_set.all(): - mirror.enabled = False - mirror.refresh = False - mirror.save() + self.mirror_set.all().update(enabled=False, refresh=False) def enable(self): """ Enable a repo. This involves enabling each mirror, which allows it @@ -131,10 +126,7 @@ def enable(self): mirror so that it updates its package metadata. """ self.enabled = True - for mirror in self.mirror_set.all(): - mirror.enabled = True - mirror.refresh = True - mirror.save() + self.mirror_set.all().update(enabled=True, refresh=True) class Mirror(models.Model): diff --git a/repos/utils.py b/repos/utils.py index 13cee149..0d81eb25 100644 --- a/repos/utils.py +++ b/repos/utils.py @@ -273,11 +273,12 @@ def find_best_repo(package, hostrepos): repo. Returns the best repo. """ best_repo = None - package_repos = hostrepos.filter(repo__mirror__packages=package).distinct() + package_repos = hostrepos.filter(repo__mirror__packages=package).select_related('repo').distinct() + package_repos = list(package_repos) if package_repos: best_repo = package_repos[0] - if package_repos.count() > 1: + if len(package_repos) > 1: for hostrepo in package_repos: if hostrepo.repo.security: best_repo = hostrepo diff --git a/repos/views.py b/repos/views.py index 58a9cdd9..4f6869ae 100644 --- a/repos/views.py +++ b/repos/views.py @@ -43,7 +43,7 @@ @login_required def repo_list(request): - repos = Repository.objects.select_related().order_by('name') + repos = Repository.objects.select_related('arch').order_by('name') if 'repotype' in request.GET: repos = repos.filter(repotype=request.GET['repotype']) @@ -146,11 +146,11 @@ def move_mirrors(repo): hostrepo.delete() mirror.repo = repo mirror.save() - if oldrepo.mirror_set.count() == 0: + if not oldrepo.mirror_set.exists(): oldrepo.delete() # Use cached packages_count instead of expensive annotation - mirrors = Mirror.objects.select_related().order_by('packages_checksum') + mirrors = Mirror.objects.select_related('repo').order_by('packages_checksum') checksum = None if 'checksum' in request.GET: @@ -318,9 +318,7 @@ def repo_edit(request, repo_id): repo = edit_form.save() repo.save() mirrors = edit_form.cleaned_data['mirrors'] - for mirror in mirrors: - mirror.repo = repo - mirror.save() + mirrors.update(repo=repo) if repo.enabled: repo.enable() else: @@ -418,7 +416,7 @@ def _get_filtered_repos(filter_params): from urllib.parse import parse_qs params = parse_qs(filter_params) - repos = Repository.objects.select_related().order_by('name') + repos = Repository.objects.select_related('arch').order_by('name') if 'repotype' in params: repos = repos.filter(repotype=params['repotype'][0]) @@ -509,7 +507,7 @@ def _get_filtered_mirrors(filter_params): from urllib.parse import parse_qs params = parse_qs(filter_params) - mirrors = Mirror.objects.select_related().order_by('packages_checksum') + mirrors = Mirror.objects.select_related('repo').order_by('packages_checksum') if 'checksum' in params: mirrors = mirrors.filter(packages_checksum=params['checksum'][0]) @@ -591,7 +589,7 @@ class RepositoryViewSet(viewsets.ModelViewSet): """ API endpoint that allows repositories to be viewed or edited. """ - queryset = Repository.objects.all() + queryset = Repository.objects.select_related('arch').all() serializer_class = RepositorySerializer @@ -599,7 +597,7 @@ class MirrorViewSet(viewsets.ModelViewSet): """ API endpoint that allows mirrors to be viewed or edited. """ - queryset = Mirror.objects.all() + queryset = Mirror.objects.select_related('repo').all() serializer_class = MirrorSerializer @@ -607,5 +605,5 @@ class MirrorPackageViewSet(viewsets.ModelViewSet): """ API endpoint that allows mirror packages to be viewed or edited. """ - queryset = MirrorPackage.objects.all() + queryset = MirrorPackage.objects.select_related('mirror', 'package').all() serializer_class = MirrorPackageSerializer diff --git a/security/managers.py b/security/managers.py index 4dfcffaf..f8d054a1 100644 --- a/security/managers.py +++ b/security/managers.py @@ -19,4 +19,4 @@ class CVEManager(models.Manager): def get_queryset(self): - return super().get_queryset().select_related() + return super().get_queryset() diff --git a/security/views.py b/security/views.py index ae56a82b..7ae5d851 100644 --- a/security/views.py +++ b/security/views.py @@ -32,7 +32,7 @@ @login_required def cwe_list(request): - cwes = CWE.objects.select_related() + cwes = CWE.objects.all() if 'search' in request.GET: terms = request.GET['search'].lower() @@ -65,7 +65,7 @@ def cwe_detail(request, cwe_id): @login_required def cve_list(request): - cves = CVE.objects.select_related() + cves = CVE.objects.all() if 'erratum_id' in request.GET: cves = cves.filter(erratum=request.GET['erratum_id']) @@ -117,7 +117,7 @@ def cve_detail(request, cve_id): @login_required def reference_list(request): - refs = Reference.objects.select_related().order_by('ref_type') + refs = Reference.objects.all().order_by('ref_type') if 'ref_type' in request.GET: refs = refs.filter(ref_type=request.GET['ref_type']).distinct() diff --git a/util/management/commands/revoke_api_key.py b/util/management/commands/revoke_api_key.py index d22955e7..05985c48 100644 --- a/util/management/commands/revoke_api_key.py +++ b/util/management/commands/revoke_api_key.py @@ -42,7 +42,7 @@ def handle(self, *args, **options): if not api_keys.exists(): api_keys = APIKey.objects.filter(name=key_input) - if api_keys.count() == 0: + if not api_keys.exists(): raise CommandError(f'No API key found matching: {key_input}') elif api_keys.count() > 1: raise CommandError(f'Multiple keys match "{key_input}". Please be more specific.') diff --git a/util/templatetags/common.py b/util/templatetags/common.py index 0fdeaff1..119e7f34 100644 --- a/util/templatetags/common.py +++ b/util/templatetags/common.py @@ -19,6 +19,7 @@ from urllib.parse import urlencode from django.template import Library +from django.db.models import Sum from django.template.loader import get_template from django.utils import timezone from django.utils.html import format_html @@ -138,7 +139,4 @@ def reports_timedelta(): @register.simple_tag def host_count(osrelease): - host_count = 0 - for osvariant in osrelease.osvariant_set.all(): - host_count += osvariant.host_set.count() - return host_count + return osrelease.osvariant_set.aggregate(total=Sum('hosts_count'))['total'] or 0 From b03bc47537778df1b8c4f1f6daf62fcb6bcf729b Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 13 Feb 2026 00:30:47 -0500 Subject: [PATCH 113/146] add .iterator() to large queryset loops --- hosts/tasks.py | 6 +++--- sbin/patchman | 6 +++--- security/tasks.py | 4 ++-- security/utils.py | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/hosts/tasks.py b/hosts/tasks.py index a8632a5d..b9305cf9 100755 --- a/hosts/tasks.py +++ b/hosts/tasks.py @@ -33,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) @@ -43,7 +43,7 @@ def find_all_host_updates_homogenous(): """ updated_hosts = [] ts = get_datetime_now() - for host in Host.objects.all(): + for host in Host.objects.all().iterator(): if host not in updated_hosts: host.find_updates() host.updated_at = ts @@ -61,7 +61,7 @@ def find_all_host_updates_homogenous(): updates = host.updates.all() phosts = [] - for fhost in filtered_hosts: + for fhost in filtered_hosts.iterator(): frepos = set(fhost.repos.all()) if repos != frepos: continue diff --git a/sbin/patchman b/sbin/patchman index 5dabdfa7..0e164c98 100755 --- a/sbin/patchman +++ b/sbin/patchman @@ -164,7 +164,7 @@ def host_updates_alt(host=None): updated_hosts = [] hosts = get_hosts(host, 'Finding updates') ts = get_datetime_now() - for host in hosts: + for host in hosts.iterator(): info_message(text=str(host)) if host not in updated_hosts: host.find_updates() @@ -183,7 +183,7 @@ def host_updates_alt(host=None): updates = host.updates.all() phosts = [] - for fhost in filtered_hosts: + for fhost in filtered_hosts.iterator(): frepos = set(fhost.repos.all()) rdiff = repos.difference(frepos) @@ -352,7 +352,7 @@ def process_reports(host=None, force=False): info_message(text=text) - for report in reports: + for report in reports.iterator(): report.process(find_updates=False) diff --git a/security/tasks.py b/security/tasks.py index 0cfbc2f1..93d48427 100644 --- a/security/tasks.py +++ b/security/tasks.py @@ -50,7 +50,7 @@ def update_cves(): if cache.add(lock_key, 'true', lock_expire): try: - for cve in CVE.objects.all(): + for cve in CVE.objects.all().iterator(): update_cve.delay(cve.id) finally: cache.delete(lock_key) @@ -87,7 +87,7 @@ def update_cwes(): if cache.add(lock_key, 'true', lock_expire): try: - for cwe in CWE.objects.all(): + for cwe in CWE.objects.all().iterator(): update_cwe.delay(cwe.id) finally: cache.delete(lock_key) diff --git a/security/utils.py b/security/utils.py index 127f2c73..745cf1f8 100644 --- a/security/utils.py +++ b/security/utils.py @@ -42,7 +42,7 @@ def update_cves(cve_id=None, fetch_nist_data=False): cve = CVE.objects.get(cve_id=cve_id) cve.fetch_cve_data(fetch_nist_data, sleep_secs=0) else: - for cve in CVE.objects.all(): + for cve in CVE.objects.all().iterator(): cve.fetch_cve_data(fetch_nist_data) @@ -56,7 +56,7 @@ def update_cwes(cve_id=None): cwes = cve.cwes.all() else: cwes = CWE.objects.all() - for cwe in cwes: + for cwe in cwes.iterator(): cwe.fetch_cwe_data() From 9eb83e83ea19f26b9f9eaeee032c9dfda1287df4 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Fri, 13 Feb 2026 00:40:39 -0500 Subject: [PATCH 114/146] address template double-count issues --- errata/templates/errata/erratum_detail.html | 10 +++++---- .../operatingsystems/osrelease_detail.html | 10 +++++---- .../osvariant_delete_multiple.html | 2 +- util/templates/dashboard.html | 8 +++++-- util/views.py | 22 +++++++++++++++++++ 5 files changed, 41 insertions(+), 11 deletions(-) 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/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 %}