Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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.models.project_health_metrics import ProjectHealthMetrics


@require_GET
def generate_overview_pdf(_request):
"""Generate a PDF overview of OWASP project health metrics."""
return FileResponse(
ProjectHealthMetrics.generate_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.

67 changes: 67 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,69 @@ 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="*",
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

@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/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

This file was deleted.

64 changes: 64 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,64 @@ 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("apps.owasp.models.project_health_metrics.Canvas")
@patch("apps.owasp.models.project_health_metrics.Table")
@patch("apps.owasp.models.project_health_metrics.TableStyle")
@patch("apps.owasp.models.project_health_metrics.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="*", 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()
1 change: 1 addition & 0 deletions frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ NEXT_PUBLIC_IDX_URL=http://localhost:8000/idx/
NEXT_PUBLIC_IS_PROJECT_HEALTH_ENABLED=true
NEXT_PUBLIC_RELEASE_VERSION=
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_VIEWS_URL=http://localhost:8000/
NEXT_SENTRY_AUTH_TOKEN=
NEXT_SERVER_CSRF_URL=http://backend:8000/csrf/
NEXT_SERVER_DISABLE_SSR=false
Expand Down
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
Loading