-
- {% 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 %}
{% endfor %}
@@ -141,21 +148,27 @@
{% gen_table host.modules.all %}
-
-
-
-
Show/Hide Installed Packages
-
-
- {% for package in host.packages.select_related %}
-
- {{ package }}
-
- {% endfor %}
-
-
-
-
+
+
{% endblock %}
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 %}
+
+
+
+
+ {% 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 %}
-
-
-
- Hostname
- Updates
- Affected by Errata
- Running Kernel
- OS Variant
- Last Report
- Reboot Status
-
-
-
- {% for host in object_list %}
-
- {{ 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 %}
-
- {% endfor %}
-
-
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' '
+ 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..b50f021c 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,58 @@
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), 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:
hosts = hosts.filter(domain=request.GET['domain_id'])
@@ -76,16 +120,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 +133,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
@@ -111,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
@@ -178,6 +233,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 %}
-
-
-
- Name
- Stream
- Version
- Context
- Repo
- Packages
- Enabled on Hosts
-
-
-
- {% for module in object_list %}
-
- {{ module.name }}
- {{ module.stream }}
- {{ module.version }}
- {{ module.context }}
- {{ module.repo }}
- {{ module.packages.count }}
- {{ module.host_set.count }}
-
- {% endfor %}
-
-
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..14437094
--- /dev/null
+++ b/operatingsystems/tables.py
@@ -0,0 +1,168 @@
+# 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 centered'}},
+ )
+ osvariants = tables.TemplateColumn(
+ OSVARIANTS_TEMPLATE,
+ verbose_name='OS Variants',
+ orderable=False,
+ 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 centered'}},
+ )
+ osrelease_errata = tables.TemplateColumn(
+ OSRELEASE_ERRATA_TEMPLATE,
+ verbose_name='Errata',
+ orderable=False,
+ attrs={'th': {'class': 'col-sm-1'}, 'td': {'class': 'col-sm-1 centered'}},
+ )
+
+ 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',
+ default='',
+ 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 centered'}},
+ )
+ 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 centered'}},
+ )
+
+ 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 %}
-
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 %}
-
-
-
- Name
- Architecture
- Codename
- Hosts
- OS Release
- Repos (OS Release)
-
-
-
- {% for osvariant in object_list %}
-
- {{ 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 %}
-
- {% endfor %}
-
-
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 %}
+
+
+
+
+{% 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 %}
+
+
+
+
{% 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 %}
-
-
-
- Package
- Versions available
-
-
-
- {% for packagename in object_list %}
-
- {{ packagename }}
- {{ packagename.package_set.count }}
-
- {% endfor %}
-
-
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 %}
-
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..76c28b3f 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,8 @@
'django_extensions',
'taggit',
'bootstrap3',
+ 'django_tables2',
+ 'django_select2',
'rest_framework',
'django_filters',
'celery',
@@ -108,6 +111,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..86927756 100644
--- a/patchman/static/css/base.css
+++ b/patchman/static/css/base.css
@@ -5,9 +5,60 @@
.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; }
+.breadcrumb { font-size: 14px; background-color: #222; border-radius: 0; margin-bottom: 3px; position: sticky; top: 0; z-index: 100; }
+.breadcrumb > li:nth-child(2):before { content: none; }
.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; }
+.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; }
+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; }
+
+/* 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; overflow: hidden; }
+.sidebar-header { padding: 15px; border-bottom: 1px solid #333; }
+.sidebar-brand { color: #9d9d9d; font-size: 18px; font-weight: bold; text-decoration: none; white-space: nowrap; }
+.sidebar-brand:hover { color: #fff; text-decoration: none; }
+.sidebar-toggle { background: none; border: none; color: #666; cursor: pointer; padding: 0 10px 0 0; font-size: 14px; }
+.sidebar-toggle:hover { color: #333; }
+.sidebar-toggle .glyphicon { transition: transform 0.2s; }
+.sidebar-toggle-container { list-style: none; display: flex; align-items: center; }
+.sidebar-menu { list-style: none; padding: 0; margin: 0; overflow-y: auto; overflow-x: hidden; }
+.sidebar-menu li a { display: block; padding: 10px 15px; color: #9d9d9d; text-decoration: none; font-size: 14px; white-space: nowrap; }
+.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; }
+
+/* Collapsed sidebar */
+.sidebar-nav.collapsed { width: 0; }
+.sidebar-layout.collapsed .main-content { margin-left: 0; }
+.sidebar-layout.collapsed .sidebar-toggle .glyphicon { transform: rotate(180deg); }
+
+/* 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 a1dde262..00000000
Binary files a/patchman/static/img/icon-alert.gif and /dev/null differ
diff --git a/patchman/static/img/icon-no.gif b/patchman/static/img/icon-no.gif
deleted file mode 100644
index 1b4ee581..00000000
Binary files a/patchman/static/img/icon-no.gif and /dev/null differ
diff --git a/patchman/static/img/icon-yes.gif b/patchman/static/img/icon-yes.gif
deleted file mode 100644
index 73992827..00000000
Binary files a/patchman/static/img/icon-yes.gif and /dev/null differ
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/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/patchman/static/js/sidebar.js b/patchman/static/js/sidebar.js
new file mode 100644
index 00000000..a134ab36
--- /dev/null
+++ b/patchman/static/js/sidebar.js
@@ -0,0 +1,52 @@
+document.addEventListener('DOMContentLoaded', function() {
+ const STORAGE_KEY = 'patchman_sidebar_state';
+ const COLLAPSED_KEY = 'patchman_sidebar_collapsed';
+ const savedState = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
+
+ const sidebarNav = document.querySelector('.sidebar-nav');
+ const sidebarLayout = document.querySelector('.sidebar-layout');
+
+ // Apply saved collapsed state
+ if (localStorage.getItem(COLLAPSED_KEY) === 'true') {
+ sidebarNav.classList.add('collapsed');
+ sidebarLayout.classList.add('collapsed');
+ }
+
+ // Sidebar collapse toggle
+ const sidebarToggle = document.querySelector('.sidebar-toggle');
+ if (sidebarToggle) {
+ sidebarToggle.addEventListener('click', function() {
+ sidebarNav.classList.toggle('collapsed');
+ sidebarLayout.classList.toggle('collapsed');
+ localStorage.setItem(COLLAPSED_KEY, sidebarNav.classList.contains('collapsed'));
+ });
+ }
+
+ // 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/patchman/urls.py b/patchman/urls.py
index 2ae64f56..b66a0de3 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
@@ -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/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 %}