Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 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
Empty file.
16 changes: 16 additions & 0 deletions backend/apps/owasp/api/internal/views/project_health_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Views for OWASP project health metrics."""

from django.http import FileResponse
from django.views.decorators.http import require_GET

from apps.owasp.utils.pdf import generate_metrics_overview_pdf


@require_GET
def generate_overview_pdf(_request):
"""Generate a PDF overview of OWASP project health metrics."""
return FileResponse(
generate_metrics_overview_pdf(),
as_attachment=True,
filename="owasp_project_health_metrics_overview.pdf",
)
13 changes: 13 additions & 0 deletions backend/apps/owasp/api/internal/views/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""URLs for OWASP project health metrics."""

from django.urls import path

from apps.owasp.api.internal.views.project_health_metrics import generate_overview_pdf

urlpatterns = [
path(
"project-health-metrics/overview/pdf/",
generate_overview_pdf,
name="project_health_metrics_overview_pdf",
),
]

This file was deleted.

Empty file.
77 changes: 77 additions & 0 deletions backend/apps/owasp/utils/pdf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""PDF generation for OWASP project health metrics."""

from io import BytesIO

from django.utils import timezone
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Table, TableStyle

from apps.owasp.models.project_health_metrics import ProjectHealthMetrics


def generate_metrics_overview_pdf() -> BytesIO:
"""Generate a PDF overview of project health metrics.

Returns:
BytesIO: PDF content as bytes.

"""
metrics_stats = ProjectHealthMetrics.get_stats()

buffer = BytesIO()
canvas = Canvas(buffer)
canvas.setFont("Helvetica", 12)
canvas.setTitle("OWASP Project Health Metrics Overview")
canvas.drawCentredString(300, 800, "OWASP Project Health Metrics Overview")

table_data = (
["Metric", "Value"],
["Healthy Projects", f"{metrics_stats.projects_count_healthy}"],
["Unhealthy Projects", f"{metrics_stats.projects_count_unhealthy}"],
["Need Attention Projects", f"{metrics_stats.projects_count_need_attention}"],
[
"Average Score",
f"{metrics_stats.average_score:.2f}"
if metrics_stats.average_score is not None
else "N/A",
],
["Total Contributors", f"{metrics_stats.total_contributors:,}"],
["Total Forks", f"{metrics_stats.total_forks:,}"],
["Total Stars", f"{metrics_stats.total_stars:,}"],
[
"Healthy Projects Percentage",
f"%{metrics_stats.projects_percentage_healthy:.2f}",
],
[
"Need Attention Projects Percentage",
f"%{metrics_stats.projects_percentage_need_attention:.2f}",
],
[
"Unhealthy Projects Percentage",
f"%{metrics_stats.projects_percentage_unhealthy:.2f}",
],
)

table = Table(
table_data,
colWidths="*",
style=TableStyle(
[
("BACKGROUND", (0, 0), (-1, 0), "lightgrey"),
("TEXTCOLOR", (0, 0), (-1, 0), "black"),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("BOTTOMPADDING", (0, 0), (-1, 0), 5),
("BACKGROUND", (0, 1), (-1, -1), "white"),
]
),
)
table.wrapOn(canvas, 400, 600)
table.drawOn(canvas, 100, 570)
canvas.drawCentredString(
300, 100, f"Generated on: {timezone.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
canvas.showPage()
canvas.save()
buffer.seek(0)
return buffer
2 changes: 2 additions & 0 deletions backend/settings/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from apps.core.api.internal.algolia import algolia_search
from apps.core.api.internal.csrf import get_csrf_token
from apps.core.api.internal.status import get_status
from apps.owasp.api.internal.views.urls import urlpatterns as owasp_urls
from apps.slack.apps import SlackConfig
from settings.api.v1 import api as api_v1
from settings.graphql import schema
Expand All @@ -24,6 +25,7 @@
path("graphql/", csrf_protect(GraphQLView.as_view(schema=schema, graphiql=settings.DEBUG))),
path("api/v1/", api_v1.urls),
path("a/", admin.site.urls),
path("owasp/", include(owasp_urls)),
path("status/", get_status),
path("", include("apps.sitemap.urls")),
]
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -1,20 +1,24 @@
from unittest.mock import patch
"""Test cases for OWASP project health metrics pdf generation."""

from django.conf import settings
from django.core.management import call_command
from unittest.mock import patch

from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode
from apps.owasp.utils.pdf import generate_metrics_overview_pdf


class TestOwaspGenerateProjectHealthMetricsOverviewPdf:
@patch("apps.owasp.models.project_health_metrics.ProjectHealthMetrics.get_stats")
@patch("reportlab.pdfgen.canvas.Canvas")
@patch("reportlab.platypus.tables.Table")
@patch("reportlab.platypus.tables.TableStyle")
@patch("io.BytesIO")
@patch("pathlib.Path")
def test_command_execution(
self, mock_path, mock_bytes_io, mock_table_style, mock_table, mock_canvas, mock_get_stats
class TestGenerateMetricsPDF:
@patch("apps.owasp.utils.pdf.ProjectHealthMetrics.get_stats")
@patch("apps.owasp.utils.pdf.Canvas")
@patch("apps.owasp.utils.pdf.Table")
@patch("apps.owasp.utils.pdf.TableStyle")
@patch("apps.owasp.utils.pdf.BytesIO")
def test_generate_overview_pdf(
self,
mock_bytes_io,
mock_table_style,
mock_table,
mock_canvas,
mock_get_stats,
):
"""Test that the command executes without errors."""
metrics_stats = ProjectHealthStatsNode(
Expand All @@ -31,16 +35,24 @@ def test_command_execution(
monthly_overall_scores=[],
monthly_overall_scores_months=[],
)
table_data = [
table_data = (
["Metric", "Value"],
["Healthy Projects", f"{metrics_stats.projects_count_healthy}"],
["Unhealthy Projects", f"{metrics_stats.projects_count_unhealthy}"],
["Need Attention Projects", f"{metrics_stats.projects_count_need_attention}"],
["Average Score", f"{metrics_stats.average_score:.2f}"],
[
"Average Score",
f"{metrics_stats.average_score:.2f}"
if metrics_stats.average_score is not None
else "N/A",
],
["Total Contributors", f"{metrics_stats.total_contributors:,}"],
["Total Forks", f"{metrics_stats.total_forks:,}"],
["Total Stars", f"{metrics_stats.total_stars:,}"],
["Healthy Projects Percentage", f"%{metrics_stats.projects_percentage_healthy:.2f}"],
[
"Healthy Projects Percentage",
f"%{metrics_stats.projects_percentage_healthy:.2f}",
],
[
"Need Attention Projects Percentage",
f"%{metrics_stats.projects_percentage_need_attention:.2f}",
Expand All @@ -49,16 +61,17 @@ def test_command_execution(
"Unhealthy Projects Percentage",
f"%{metrics_stats.projects_percentage_unhealthy:.2f}",
],
]
)
mock_get_stats.return_value = metrics_stats
call_command("owasp_generate_project_health_metrics_overview_pdf")
generate_metrics_overview_pdf()
mock_bytes_io.assert_called_once()
mock_canvas.assert_called_once_with(mock_bytes_io.return_value)
canvas = mock_canvas.return_value
mock_table.assert_called_once_with(table_data, colWidths="*")
mock_table.assert_called_once_with(
table_data, colWidths="*", style=mock_table_style.return_value
)
mock_table_style.assert_called_once()
mock_table.return_value.wrapOn.assert_called_once_with(canvas, 400, 600)
mock_table.return_value.drawOn.assert_called_once_with(canvas, 100, 570)
canvas.showPage.assert_called_once()
canvas.save.assert_called_once()
mock_path.assert_called_once_with(settings.BASE_DIR)
22 changes: 21 additions & 1 deletion frontend/src/app/projects/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,14 @@ import {
faCodeBranch,
faChartColumn,
faHeart,
faFileArrowDown,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Tooltip } from '@heroui/tooltip'
import millify from 'millify'
import { useState, useEffect, FC } from 'react'
import { handleAppError } from 'app/global-error'
import { fetchMetricsOverviewPDF } from 'server/fetchMetricsOverviewPDF'
import { GET_PROJECT_HEALTH_STATS } from 'server/queries/projectsHealthDashboardQueries'
import type { ProjectHealthStats } from 'types/projectHealthStats'
import DashboardCard from 'components/DashboardCard'
Expand Down Expand Up @@ -95,7 +99,23 @@ const ProjectsDashboardPage: FC = () => {
]
return (
<>
<h1 className="font-semibold">Project Health Dashboard Overview</h1>
<div className="mb-4 flex items-center justify-start">
<h1 className="font-semibold">Project Health Dashboard Overview</h1>
<Tooltip
content="Download as PDF"
className="ml-2"
placement="top"
delay={100}
closeDelay={100}
showArrow
>
<FontAwesomeIcon
icon={faFileArrowDown}
className="ml-2 h-7 w-7 cursor-pointer text-gray-500 transition-colors duration-200 hover:text-gray-700"
onClick={async () => await fetchMetricsOverviewPDF()}
/>
</Tooltip>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 md:grid-cols-3">
{projectsCardsItems.map((item) => (
<ProjectTypeDashboardCard
Expand Down
39 changes: 39 additions & 0 deletions frontend/src/server/fetchMetricsOverviewPDF.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { handleAppError } from 'app/global-error'
import { API_URL } from 'utils/credentials'

export const fetchMetricsOverviewPDF = async (): Promise<void> => {
try {
const baseUrl = API_URL.split('/api')[0]
const response = await fetch(`${baseUrl}/owasp/project-health-metrics/overview/pdf`, {
method: 'GET',
headers: {
accept: 'application/pdf',
},
})

if (!response.ok) {
const message = `Failed to fetch metrics overview PDF: ${response.status} ${response.statusText}`
handleAppError(new Error(message))
}

const pdfBlob = await response.blob()
if (pdfBlob.size === 0) {
handleAppError(new Error('PDF blob is empty or undefined'))
}
const pdfUrl = window.URL.createObjectURL(pdfBlob)
const link = document.createElement('a')
link.href = pdfUrl
const fileName = `owasp_metrics_overview_${new Date().toISOString().split('T')[0]}.pdf`
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(pdfUrl)
} catch (error) {
handleAppError(
error instanceof Error
? error
: new Error('An unexpected error occurred while fetching the PDF')
)
}
}