Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
19 changes: 19 additions & 0 deletions backend/apps/owasp/api/rest/v1/project_health_metrics.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""API endpoint for OWASP project health metrics."""

from django.http import FileResponse
from ninja import Router

from apps.owasp.models.project_health_metrics import ProjectHealthMetrics

router = Router()


@router.get("/overview-pdf")
def generate_overview_pdf(request):
"""Generate a PDF overview of OWASP project health metrics."""
pdf = ProjectHealthMetrics.generate_overview_pdf()
return FileResponse(
pdf,
as_attachment=True,
filename="owasp_project_health_metrics_overview.pdf",
)
2 changes: 2 additions & 0 deletions backend/apps/owasp/api/rest/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
from apps.owasp.api.rest.v1.committee import router as committee_router
from apps.owasp.api.rest.v1.event import router as event_router
from apps.owasp.api.rest.v1.project import router as project_router
from apps.owasp.api.rest.v1.project_health_metrics import router as project_health_metrics_router

router = Router()

router.add_router(r"/chapters", chapter_router)
router.add_router(r"/committees", committee_router)
router.add_router(r"/events", event_router)
router.add_router(r"/projects", project_router)
router.add_router(r"/project-health-metrics", project_health_metrics_router)

This file was deleted.

66 changes: 66 additions & 0 deletions backend/apps/owasp/models/project_health_metrics.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
"""Project health metrics model."""

from io import BytesIO

from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.functions import ExtractMonth, TruncDate
from django.utils import timezone
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Table, TableStyle

from apps.common.models import BulkSaveModel, TimestampedModel
from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode
Expand Down Expand Up @@ -156,6 +160,68 @@ def bulk_save(metrics: list, fields: list | None = None) -> None: # type: ignor
"""
BulkSaveModel.bulk_save(ProjectHealthMetrics, metrics, fields=fields)

@staticmethod
def generate_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}"],
["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()
buffer.seek(0)
return buffer

@staticmethod
def get_latest_health_metrics() -> models.QuerySet["ProjectHealthMetrics"]:
"""Get latest health metrics for each project.
Expand Down
2 changes: 2 additions & 0 deletions backend/tests/apps/owasp/api/rest/v1/urls_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from apps.owasp.api.rest.v1.committee import router as committee_router
from apps.owasp.api.rest.v1.event import router as event_router
from apps.owasp.api.rest.v1.project import router as project_router
from apps.owasp.api.rest.v1.project_health_metrics import router as project_health_metrics_router
from apps.owasp.api.rest.v1.urls import router as main_router


Expand All @@ -15,6 +16,7 @@ class TestRouterRegistration:
"/committees": committee_router,
"/events": event_router,
"/projects": project_router,
"/project-health-metrics": project_health_metrics_router,
}

def test_all_routers_are_registered(self):
Expand Down

This file was deleted.

57 changes: 57 additions & 0 deletions backend/tests/apps/owasp/models/project_health_metrics_test.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
from unittest.mock import patch

import pytest
from django.core.exceptions import ValidationError
from django.utils import timezone

from apps.owasp.api.internal.nodes.project_health_stats import ProjectHealthStatsNode
from apps.owasp.models.project import Project
from apps.owasp.models.project_health_metrics import ProjectHealthMetrics

Expand Down Expand Up @@ -116,3 +119,57 @@ def test_handle_days_calculation(self, field_name, expected_days):
metrics.pull_request_last_created_at = self.FIXED_DATE

assert getattr(metrics, field_name) == expected_days

@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")
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}"],
["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
ProjectHealthMetrics.generate_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()
23 changes: 21 additions & 2 deletions frontend/src/app/projects/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,21 @@ 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'
import DonutBarChart from 'components/DonutBarChart'
import LineChart from 'components/LineChart'
import LoadingSpinner from 'components/LoadingSpinner'
import ProjectTypeDashboardCard from 'components/ProjectTypeDashboardCard'

const ProjectsDashboardPage: FC = () => {
const [stats, setStats] = useState<ProjectHealthStats>()
const [isLoading, setIsLoading] = useState<boolean>(true)
Expand Down Expand Up @@ -95,7 +98,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 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
Loading
Loading