Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions dje/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@
from django.utils.http import urlencode

import requests
from openpyxl.styles import Alignment
from openpyxl.styles import Border
from openpyxl.styles import Font
from openpyxl.styles import NamedStyle
from openpyxl.styles import Side
from openpyxl.utils import get_column_letter
from packageurl import PackageURL


Expand Down Expand Up @@ -747,3 +753,21 @@ def merge_common_non_empty_values(dicts):
merged_result[key] = values[0]

return merged_result


def style_xlsx_worksheet(worksheet, headers=None, auto_width=True):
"""Apply standard styling to an XLSX worksheet."""
header_style = NamedStyle(name="header")
header_style.font = Font(bold=True)
header_style.border = Border(bottom=Side(border_style="thin"))
header_style.alignment = Alignment(horizontal="center", vertical="center")

for cell in worksheet[1]:
cell.style = header_style

worksheet.freeze_panes = "A2"

if auto_width and headers:
for col_index, header in enumerate(headers, 1):
column_letter = get_column_letter(col_index)
worksheet.column_dimensions[column_letter].width = max(len(str(header)) + 4, 12)
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,44 @@

{% block content %}
<div class="d-flex align-items-baseline gap-3 mb-3">
<h1 class="h3 mb-0">{% trans "Compliance Control Center" %}</h1>
<span class="text-body-secondary">{{ total_products }} {% trans "active product" %}{{ total_products|pluralize }}</span>
<h1 class="h3 mb-0">
{% trans "Compliance Control Center" %}
</h1>
<span class="text-body-secondary">
{{ total_products }} {% trans "active product" %}{{ total_products|pluralize }}
</span>
<div class="dropdown ms-auto">
<button class="btn btn-sm btn-outline-dark dropdown-toggle" type="button" data-bs-toggle="dropdown">
<i class="fas fa-download me-1"></i>{% trans "Export" %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item" href="?export=csv">
<i class="fas fa-download me-1"></i>{% trans "Comma-separated Values (.csv)" %}
</a>
</li>
<li>
<a class="dropdown-item" href="?export=json">
<i class="fas fa-download me-1"></i>{% trans "JSON (.json)" %}
</a>
</li>
<li>
<a class="dropdown-item" href="?export=ods">
<i class="fas fa-download me-1"></i>{% trans "OpenDocument (.ods)" %}
</a>
</li>
<li>
<a class="dropdown-item" href="?export=xlsx">
<i class="fas fa-download me-1"></i>{% trans "Microsoft Excel (.xlsx)" %}
</a>
</li>
<li>
<a class="dropdown-item" href="?export=yaml">
<i class="fas fa-download me-1"></i>{% trans "YAML (.yaml)" %}
</a>
</li>
</ul>
</div>
</div>

<div class="row g-3 mb-3">
Expand Down
153 changes: 137 additions & 16 deletions product_portfolio/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#

import csv
import io
import json
from collections import OrderedDict
from collections import defaultdict
Expand Down Expand Up @@ -48,6 +49,7 @@
from django.template.context_processors import csrf
from django.template.response import TemplateResponse
from django.urls import reverse
from django.utils import timezone
from django.utils.decorators import method_decorator
from django.utils.html import format_html
from django.utils.html import mark_safe
Expand All @@ -59,14 +61,11 @@
from django.views.generic import FormView
from django.views.generic import TemplateView

import odfdo
import saneyaml
from crispy_forms.utils import render_crispy_form
from guardian.shortcuts import get_perms as guardian_get_perms
from openpyxl import Workbook
from openpyxl.styles import Alignment
from openpyxl.styles import Border
from openpyxl.styles import Font
from openpyxl.styles import NamedStyle
from openpyxl.styles import Side

from component_catalog.forms import ComponentAjaxForm
from component_catalog.license_expression_dje import build_licensing
Expand All @@ -92,6 +91,7 @@
from dje.utils import get_object_compare_diff
from dje.utils import group_by_simple
from dje.utils import is_uuid4
from dje.utils import style_xlsx_worksheet
from dje.views import DataspacedCreateView
from dje.views import DataspacedDeleteView
from dje.views import DataspacedFilterView
Expand Down Expand Up @@ -1696,16 +1696,7 @@ def get_relation_data(relation, diff, is_left):
ws.append(row)

# Styling
header = NamedStyle(name="header")
header.font = Font(bold=True)
header.border = Border(bottom=Side(border_style="thin"))
header.alignment = Alignment(horizontal="center", vertical="center")
header_row = ws[1]
for cell in header_row:
cell.style = header

# Freeze first header row
ws.freeze_panes = "A2"
style_xlsx_worksheet(ws)

# Columns width
ws.column_dimensions["B"].width = 40
Expand Down Expand Up @@ -2821,13 +2812,143 @@ def get_security_compliance_context(product, display_limit=10):
}


class ComplianceDashboardView(LoginRequiredMixin, DataspacedFilterView):
class ExportComplianceMixin:
"""Mixin for views that support CSV, XLSX, and JSON export."""

export_filename = "export"
export_fields = {}

def get(self, request, *args, **kwargs):
export_format = request.GET.get("export")
if export_format in ("csv", "xlsx", "json", "ods", "yaml"):
return self.export(export_format)
return super().get(request, *args, **kwargs)

def get_export_headers(self):
return list(self.export_fields.values())

def get_export_fields(self):
return list(self.export_fields.keys())

def get_export_queryset(self):
return self.get_queryset()

def get_export_rows(self):
return self.get_export_queryset().values_list(*self.get_export_fields())

def get_export_filename(self, extension):
timestamp = timezone.now().strftime("%Y-%m-%d_%H%M%S")
return f"{self.export_filename}_{timestamp}.{extension}"

def get_content_disposition(self, extension):
return f'attachment; filename="{self.get_export_filename(extension)}"'

def export(self, export_format):
if export_format == "csv":
return self.export_csv()
if export_format == "xlsx":
return self.export_xlsx()
if export_format == "ods":
return self.export_ods()
if export_format == "yaml":
return self.export_yaml()
return self.export_json()

def export_csv(self):
response = HttpResponse(content_type="text/csv")
response["Content-Disposition"] = self.get_content_disposition("csv")
writer = csv.writer(response)
writer.writerow(self.get_export_headers())
writer.writerows(self.get_export_rows())
return response

def export_xlsx(self):
workbook = Workbook()
worksheet = workbook.active
worksheet.title = self.export_filename.replace("_", " ").title()

headers = self.get_export_headers()
worksheet.append(headers)

for row in self.get_export_rows():
worksheet.append(row)

style_xlsx_worksheet(worksheet, headers)

response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
response["Content-Disposition"] = self.get_content_disposition("xlsx")
workbook.save(response)
return response

def export_json(self):
data = list(self.get_export_queryset().values(*self.get_export_fields()))
response = HttpResponse(
json.dumps(data, indent=2, default=str),
content_type="application/json",
)
response["Content-Disposition"] = self.get_content_disposition("json")
return response

def export_ods(self):
title = self.export_filename.replace("_", " ").title()
table = odfdo.Table(title)

for row_data in [self.get_export_headers()] + list(self.get_export_rows()):
row = odfdo.Row()
for value in row_data:
row.append(odfdo.Cell(str(value if value is not None else ""), cell_type="string"))
table.append(row)

document = odfdo.Document("spreadsheet")
document.body.clear()
document.body.append(table)

file_output = io.BytesIO()
document.save(file_output)

response = HttpResponse(
file_output.getvalue(),
content_type="application/vnd.oasis.opendocument.spreadsheet",
)
response["Content-Disposition"] = self.get_content_disposition("ods")
return response

def export_yaml(self):
fields = self.get_export_fields()
# Round-trip through JSON to convert Decimal and other non-serializable types
data = json.loads(json.dumps(list(self.get_export_queryset().values(*fields)), default=str))
response = HttpResponse(
saneyaml.dump(data),
content_type="application/x-yaml",
)
response["Content-Disposition"] = self.get_content_disposition("yaml")
return response


class ComplianceDashboardView(LoginRequiredMixin, ExportComplianceMixin, DataspacedFilterView):
"""Compliance control center: overview of all products."""

template_name = "product_portfolio/compliance_dashboard.html"
model = Product
filterset_class = ProductFilterSet
paginate_by = settings.DEJACODE_PAGINATE_BY.get("compliance", 50)
export_filename = "compliance_dashboard"
export_fields = {
"name": "Product",
"version": "Version",
"package_count": "Packages",
"license_error_count": "License errors",
"license_warning_count": "License warnings",
"max_risk_level": "Max risk level",
"risk_threshold": "Risk threshold",
"critical_count": "Critical",
"high_count": "High",
"medium_count": "Medium",
"low_count": "Low",
"vulnerability_count": "Total vulnerabilities",
}

def get_queryset(self):
base_qs = Product.objects.get_queryset(
Expand Down
Loading