From a2fa62828cbf6a7b9778927b5d96b57aaedf3be6 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Sat, 25 Jul 2026 00:27:01 +0530 Subject: [PATCH 01/16] Setup Insights App Signed-off-by: Sampurna Pyne --- insights/__init__.py | 8 ++++++++ insights/apps.py | 14 ++++++++++++++ vulnerabilities/templates/navbar.html | 3 +++ vulnerablecode/settings.py | 1 + vulnerablecode/urls.py | 1 + 5 files changed, 27 insertions(+) create mode 100644 insights/__init__.py create mode 100644 insights/apps.py diff --git a/insights/__init__.py b/insights/__init__.py new file mode 100644 index 000000000..20854f2ad --- /dev/null +++ b/insights/__init__.py @@ -0,0 +1,8 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# diff --git a/insights/apps.py b/insights/apps.py new file mode 100644 index 000000000..7200f28cd --- /dev/null +++ b/insights/apps.py @@ -0,0 +1,14 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from django.apps import AppConfig + + +class InsightsConfig(AppConfig): + name = "insights" diff --git a/vulnerabilities/templates/navbar.html b/vulnerabilities/templates/navbar.html index 530c2521e..6e9979019 100644 --- a/vulnerabilities/templates/navbar.html +++ b/vulnerabilities/templates/navbar.html @@ -26,6 +26,9 @@ From 451e98596d260de3ebd93aa43f503759457563aa Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Wed, 12 Aug 2026 03:12:36 +0530 Subject: [PATCH 12/16] Addmodels for Overview and Data Quality Panel along with migration script Signed-off-by: Sampurna Pyne --- ...nsight_overviewcoverageinsight_and_more.py | 150 ++++++++++++++++++ insights/models.py | 65 ++++++++ 2 files changed, 215 insertions(+) create mode 100644 insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py diff --git a/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py b/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py new file mode 100644 index 000000000..be15737cb --- /dev/null +++ b/insights/migrations/0002_overviewinsight_overviewcoverageinsight_and_more.py @@ -0,0 +1,150 @@ +# Generated by Django 5.2.11 on 2026-08-08 22:59 + +import django.contrib.postgres.fields +import django.db.models.deletion +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("insights", "0001_initial"), + ] + + operations = [ + migrations.CreateModel( + name="OverviewInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("total_advisories", models.IntegerField(default=0)), + ("total_packages", models.IntegerField(default=0)), + ("total_data_sources", models.IntegerField(default=0)), + ( + "last_30days", + django.contrib.postgres.fields.ArrayField( + base_field=models.IntegerField(), default=list, size=None + ), + ), + ( + "snapshot", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="overview", + to="insights.dailysnapshot", + ), + ), + ], + ), + migrations.CreateModel( + name="OverviewCoverageInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("source_name", models.CharField(max_length=100)), + ("count", models.IntegerField()), + ( + "overview", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="coverage_insights", + to="insights.overviewinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="OverviewYearlyInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("year", models.IntegerField()), + ("count", models.IntegerField()), + ( + "overview", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="yearly_insights", + to="insights.overviewinsight", + ), + ), + ], + ), + migrations.CreateModel( + name="DataQualityIssueByDatasourceInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("issue_type", models.CharField(max_length=50)), + ("datasource_id", models.CharField(max_length=100)), + ("count", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="data_quality_issue_types", + to="insights.dailysnapshot", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("snapshot", "issue_type", "datasource_id"), + name="unique_snapshot_issue_type_datasource", + ) + ], + }, + ), + migrations.CreateModel( + name="DataQualityToDosResolutionInsight", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("datasource_id", models.CharField(max_length=100)), + ( + "month", + models.DateField(help_text="The first day of the month for this data point."), + ), + ("open_count", models.IntegerField(default=0)), + ("resolved_count", models.IntegerField(default=0)), + ( + "snapshot", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="data_quality_todos_resolutions", + to="insights.dailysnapshot", + ), + ), + ], + options={ + "constraints": [ + models.UniqueConstraint( + fields=("snapshot", "datasource_id", "month"), + name="unique_snapshot_todos_resolution_datasource_month", + ) + ], + }, + ), + ] diff --git a/insights/models.py b/insights/models.py index ed01830ce..89f9843c9 100644 --- a/insights/models.py +++ b/insights/models.py @@ -24,6 +24,36 @@ class Meta: ordering = ["-created_at"] +class OverviewInsight(models.Model): + snapshot = models.OneToOneField( + "DailySnapshot", related_name="overview", on_delete=models.CASCADE + ) + + total_advisories = models.IntegerField(default=0) + total_packages = models.IntegerField(default=0) + total_data_sources = models.IntegerField(default=0) + last_30days = ArrayField( + models.IntegerField(), + default=list, + ) + + +class OverviewYearlyInsight(models.Model): + overview = models.ForeignKey( + OverviewInsight, related_name="yearly_insights", on_delete=models.CASCADE + ) + year = models.IntegerField() + count = models.IntegerField() + + +class OverviewCoverageInsight(models.Model): + overview = models.ForeignKey( + OverviewInsight, related_name="coverage_insights", on_delete=models.CASCADE + ) + source_name = models.CharField(max_length=100) + count = models.IntegerField() + + class PackageInsight(models.Model): snapshot = models.ForeignKey( DailySnapshot, related_name="package_insights", on_delete=models.CASCADE @@ -95,6 +125,41 @@ class Meta: ] +class DataQualityIssueByDatasourceInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="data_quality_issue_types", on_delete=models.CASCADE + ) + issue_type = models.CharField(max_length=50) + datasource_id = models.CharField(max_length=100) + count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["snapshot", "issue_type", "datasource_id"], + name="unique_snapshot_issue_type_datasource", + ) + ] + + +class DataQualityToDosResolutionInsight(models.Model): + snapshot = models.ForeignKey( + DailySnapshot, related_name="data_quality_todos_resolutions", on_delete=models.CASCADE + ) + datasource_id = models.CharField(max_length=100) + month = models.DateField(help_text="The first day of the month for this data point.") + open_count = models.IntegerField(default=0) + resolved_count = models.IntegerField(default=0) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["snapshot", "datasource_id", "month"], + name="unique_snapshot_todos_resolution_datasource_month", + ) + ] + + @dataclass(frozen=True) class ChartDefinition: From cdc94bd3092542e3bc6d3f287ed3263f4f3cc77e Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Wed, 12 Aug 2026 03:13:42 +0530 Subject: [PATCH 13/16] Add charts for Overview and Data Quality Panel Signed-off-by: Sampurna Pyne --- insights/charts/data_quality_panel.py | 238 ++++++++++++++++++++++++++ insights/charts/overview_panel.py | 186 ++++++++++++++++++++ insights/utils.py | 5 + 3 files changed, 429 insertions(+) create mode 100644 insights/charts/data_quality_panel.py create mode 100644 insights/charts/overview_panel.py diff --git a/insights/charts/data_quality_panel.py b/insights/charts/data_quality_panel.py new file mode 100644 index 000000000..43ad03134 --- /dev/null +++ b/insights/charts/data_quality_panel.py @@ -0,0 +1,238 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from collections import defaultdict +from datetime import timedelta +from itertools import accumulate +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models.functions import TruncMonth +from django.utils import timezone + +from insights.models import DataQualityIssueByDatasourceInsight +from insights.models import DataQualityToDosResolutionInsight +from insights.utils import format_issue_type_label +from vulnerabilities.models import AdvisoryToDoV2 + +# Ignore Phantom Importers that don't collect Affected Packages +IGNORED_IMPORTERS = { + "epss_importer_v2", + "epss", + "vulnrichment_importer_v2", + "vulnrichment", + "suse_importer_v2", + "suse_score", +} + + +# Open Issues by Type and Datasource Contribution per Open Issue +def open_issues_to_datasource_queryset(): + """Return a query set of open issue counts by type and datasource.""" + return ( + AdvisoryToDoV2.objects.filter(is_resolved=False, advisories__datasource_id__isnull=False) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .values("issue_type", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + + +def iter_open_issues_to_datasource_insights(): + """Yield DataQualityIssueByDatasourceInsight objects.""" + for row in open_issues_to_datasource_queryset(): + yield DataQualityIssueByDatasourceInsight( + issue_type=row["issue_type"], + datasource_id=row["advisories__datasource_id"], + count=row["count"], + ) + + +def collect_open_issues_to_datasource(pipeline: Any) -> None: + """Collect open issue counts by type and datasource.""" + pipeline.data_quality_issue_types = list(iter_open_issues_to_datasource_insights()) + + +def build_issue_type_columns(issue_counts: dict) -> Dict[str, Any]: + """Helper to build column data for the issue type bar chart as expected by Billboard""" + issue_types = list(issue_counts.keys()) + open_issue_counts = list(issue_counts.values()) + + return { + "columns": [ + ["x"] + issue_types, + ["To-Dos"] + open_issue_counts, + ], + "x_label": "Type of Issue", + "y_label": "To-Dos Open Issue Count", + "color": "var(--bulma-danger)", + } + + +def format_issue_type_bar(snapshot: Any) -> Dict[str, Any]: + """Format open issues by type per datasource for the colored bar chart.""" + open_issue_counts_by_datasource = defaultdict(dict) + total_issues_by_type = defaultdict(int) + + for insight in snapshot.data_quality_issue_types.all(): + label = format_issue_type_label(insight.issue_type) + open_issue_counts_by_datasource[insight.datasource_id][label] = insight.count + total_issues_by_type[label] += insight.count + + data = {} + for datasource_id, counts in open_issue_counts_by_datasource.items(): + data[datasource_id] = build_issue_type_columns(counts) + + if total_issues_by_type: + data["global"] = build_issue_type_columns(total_issues_by_type) + + return data + + +def format_issue_contribution_donut(snapshot: Any) -> Dict[str, Any]: + """Format datasource contribution per open issue for the donut chart as expected by Billboard""" + datasource_counts_by_issue = defaultdict(dict) + total_issues_by_datasource = defaultdict(int) + + for insight in snapshot.data_quality_issue_types.all(): + label = format_issue_type_label(insight.issue_type) + datasource_counts_by_issue[label][insight.datasource_id] = insight.count + total_issues_by_datasource[insight.datasource_id] += insight.count + + data = {} + for issue_type, counts in datasource_counts_by_issue.items(): + columns = [[datasource_id, count] for datasource_id, count in counts.items()] + data[issue_type] = {"columns": columns} + + if total_issues_by_datasource: + global_columns = [ + [datasource_id, count] for datasource_id, count in total_issues_by_datasource.items() + ] + data["global"] = {"columns": global_columns} + + return data + + +# To-Dos Issue Resolution Timeline +def data_quality_todos_resolutions_queryset(): + """Return resolution rates by month for open and resolved to-dos.""" + start_date = timezone.now() - timedelta(days=365) # Collect last 12 months only + + open_todos = ( + AdvisoryToDoV2.objects.filter( + created_at__isnull=False, + created_at__gte=start_date, + advisories__datasource_id__isnull=False, + ) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .annotate(month=TruncMonth("created_at")) + .values("month", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + + resolved_todos = ( + AdvisoryToDoV2.objects.filter( + is_resolved=True, + resolved_at__isnull=False, + resolved_at__gte=start_date, + advisories__datasource_id__isnull=False, + ) + .exclude(advisories__datasource_id__in=IGNORED_IMPORTERS) + .annotate(month=TruncMonth("resolved_at")) + .values("month", "advisories__datasource_id") + .annotate(count=Count("todo_id", distinct=True)) + .iterator() + ) + return open_todos, resolved_todos + + +def iter_data_quality_todos_resolutions_insights(): + """Yield DataQualityToDosResolutionInsight objects.""" + open_todos, resolved_todos = data_quality_todos_resolutions_queryset() + + open_counts = defaultdict(int) + for open_record in open_todos: + datasource = open_record["advisories__datasource_id"] + month = open_record["month"].date() + open_counts[datasource, month] += open_record["count"] + + resolved_counts = defaultdict(int) + for resolved_record in resolved_todos: + datasource = resolved_record["advisories__datasource_id"] + month = resolved_record["month"].date() + resolved_counts[datasource, month] += resolved_record["count"] + + all_keys = set(open_counts.keys()) | set( + resolved_counts.keys() + ) # All unique (datasource, month) pairs across opened and resolved to-dos + + for datasource_id, month_date in sorted(all_keys): + yield DataQualityToDosResolutionInsight( + datasource_id=datasource_id, + month=month_date, + open_count=open_counts[(datasource_id, month_date)], + resolved_count=resolved_counts[(datasource_id, month_date)], + ) + + +def collect_data_quality_todos_resolutions(pipeline: Any) -> None: + """Collect historical resolution rates by month.""" + pipeline.data_quality_todos_resolutions = list(iter_data_quality_todos_resolutions_insights()) + + +def build_todos_resolution_columns(month_counts: Dict[str, Dict[str, int]]) -> Dict[str, Any]: + """Helper to build issue resolution line chart as expected by Billboard""" + sorted_months = sorted(month_counts.keys()) + new_open_counts = [month_counts[month]["open"] for month in sorted_months] + new_resolved_counts = [month_counts[month]["resolved"] for month in sorted_months] + + return { + "columns": [ + ["x"] + sorted_months, + ["Open"] + list(accumulate(new_open_counts)), + ["Resolved"] + list(accumulate(new_resolved_counts)), + ], + "new_open_counts": new_open_counts, + "new_resolved_counts": new_resolved_counts, + "y_label": "Cumulative Count", + } + + +def format_todos_resolution_timeline(snapshot: Any) -> Dict[str, Any]: + """Format cumulative to-dos resolution line chart""" + resolution_insights = snapshot.data_quality_todos_resolutions.all().order_by("month") + + datasource_month_counts = defaultdict(dict) + global_month_counts = {} + + for insight in resolution_insights: + formatted_month = insight.month.strftime("%Y-%m-%d") + datasource_id = insight.datasource_id + + # Initialize global month count if not exists + if formatted_month not in global_month_counts: + global_month_counts[formatted_month] = {"open": 0, "resolved": 0} + + global_month_counts[formatted_month]["open"] += insight.open_count + global_month_counts[formatted_month]["resolved"] += insight.resolved_count + + datasource_month_counts[datasource_id][formatted_month] = { + "open": insight.open_count, + "resolved": insight.resolved_count, + } + + data = {} + if global_month_counts: + data["global"] = build_todos_resolution_columns(global_month_counts) + for datasource_id, month_counts in datasource_month_counts.items(): + data[datasource_id] = build_todos_resolution_columns(month_counts) + + return data diff --git a/insights/charts/overview_panel.py b/insights/charts/overview_panel.py new file mode 100644 index 000000000..fab450e41 --- /dev/null +++ b/insights/charts/overview_panel.py @@ -0,0 +1,186 @@ +# +# Copyright (c) nexB Inc. and others. All rights reserved. +# VulnerableCode is a trademark of nexB Inc. +# SPDX-License-Identifier: Apache-2.0 +# See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +# See https://github.com/aboutcode-org/vulnerablecode for support or download. +# See https://aboutcode.org for more information about nexB OSS projects. +# + +from datetime import timedelta +from typing import Any +from typing import Dict + +from django.db.models import Count +from django.db.models.functions import ExtractYear +from django.db.models.functions import TruncDate +from django.utils import timezone + +from insights.models import DailySnapshot +from insights.models import OverviewInsight +from insights.models import OverviewYearlyInsight +from insights.utils import format_importer_name +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import PackageV2 + + +# Overview KPIs +def kpi_card_queryset() -> Dict[str, int]: + """Return querysets for KPI cards.""" + return { + "total_advisories": AdvisoryV2.objects.filter(is_latest=True).aggregate( + count=Count("avid", distinct=True) + )["count"], + "total_packages": PackageV2.objects.values("plain_package_url").distinct().count(), + "total_data_sources": AdvisoryV2.objects.values("datasource_id").distinct().count(), + } + + +def daily_growth_counts(reference_time) -> list[int]: + """Return daily advisory ingestion counts for the last 30 days as a list.""" + start_date = reference_time.date() - timedelta(days=30) + daily_counts = { + entry["day"]: entry["count"] + for entry in ( + AdvisoryV2.objects.filter(is_latest=True, date_collected__gte=start_date) + .annotate(day=TruncDate("date_collected")) + .values("day") + .annotate(count=Count("avid", distinct=True)) + ) + if entry["day"] + } + return [daily_counts.get(start_date + timedelta(days=i), 0) for i in range(31)] + + +def collect_kpi_card(pipeline: Any) -> None: + """Collect overall totals and 30-day ingestion counts for OverviewInsight.""" + reference_time = timezone.now() + pipeline.overview_insight = OverviewInsight( + **kpi_card_queryset(), + last_30days=daily_growth_counts(reference_time), + ) + + +def build_kpi_card( + overview: OverviewInsight, previous_snapshot: DailySnapshot = None +) -> Dict[str, Any]: + """Build the KPI card data with deltas compared to the previous snapshot.""" + delta_advisories = 0 + delta_packages = 0 + delta_sources = 0 + + if previous_snapshot: + previous_overview = previous_snapshot.overview + delta_advisories = overview.total_advisories - previous_overview.total_advisories + delta_packages = overview.total_packages - previous_overview.total_packages + delta_sources = overview.total_data_sources - previous_overview.total_data_sources + + return { + "total_advisories": overview.total_advisories, + "delta_advisories": delta_advisories, + "total_packages": overview.total_packages, + "delta_packages": delta_packages, + "total_data_sources": overview.total_data_sources, + "delta_data_sources": delta_sources, + } + + +def format_kpi_card(snapshot: Any) -> Dict[str, Any]: + """Format the Overview KPI cards and calculate deltas compared to the previous snapshot.""" + previous_snapshot = ( + DailySnapshot.objects.filter(created_at__lt=snapshot.created_at) + .order_by("-created_at") + .first() + ) + return {"kpis": build_kpi_card(snapshot.overview, previous_snapshot)} + + +# Year wise distribution of advisories +def yearly_distribution_queryset(): + """Return a queryset of advisories grouped by year for the last 10 years.""" + current_year = timezone.now().year + start_year = current_year - 9 + + return ( + AdvisoryV2.objects.filter( + date_published__isnull=False, + date_published__year__gte=start_year, + date_published__year__lte=current_year, + ) + .annotate(year=ExtractYear("date_published")) + .values("year") + .annotate(count=Count("avid", distinct=True)) + .order_by("-year") + .iterator() + ) + + +def iter_yearly_distribution_insights(): + """Yield OverviewYearlyInsight objects.""" + for yearly_record in yearly_distribution_queryset(): + yield OverviewYearlyInsight(**yearly_record) + + +def collect_yearly_distribution(pipeline: Any) -> None: + """Collect advisories by published year for the last 10 years.""" + pipeline.overview_yearly_insights = list(iter_yearly_distribution_insights()) + + +def format_yearly_distribution(snapshot: Any) -> Dict[str, Any]: + """Format historical advisories published over the last 10 years as expected by Billboard""" + yearly_insights = snapshot.overview.yearly_insights.order_by("year") + + return { + "columns": [ + ["x"] + [str(yi.year) for yi in yearly_insights], + ["Advisories Published"] + [yi.count for yi in yearly_insights], + ], + "x_label": "Year", + "y_label": "Advisories Published", + "color": "var(--bulma-primary)", + "rotated": False, + "x_tick_culling": 15, + } + + +# Advisory Ingestion Trend +def format_growth_trend(snapshot: Any) -> Dict[str, Any]: + """Format daily advisory ingestion trend as expected by Billboard.""" + reference_time = getattr(snapshot, "created_at", None) or timezone.now() + start_date = reference_time.date() - timedelta(days=30) + + dates = [(start_date + timedelta(days=i)).strftime("%b %d") for i in range(31)] + counts = getattr(snapshot.overview, "last_30days", []) or [0] * len(dates) + + return { + "columns": [ + ["x"] + dates, + ["Advisories Imported"] + counts, + ], + "x_label": "Date", + "y_label": "Advisories Imported", + "color": "var(--bulma-primary-dark)", + "rotated": False, + "x_tick_culling": 7, + } + + +# Coverage Comparison +def format_coverage_comparison(snapshot: Any) -> Dict[str, Any]: + """Format the coverage comparison graph as expected by Billboard.""" + importers = list(snapshot.importer_insights.all().order_by("-total_advisories")) + importer_names = [format_importer_name(importer.importer) for importer in importers] + total_vulnerablecode_advisories = sum(importer.total_advisories for importer in importers) + + columns = [ + ["x", "VulnerableCode", "Sources"], + ["VulnerableCode", total_vulnerablecode_advisories, 0], + ] + for importer in importers: + columns.append([format_importer_name(importer.importer), 0, importer.total_advisories]) + + return { + "columns": columns, + "groups": [importer_names], + "y_label": "Advisories", + } diff --git a/insights/utils.py b/insights/utils.py index e4d0e6471..30ea807c8 100644 --- a/insights/utils.py +++ b/insights/utils.py @@ -28,3 +28,8 @@ def format_importer_name(name: str) -> str: if len(parts) == 2 and parts[1].isdigit(): name = parts[0] return name.replace("_", " ") + + +def format_issue_type_label(issue_type: str) -> str: + """Format issue types labels for UI""" + return issue_type.replace("_", " ").lower() From 76b9540847290c35ddc3cd43a2af6a3a9a7e7900 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Wed, 12 Aug 2026 03:15:04 +0530 Subject: [PATCH 14/16] Register charts in Overview and Data Quality Panel Signed-off-by: Sampurna Pyne --- insights/charts/__init__.py | 66 ++++++++++++++++++++++++++ insights/insights_snapshot_pipeline.py | 33 +++++++++++++ 2 files changed, 99 insertions(+) diff --git a/insights/charts/__init__.py b/insights/charts/__init__.py index a1f26ad73..c989f493a 100644 --- a/insights/charts/__init__.py +++ b/insights/charts/__init__.py @@ -2,10 +2,21 @@ from insights.models import ChartDefinition +from .data_quality_panel import collect_data_quality_todos_resolutions +from .data_quality_panel import collect_open_issues_to_datasource +from .data_quality_panel import format_issue_contribution_donut +from .data_quality_panel import format_issue_type_bar +from .data_quality_panel import format_todos_resolution_timeline from .importer_panel import _get_snapshot_data from .importer_panel import build_importer_exploit_columns from .importer_panel import build_importer_package_columns from .importer_panel import collect_importers +from .overview_panel import collect_kpi_card +from .overview_panel import collect_yearly_distribution +from .overview_panel import format_coverage_comparison +from .overview_panel import format_growth_trend +from .overview_panel import format_kpi_card +from .overview_panel import format_yearly_distribution from .package_panel import collect_cwes from .package_panel import collect_ecosystem_distribution from .package_panel import collect_packages @@ -16,6 +27,38 @@ from .severity_panel import get_severity_snapshot_data CHARTS = [ + ChartDefinition( + id="overview-stats", + title="Overview Stats", + panel="overview_panel", + chart_type="custom", + formatter_fn=format_kpi_card, + collect_fn=collect_kpi_card, + ), + ChartDefinition( + id="overview-cross-validation", + title="Coverage Comparison", + panel="overview_panel", + chart_type="stacked_bar", + formatter_fn=format_coverage_comparison, + collect_fn=collect_importers, + ), + ChartDefinition( + id="overview-growth-trend", + title="Advisories Imported in the last 30 days", + panel="overview_panel", + chart_type="colored_bar", + formatter_fn=format_growth_trend, + collect_fn=collect_kpi_card, + ), + ChartDefinition( + id="overview-historical", + title="Advisories Published in the Last 10 years", + panel="overview_panel", + chart_type="colored_bar", + formatter_fn=format_yearly_distribution, + collect_fn=collect_yearly_distribution, + ), ChartDefinition( id="pkg-dist-donut", title="Ecosystem Distribution", @@ -68,6 +111,29 @@ formatter_fn=partial(_get_snapshot_data, build_columns_fn=build_importer_exploit_columns), collect_fn=collect_importers, ), + ChartDefinition( + id="dq-issue-type-bar", + title="Open Issues by Type per Datasource", + panel="data_quality_panel", + chart_type="colored_bar", + formatter_fn=format_issue_type_bar, + collect_fn=collect_open_issues_to_datasource, + ), + ChartDefinition( + id="dq-importer-contribution-donut", + title="Datasource Contribution per Open Issue", + panel="data_quality_panel", + chart_type="donut", + formatter_fn=format_issue_contribution_donut, + ), + ChartDefinition( + id="dq-resolution-timeline", + title="Issue Opened and Resolved Monthly", + panel="data_quality_panel", + chart_type="line", + formatter_fn=format_todos_resolution_timeline, + collect_fn=collect_data_quality_todos_resolutions, + ), ] PANELS = [ diff --git a/insights/insights_snapshot_pipeline.py b/insights/insights_snapshot_pipeline.py index 06084c062..5e4644a63 100644 --- a/insights/insights_snapshot_pipeline.py +++ b/insights/insights_snapshot_pipeline.py @@ -11,7 +11,11 @@ from insights.charts import CHARTS from insights.models import DailySnapshot +from insights.models import DataQualityIssueByDatasourceInsight +from insights.models import DataQualityToDosResolutionInsight from insights.models import ImporterInsight +from insights.models import OverviewCoverageInsight +from insights.models import OverviewYearlyInsight from insights.models import PackageCWEInsight from insights.models import PackageInsight from insights.models import PackageNameInsight @@ -42,6 +46,11 @@ def compute_chart_analytics(self): self.package_cwes = [] self.importer_insights = [] self.severity_insight = None + self.overview_insight = None + self.overview_yearly_insights = [] + self.overview_coverage_insights = [] + self.data_quality_issue_types = [] + self.data_quality_todos_resolutions = [] active_charts = [chart_def for chart_def in CHARTS if chart_def.collect_fn] # Count steps for progress bar @@ -78,8 +87,32 @@ def save_snapshot(self): self.severity_insight.snapshot_id = snapshot.id self.severity_insight.save() + if self.overview_insight: + self.overview_insight.snapshot_id = snapshot.id + self.overview_insight.save() + + for yearly in self.overview_yearly_insights: + yearly.overview_id = self.overview_insight.id + for coverage in self.overview_coverage_insights: + coverage.overview_id = self.overview_insight.id + + for insight in self.data_quality_issue_types: + insight.snapshot_id = snapshot.id + for insight in self.data_quality_todos_resolutions: + insight.snapshot_id = snapshot.id + PackageInsight.objects.bulk_create(self.package_insights.values(), batch_size=5000) PackageNameInsight.objects.bulk_create(self.package_names, batch_size=5000) PackageCWEInsight.objects.bulk_create(self.package_cwes, batch_size=5000) ImporterInsight.objects.bulk_create(self.importer_insights, batch_size=5000) + OverviewYearlyInsight.objects.bulk_create(self.overview_yearly_insights, batch_size=5000) + OverviewCoverageInsight.objects.bulk_create( + self.overview_coverage_insights, batch_size=5000 + ) + DataQualityIssueByDatasourceInsight.objects.bulk_create( + self.data_quality_issue_types, batch_size=5000 + ) + DataQualityToDosResolutionInsight.objects.bulk_create( + self.data_quality_todos_resolutions, batch_size=5000 + ) self.log("Snapshot saved successfully.") From 7cc580e7fde8403e98cb92fd83da077feea40201 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Wed, 12 Aug 2026 03:16:12 +0530 Subject: [PATCH 15/16] Add UI templates and Billboard.JS for Overview and Data Quality Panel Signed-off-by: Sampurna Pyne --- insights/static/insights/css/insights.css | 26 ++++- .../static/insights/js/data_quality_panel.js | 21 ++++ insights/static/insights/js/overview_panel.js | 41 +++++++ insights/static/insights/js/renderers.js | 104 +++++++++++++++++- .../data_quality_panel_content.html | 16 +++ .../components/overview_panel_content.html | 48 ++++++++ insights/templates/insights/dashboard.html | 8 +- 7 files changed, 257 insertions(+), 7 deletions(-) create mode 100644 insights/static/insights/js/data_quality_panel.js create mode 100644 insights/static/insights/js/overview_panel.js create mode 100644 insights/templates/insights/components/data_quality_panel_content.html create mode 100644 insights/templates/insights/components/overview_panel_content.html diff --git a/insights/static/insights/css/insights.css b/insights/static/insights/css/insights.css index 832861606..2f3676e17 100644 --- a/insights/static/insights/css/insights.css +++ b/insights/static/insights/css/insights.css @@ -197,8 +197,8 @@ .card-chart-title { border-bottom: 1px solid #e8e8e8; - padding-bottom: 0.75rem; - margin-bottom: 1.25rem; + padding-bottom: 0.5rem; + margin-bottom: 0.75rem; } /* Package Panel Specific Styles */ @@ -305,4 +305,26 @@ display: flex; align-items: center; margin-left: auto; +} + +/* Overview Panel Specific Styles */ +.kpi-card { + border-top: 4px solid var(--bulma-link); + height: 100%; + margin-bottom: 0 !important; +} + +.kpi-card:hover { + transform: translateY(-2px); + transition: transform 0.2s ease-in-out; +} + +.delta-positive { + color: var(--bulma-success); + font-weight: bold; +} + +.delta-negative { + color: var(--bulma-danger); + font-weight: bold; } \ No newline at end of file diff --git a/insights/static/insights/js/data_quality_panel.js b/insights/static/insights/js/data_quality_panel.js new file mode 100644 index 000000000..0ab1e2522 --- /dev/null +++ b/insights/static/insights/js/data_quality_panel.js @@ -0,0 +1,21 @@ +// +// Copyright (c) nexB Inc. and others. All rights reserved. +// VulnerableCode is a trademark of nexB Inc. +// SPDX-License-Identifier: Apache-2.0 +// See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +// See https://github.com/aboutcode-org/vulnerablecode for support or download. +// See https://aboutcode.org for more information about nexB OSS projects. +// + +import { initDropdownChart } from './core.js'; + +document.addEventListener('insightsDataLoaded', (e) => { + if (e.detail.panelId !== "data_quality_panel") return; + + const { snapshotData } = e.detail; + if (!snapshotData) return; + + initDropdownChart("dq-issue-type-bar", snapshotData["dq-issue-type-bar"], "All Datasources"); + initDropdownChart("dq-importer-contribution-donut", snapshotData["dq-importer-contribution-donut"], "All Issue Types"); + initDropdownChart("dq-resolution-timeline", snapshotData["dq-resolution-timeline"], "All Importers"); +}); diff --git a/insights/static/insights/js/overview_panel.js b/insights/static/insights/js/overview_panel.js new file mode 100644 index 000000000..9e17dc736 --- /dev/null +++ b/insights/static/insights/js/overview_panel.js @@ -0,0 +1,41 @@ +// +// Copyright (c) nexB Inc. and others. All rights reserved. +// VulnerableCode is a trademark of nexB Inc. +// SPDX-License-Identifier: Apache-2.0 +// See http://www.apache.org/licenses/LICENSE-2.0 for the license text. +// See https://github.com/aboutcode-org/vulnerablecode for support or download. +// See https://aboutcode.org for more information about nexB OSS projects. +// + +import { renderChartWithData } from './core.js'; + +function formatDelta(delta) { + if (delta > 0) return `+${delta.toLocaleString()} from last snapshot`; + if (delta < 0) return `${delta.toLocaleString()} from last snapshot`; + return "No change from last snapshot"; +} + +document.addEventListener('insightsDataLoaded', (e) => { + if (e.detail.panelId !== "overview_panel") return; + + const snapshotData = e.detail.snapshotData; + if (!snapshotData || !snapshotData["overview-stats"]) return; + + const overviewData = snapshotData["overview-stats"]; + + const kpis = overviewData.kpis; + if (kpis) { + document.getElementById("kpi-total-advisories").textContent = kpis.total_advisories.toLocaleString(); + document.getElementById("kpi-delta-advisories").innerHTML = formatDelta(kpis.delta_advisories); + + document.getElementById("kpi-total-packages").textContent = kpis.total_packages.toLocaleString(); + document.getElementById("kpi-delta-packages").innerHTML = formatDelta(kpis.delta_packages); + + document.getElementById("kpi-total-sources").textContent = kpis.total_data_sources.toLocaleString(); + document.getElementById("kpi-delta-sources").innerHTML = formatDelta(kpis.delta_data_sources); + } + + renderChartWithData("overview-historical", "global", snapshotData["overview-historical"]); + renderChartWithData("overview-cross-validation", "global", snapshotData["overview-cross-validation"]); + renderChartWithData("overview-growth-trend", "global", snapshotData["overview-growth-trend"]); +}); diff --git a/insights/static/insights/js/renderers.js b/insights/static/insights/js/renderers.js index bdd939be0..0c2471de8 100644 --- a/insights/static/insights/js/renderers.js +++ b/insights/static/insights/js/renderers.js @@ -22,7 +22,7 @@ const paletteVars = [ "--bulma-primary-dark", ]; -const getPalette = () => paletteVars.map(getCssVar).filter(Boolean); +export const getPalette = () => paletteVars.map(getCssVar).filter(Boolean); const getBucketColors = () => [ getCssVar("--bulma-success"), getCssVar("--bulma-success"), getCssVar("--bulma-success"), getCssVar("--bulma-success"), // 0-3: Success @@ -62,19 +62,115 @@ export const renderers = { colored_bar(id, config) { const monoColor = config.color || getCssVar("--bulma-link"); + const isRotated = config.rotated !== undefined ? config.rotated : true; bb.generate({ bindto: `#chart-${id}`, + padding: { right: 30 }, data: { x: "x", columns: config.columns, type: "bar", color: () => monoColor }, axis: { - rotated: true, - x: { type: "category", label: { text: config.x_label || "CWE", position: "outer-middle" } }, - y: { label: { text: config.y_label || "Advisories", position: "outer-center" }, tick: { format: formatWholeNumbersOnly } } + rotated: isRotated, + x: { + type: "category", + label: { text: config.x_label || "CWE", position: isRotated ? "outer-middle" : "outer-center" }, + tick: { + culling: isRotated ? false : { max: config.x_tick_culling !== undefined ? config.x_tick_culling : 8 }, + multiline: false + } + }, + y: { + label: { text: config.y_label || "Advisories", position: isRotated ? "outer-center" : "outer-middle" }, + tick: { format: formatWholeNumbersOnly } + } }, tooltip: { format: { title: x => config.full_labels?.[x] || config.columns[0][x + 1], value: val => val.toLocaleString() } }, legend: { show: false } }); }, + stacked_bar(id, config) { + bb.generate({ + bindto: `#chart-${id}`, + data: { + x: "x", + columns: config.columns, + type: "bar", + groups: config.groups, + colors: { + "VulnerableCode": getCssVar("--bulma-link"), + }, + }, + color: { pattern: getPalette() }, + axis: { + rotated: true, + x: { type: "category" }, + y: { + label: { text: config.y_label || "Advisories", position: "outer-center" }, + tick: { count: 6, format: formatWholeNumbersOnly }, + }, + }, + tooltip: { + contents(dataPoints, defaultTitle, defaultVal, color) { + const nonZeroPoints = dataPoints.filter((point) => point.value > 0); + if (!nonZeroPoints.length) return ""; + return this.internal.getTooltipContent(nonZeroPoints, defaultTitle, defaultVal, color); + }, + format: { value: (val) => val.toLocaleString() }, + }, + }); + }, + + + line(id, config) { + const isMultiSeries = config.columns.length > 2; + const color = config.color || getCssVar("--bulma-primary"); + + bb.generate({ + bindto: `#chart-${id}`, + data: { + x: "x", + columns: config.columns, + type: "line", + colors: { + "Open": getCssVar("--bulma-danger"), + "Resolved": getCssVar("--bulma-primary"), + "Total Advisories": color, + "Advisories Ingested": color, + }, + }, + axis: { + x: { + type: "timeseries", + tick: { + format: isMultiSeries ? "%Y-%m" : "%b %d", + fit: true, + count: 6, + }, + }, + y: { + min: 0, + padding: { bottom: 0 }, + label: { text: config.y_label || "Count", position: "outer-middle" }, + tick: { format: formatWholeNumbersOnly }, + }, + }, + point: { r: 3, focus: { expand: { r: 5 } } }, + legend: { show: isMultiSeries }, + tooltip: { + format: { + value(val, ratio, id, index) { + const added = + id === "Open" + ? config.new_open_counts?.[index] + : config.new_resolved_counts?.[index]; + return added !== undefined + ? `${val.toLocaleString()} (+${added.toLocaleString()} new)` + : val.toLocaleString(); + }, + }, + }, + }); + }, + scatter(id, config) { const [, ...buckets] = config.columns[0]; const [, ...counts] = config.columns[1]; diff --git a/insights/templates/insights/components/data_quality_panel_content.html b/insights/templates/insights/components/data_quality_panel_content.html new file mode 100644 index 000000000..0c1f56b32 --- /dev/null +++ b/insights/templates/insights/components/data_quality_panel_content.html @@ -0,0 +1,16 @@ +
+
+ {% with chart=panel.charts.0 %} + {% include 'insights/components/chart_card.html' %} + {% endwith %} + {% with chart=panel.charts.1 %} + {% include 'insights/components/chart_card.html' %} + {% endwith %} +
+ +
+ {% with chart=panel.charts.2 %} + {% include 'insights/components/chart_card.html' %} + {% endwith %} +
+
diff --git a/insights/templates/insights/components/overview_panel_content.html b/insights/templates/insights/components/overview_panel_content.html new file mode 100644 index 000000000..b2c3e7b88 --- /dev/null +++ b/insights/templates/insights/components/overview_panel_content.html @@ -0,0 +1,48 @@ +
+ +
+
+
+

Total Active Advisories

+

--

+

--

+
+
+
+
+

Total Packages

+

--

+

--

+
+
+
+
+

Data Sources

+

--

+

--

+
+
+
+ +
+
+
+
+
+

{{ panel.charts.3.title }}

+
+
+
+

{{ panel.charts.2.title }}

+
+
+
+
+
+
+ {% with chart=panel.charts.1 %} + {% include 'insights/components/chart_card.html' %} + {% endwith %} +
+
+
diff --git a/insights/templates/insights/dashboard.html b/insights/templates/insights/dashboard.html index bc8055219..5c6d5e29d 100644 --- a/insights/templates/insights/dashboard.html +++ b/insights/templates/insights/dashboard.html @@ -42,7 +42,11 @@
- {% if panel.layout == 'split_top' %} + {% if panel.id == 'overview_panel' %} + {% include 'insights/components/overview_panel_content.html' with panel=panel %} + {% elif panel.id == 'data_quality_panel' %} + {% include 'insights/components/data_quality_panel_content.html' with panel=panel %} + {% elif panel.layout == 'split_top' %} {% include 'insights/components/package_panel_donuts.html' with panel=panel %} {% for chart in panel.charts|slice:"2:" %} {% include 'insights/components/chart_card.html' with chart=chart current_panel_id=current_panel_id search_query=search_query search_error=search_error %} @@ -68,7 +72,9 @@ {{ search_results_dict|json_script:"chart-search-data" }} {% endif %} + + {% endblock %} \ No newline at end of file From e347d8dccd4d7490d6b3e57835561711ac326485 Mon Sep 17 00:00:00 2001 From: Sampurna Pyne Date: Wed, 12 Aug 2026 03:16:47 +0530 Subject: [PATCH 16/16] Add unit tests for Overview and Data Quality Panel Signed-off-by: Sampurna Pyne --- insights/tests/test_data_quality_panel.py | 92 +++++++++++++++++++ .../tests/test_insights_snapshot_pipeline.py | 23 +++++ insights/tests/test_overview_panel.py | 71 ++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 insights/tests/test_data_quality_panel.py create mode 100644 insights/tests/test_overview_panel.py diff --git a/insights/tests/test_data_quality_panel.py b/insights/tests/test_data_quality_panel.py new file mode 100644 index 000000000..9c8854403 --- /dev/null +++ b/insights/tests/test_data_quality_panel.py @@ -0,0 +1,92 @@ +import datetime + +from django.test import TestCase +from django.utils import timezone + +from insights.charts.data_quality_panel import data_quality_todos_resolutions_queryset +from insights.charts.data_quality_panel import open_issues_to_datasource_queryset +from vulnerabilities.models import AdvisoryToDoV2 +from vulnerabilities.models import AdvisoryV2 +from vulnerabilities.models import ToDoRelatedAdvisoryV2 + + +class TestDataQualityPanelQuerysets(TestCase): + def setUp(self): + self.adv1 = AdvisoryV2.objects.create( + avid="github_osv/GHSA-1", + datasource_id="github_osv", + unique_content_id="1", + is_latest=True, + pipeline_id="github_osv_pipeline", + advisory_id="GHSA-1", + url="https://example.com/GHSA-1", + ) + self.adv2 = AdvisoryV2.objects.create( + avid="nvd/CVE-2023-1234", + datasource_id="nvd", + unique_content_id="2", + is_latest=True, + pipeline_id="nvd_pipeline", + advisory_id="CVE-2023-1234", + url="https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + ) + + now = timezone.now() + + self.todo1 = AdvisoryToDoV2.objects.create( + alias="GHSA-1-TODO", + related_advisories_id="hash1", + issue_type="MISSING_AFFECTED_PACKAGE", + is_resolved=False, + created_at=now - datetime.timedelta(days=60), + ) + ToDoRelatedAdvisoryV2.objects.create(todo=self.todo1, advisory=self.adv1) + + self.todo2 = AdvisoryToDoV2.objects.create( + alias="CVE-2023-1234-TODO", + related_advisories_id="hash2", + issue_type="CONFLICTING_SEVERITY_SCORES", + is_resolved=False, + created_at=now - datetime.timedelta(days=45), + ) + ToDoRelatedAdvisoryV2.objects.create(todo=self.todo2, advisory=self.adv2) + + self.todo3 = AdvisoryToDoV2.objects.create( + alias="GHSA-1-TODO-2", + related_advisories_id="hash3", + issue_type="CONFLICTING_SEVERITY_SCORES", + is_resolved=True, + created_at=now - datetime.timedelta(days=60), + resolved_at=now - datetime.timedelta(days=15), + ) + ToDoRelatedAdvisoryV2.objects.create(todo=self.todo3, advisory=self.adv1) + + def test_open_issues_to_datasource_queryset(self): + """Test open issue query correctly aggregates unresolved to-dos by type and source.""" + qs = list(open_issues_to_datasource_queryset()) + + self.assertEqual(len(qs), 2) + + missing_pkg_todo = next(t for t in qs if t["issue_type"] == "MISSING_AFFECTED_PACKAGE") + self.assertEqual(missing_pkg_todo["advisories__datasource_id"], "github_osv") + self.assertEqual(missing_pkg_todo["count"], 1) + + conflicting_sev_todo = next( + t for t in qs if t["issue_type"] == "CONFLICTING_SEVERITY_SCORES" + ) + self.assertEqual(conflicting_sev_todo["advisories__datasource_id"], "nvd") + self.assertEqual(conflicting_sev_todo["count"], 1) + + def test_data_quality_todos_resolutions_queryset(self): + """Test timeline query separately aggregates opened and resolved to-dos by month.""" + open_todos_qs, resolved_todos_qs = data_quality_todos_resolutions_queryset() + + open_todos = list(open_todos_qs) + resolved_todos = list(resolved_todos_qs) + + self.assertEqual(sum(t["count"] for t in open_todos), 3) + self.assertEqual(sum(t["count"] for t in resolved_todos), 1) + + resolved = resolved_todos[0] + self.assertEqual(resolved["advisories__datasource_id"], "github_osv") + self.assertEqual(resolved["count"], 1) diff --git a/insights/tests/test_insights_snapshot_pipeline.py b/insights/tests/test_insights_snapshot_pipeline.py index be0ed2f43..0efda828d 100644 --- a/insights/tests/test_insights_snapshot_pipeline.py +++ b/insights/tests/test_insights_snapshot_pipeline.py @@ -1,10 +1,13 @@ from django.test import TestCase +from django.utils import timezone from insights.insights_snapshot_pipeline import InsightsSnapshotPipeline from insights.models import DailySnapshot from insights.tests.test_importer_panel import create_adv from vulnerabilities.models import AdvisorySeverity +from vulnerabilities.models import AdvisoryToDoV2 from vulnerabilities.models import PackageV2 +from vulnerabilities.models import ToDoRelatedAdvisoryV2 class TestInsightsSnapshotPipeline(TestCase): @@ -16,9 +19,20 @@ def test_pipeline_execution_with_data(self): # Create Advisory for Importer and Severity charts advisory_1 = create_adv("GHSA-1234", "1") + advisory_1.date_published = timezone.now() + advisory_1.save() severity_1 = AdvisorySeverity.objects.create(scoring_system="cvssv3.1", value="9.8") advisory_1.severities.add(severity_1) + todo = AdvisoryToDoV2.objects.create( + alias="GHSA-1234-TODO", + related_advisories_id="hash1", + issue_type="MISSING_AFFECTED_PACKAGE", + is_resolved=False, + created_at=timezone.now(), + ) + ToDoRelatedAdvisoryV2.objects.create(todo=todo, advisory=advisory_1) + pipeline = InsightsSnapshotPipeline() pipeline.execute() @@ -35,3 +49,12 @@ def test_pipeline_execution_with_data(self): # Verify SeverityInsight was generated self.assertTrue(hasattr(snapshot, "severity_insight")) self.assertEqual(snapshot.severity_insight.buckets[9], 1) + + self.assertTrue(hasattr(snapshot, "overview")) + self.assertEqual(snapshot.overview.total_advisories, 1) + self.assertEqual(len(snapshot.overview.last_30days), 31) + self.assertEqual(snapshot.overview.yearly_insights.count(), 1) + + self.assertEqual(snapshot.data_quality_issue_types.count(), 1) + self.assertEqual(snapshot.data_quality_issue_types.first().datasource_id, "github_osv") + self.assertEqual(snapshot.data_quality_todos_resolutions.count(), 1) diff --git a/insights/tests/test_overview_panel.py b/insights/tests/test_overview_panel.py new file mode 100644 index 000000000..b054a65d1 --- /dev/null +++ b/insights/tests/test_overview_panel.py @@ -0,0 +1,71 @@ +import datetime + +from django.test import TestCase +from django.utils import timezone + +from insights.charts.overview_panel import daily_growth_counts +from insights.charts.overview_panel import kpi_card_queryset +from insights.charts.overview_panel import yearly_distribution_queryset +from insights.tests.test_importer_panel import create_adv +from vulnerabilities.models import PackageV2 + + +class TestOverviewPanelQuerysets(TestCase): + def setUp(self): + PackageV2.objects.create(type="pypi", name="django", version="1.0.0") + PackageV2.objects.create(type="npm", name="lodash", version="1.0.0") + + self.now = timezone.now() + + adv1 = create_adv("GHSA-1234", "1") + adv1.date_published = self.now - datetime.timedelta(days=365 * 2) + adv1.date_collected = self.now - datetime.timedelta(days=5) + adv1.datasource_id = "github_osv" + adv1.save() + + adv2 = create_adv("GHSA-5678", "2") + adv2.date_published = self.now + adv2.date_collected = self.now + adv2.datasource_id = "github_osv" + adv2.save() + + adv3 = create_adv("CVE-2023-7777", "3") + adv3.date_published = self.now - datetime.timedelta(days=10) + adv3.date_collected = self.now + adv3.datasource_id = "nvd" + adv3.save() + + adv4 = create_adv("CVE-2024-9999", "4") + adv4.date_published = self.now + adv4.date_collected = self.now + adv4.datasource_id = "nvd" + adv4.save() + + adv5 = create_adv("CVE-2024-8888", "5") + adv5.date_published = self.now + adv5.date_collected = self.now + adv5.datasource_id = "nvd" + adv5.save() + + def test_kpi_card_queryset(self): + """Test KPI queries""" + stats = kpi_card_queryset() + self.assertEqual(stats["total_advisories"], 5) + self.assertEqual(stats["total_packages"], 2) + self.assertEqual(stats["total_data_sources"], 2) + + def test_yearly_distribution_queryset(self): + """Test yearly distribution query aggregates advisories by publish year.""" + yearly_records = list(yearly_distribution_queryset()) + self.assertEqual(len(yearly_records), 2) + + this_year = next(y for y in yearly_records if y["year"] == self.now.year) + two_years_ago = next(y for y in yearly_records if y["year"] == self.now.year - 2) + + self.assertEqual(this_year["count"], 4) + self.assertEqual(two_years_ago["count"], 1) + + def test_daily_growth_counts(self): + """Test daily ingestion query""" + counts = daily_growth_counts(self.now) + self.assertEqual(sum(counts), 5)