From fbbc52bc30d2d4b185cea4f087ec044fb0f17f1c Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Mon, 12 Jan 2026 00:03:24 -0500 Subject: [PATCH 01/12] 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 e00758e3aa62a4b1317c3307353c1a261d4c3528 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:02:03 -0500 Subject: [PATCH 02/12] 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 03c014541af46d235b81b9880b17d92c3d6eb5a5 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:16:31 -0500 Subject: [PATCH 03/12] 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 8b97cb3d6e6d56a01061557973ded0cc34e59492 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:18:02 -0500 Subject: [PATCH 04/12] 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 6d891e4a2d79a2d5b6e57f6fd84b7d5e4247db27 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:23:44 -0500 Subject: [PATCH 05/12] 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 844a7372df8209c83501c21333f20962f9128ef5 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:29:58 -0500 Subject: [PATCH 06/12] 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 777d99fb0d53ac9f42dd1c1963cb461725acf7e8 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:37:26 -0500 Subject: [PATCH 07/12] 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 0aa428168b91ae104d3ab6f87c01f63730eb1dd9 Mon Sep 17 00:00:00 2001 From: Marcus Furlong Date: Tue, 13 Jan 2026 19:44:05 -0500 Subject: [PATCH 08/12] 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 %} -
    +