diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 85000b87e0..20a72efab8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,9 +39,9 @@ Before contributing, ensure you have the following installed: #### `NEXT_PUBLIC_API_URL` -- **Description**: The base URL for the application's REST API. -- **Example Value**: `https://nest.owasp.org/api/` -- **Usage**: Used by frontend components to make REST API calls. +- **Description**: The base URL for the application's internal API. +- **Example Value**: `https://nest.owasp.org/` +- **Usage**: Used by frontend components to make API calls. #### `NEXT_PUBLIC_CSRF_URL` diff --git a/backend/apps/owasp/api/internal/views/__init__.py b/backend/apps/owasp/api/internal/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/owasp/api/internal/views/project_health_metrics.py b/backend/apps/owasp/api/internal/views/project_health_metrics.py new file mode 100644 index 0000000000..694d074741 --- /dev/null +++ b/backend/apps/owasp/api/internal/views/project_health_metrics.py @@ -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", + ) diff --git a/backend/apps/owasp/api/internal/views/urls.py b/backend/apps/owasp/api/internal/views/urls.py new file mode 100644 index 0000000000..64df900847 --- /dev/null +++ b/backend/apps/owasp/api/internal/views/urls.py @@ -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", + ), +] diff --git a/backend/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf.py b/backend/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf.py deleted file mode 100644 index 2cef279c1d..0000000000 --- a/backend/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf.py +++ /dev/null @@ -1,79 +0,0 @@ -"""A command to generate a PDF overview of OWASP project health metrics.""" - -from io import BytesIO -from pathlib import Path - -from django.conf import settings -from django.core.management.base import BaseCommand -from django.utils import timezone -from reportlab.pdfgen.canvas import Canvas -from reportlab.platypus.tables import Table, TableStyle - -import settings.base -from apps.owasp.models.project_health_metrics import ProjectHealthMetrics - - -class Command(BaseCommand): - help = "Generate a PDF overview of OWASP project health metrics." - - def handle(self, *args, **options): - 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}"], - ["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="*") - table.setStyle( - 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() - pdf_path = ( - Path(settings.base.Base.BASE_DIR) - / "reports" - / "owasp_project_health_metrics_overview.pdf" - ) - pdf_path.parent.mkdir(parents=True, exist_ok=True) - pdf_path.write_bytes(buffer.getvalue()) - buffer.close() - self.stdout.write(self.style.SUCCESS("PDF overview generated successfully.")) diff --git a/backend/apps/owasp/utils/__init__.py b/backend/apps/owasp/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/owasp/utils/pdf.py b/backend/apps/owasp/utils/pdf.py new file mode 100644 index 0000000000..5362b98754 --- /dev/null +++ b/backend/apps/owasp/utils/pdf.py @@ -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 diff --git a/backend/settings/urls.py b/backend/settings/urls.py index cc95146897..9d7cedc6cc 100644 --- a/backend/settings/urls.py +++ b/backend/settings/urls.py @@ -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 @@ -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")), ] diff --git a/backend/tests/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf_test.py b/backend/tests/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf_test.py deleted file mode 100644 index 567cf00386..0000000000 --- a/backend/tests/apps/owasp/management/commands/owasp_generate_project_health_metrics_overview_pdf_test.py +++ /dev/null @@ -1,64 +0,0 @@ -from unittest.mock import patch - -from django.conf import settings -from django.core.management import call_command - -from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode - - -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 - ): - """Test that the command executes without errors.""" - metrics_stats = ProjectHealthStatsNode( - projects_count_healthy=10, - projects_count_unhealthy=5, - projects_count_need_attention=3, - average_score=75.0, - total_contributors=150, - total_forks=200, - total_stars=300, - projects_percentage_healthy=66.67, - projects_percentage_need_attention=20.00, - projects_percentage_unhealthy=13.33, - monthly_overall_scores=[], - monthly_overall_scores_months=[], - ) - 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}"], - ["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}", - ], - ] - mock_get_stats.return_value = metrics_stats - call_command("owasp_generate_project_health_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_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) diff --git a/backend/tests/apps/owasp/utils/__init__.py b/backend/tests/apps/owasp/utils/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/apps/owasp/utils/pdf_test.py b/backend/tests/apps/owasp/utils/pdf_test.py new file mode 100644 index 0000000000..e83ac31eda --- /dev/null +++ b/backend/tests/apps/owasp/utils/pdf_test.py @@ -0,0 +1,77 @@ +"""Test cases for OWASP project health metrics pdf generation.""" + +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 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( + projects_count_healthy=10, + projects_count_unhealthy=5, + projects_count_need_attention=3, + average_score=75.0, + total_contributors=150, + total_forks=200, + total_stars=300, + projects_percentage_healthy=66.67, + projects_percentage_need_attention=20.00, + projects_percentage_unhealthy=13.33, + monthly_overall_scores=[], + monthly_overall_scores_months=[], + ) + 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}%", + ), + ) + mock_get_stats.return_value = metrics_stats + 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="*", 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() diff --git a/frontend/.env.example b/frontend/.env.example index 8f8e10899a..e7ea1cb531 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,4 +1,4 @@ -NEXT_PUBLIC_API_URL=http://localhost:8000/api/v1/ +NEXT_PUBLIC_API_URL=http://localhost:8000/ NEXT_PUBLIC_CSRF_URL=http://localhost:8000/csrf/ NEXT_PUBLIC_ENVIRONMENT=local NEXT_PUBLIC_GRAPHQL_URL=http://localhost:8000/graphql/ @@ -14,4 +14,4 @@ NEXT_SERVER_GITHUB_CLIENT_ID= NEXT_SERVER_GITHUB_CLIENT_SECRET= NEXT_SERVER_GRAPHQL_URL=http://backend:8000/graphql/ NEXTAUTH_SECRET= -NEXTAUTH_URL=http://localhost:3000 +NEXTAUTH_URL=http://localhost:3000/ diff --git a/frontend/src/app/projects/dashboard/page.tsx b/frontend/src/app/projects/dashboard/page.tsx index d8db45883e..df94f539f5 100644 --- a/frontend/src/app/projects/dashboard/page.tsx +++ b/frontend/src/app/projects/dashboard/page.tsx @@ -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' @@ -95,7 +99,23 @@ const ProjectsDashboardPage: FC = () => { ] return ( <> -

Project Health Dashboard Overview

+
+

Project Health Dashboard Overview

+ + await fetchMetricsOverviewPDF()} + /> + +
{projectsCardsItems.map((item) => ( => { + try { + const response = await fetch(`${API_URL}/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 date = new Date().toISOString().split('T')[0].replaceAll('-', '_') + const fileName = `owasp_metrics_overview_${date}.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') + ) + } +}