diff --git a/backend/apps/owasp/api/internal/nodes/chapter.py b/backend/apps/owasp/api/internal/nodes/chapter.py
index 23eb7a7fb6..770761d654 100644
--- a/backend/apps/owasp/api/internal/nodes/chapter.py
+++ b/backend/apps/owasp/api/internal/nodes/chapter.py
@@ -18,6 +18,8 @@ class GeoLocationType:
@strawberry_django.type(
Chapter,
fields=[
+ "contribution_data",
+ "contribution_stats",
"country",
"is_active",
"meetup_group",
diff --git a/backend/apps/owasp/api/internal/nodes/project.py b/backend/apps/owasp/api/internal/nodes/project.py
index 1b881d44ef..94912ba310 100644
--- a/backend/apps/owasp/api/internal/nodes/project.py
+++ b/backend/apps/owasp/api/internal/nodes/project.py
@@ -23,6 +23,8 @@
@strawberry_django.type(
Project,
fields=[
+ "contribution_data",
+ "contribution_stats",
"contributors_count",
"created_at",
"forks_count",
diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py
new file mode 100644
index 0000000000..ffa1d84061
--- /dev/null
+++ b/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py
@@ -0,0 +1,415 @@
+"""Management command to aggregate contributions for chapters and projects."""
+
+from datetime import datetime, timedelta
+
+from django.core.management.base import BaseCommand
+from django.utils import timezone
+
+from apps.github.models.commit import Commit
+from apps.github.models.issue import Issue
+from apps.github.models.pull_request import PullRequest
+from apps.github.models.release import Release
+from apps.owasp.models.chapter import Chapter
+from apps.owasp.models.project import Project
+
+
+class Command(BaseCommand):
+ """Aggregate contribution data for chapters and projects."""
+
+ help = "Aggregate contributions (commits, issues, PRs, releases) for chapters and projects"
+
+ def add_arguments(self, parser):
+ """Add command arguments."""
+ parser.add_argument(
+ "--entity-type",
+ type=str,
+ choices=["chapter", "project", "both"],
+ default="both",
+ help="Entity type to aggregate: chapter, project, or both",
+ )
+ parser.add_argument(
+ "--days",
+ type=int,
+ default=365,
+ help="Number of days to look back for contributions (default: 365)",
+ )
+ parser.add_argument(
+ "--key",
+ type=str,
+ help="Specific chapter or project key to aggregate",
+ )
+ parser.add_argument(
+ "--offset",
+ type=int,
+ default=0,
+ help="Skip the first N entities",
+ )
+
+ def _aggregate_contribution_dates(
+ self,
+ queryset,
+ date_field: str,
+ contribution_map: dict[str, int],
+ ) -> None:
+ """Aggregate contribution dates from a queryset into the contribution map.
+
+ Args:
+ queryset: Django queryset to aggregate
+ date_field: Name of the date field to aggregate on
+ contribution_map: Dictionary to update with counts
+
+ """
+ dates = queryset.values_list(date_field, flat=True)
+ for date_value in dates:
+ if date_value:
+ date_key = date_value.date().isoformat()
+ contribution_map[date_key] = contribution_map.get(date_key, 0) + 1
+
+ def aggregate_chapter_contributions(
+ self,
+ chapter: Chapter,
+ start_date: datetime,
+ ) -> dict[str, int]:
+ """Aggregate contributions for a chapter.
+
+ Args:
+ chapter: Chapter instance
+ start_date: Start date for aggregation
+
+ Returns:
+ Dictionary mapping YYYY-MM-DD to contribution count
+
+ """
+ contribution_map: dict[str, int] = {}
+
+ if not chapter.owasp_repository:
+ return contribution_map
+
+ repository = chapter.owasp_repository
+
+ # Aggregate commits
+ self._aggregate_contribution_dates(
+ Commit.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate issues
+ self._aggregate_contribution_dates(
+ Issue.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate pull requests
+ self._aggregate_contribution_dates(
+ PullRequest.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate releases (exclude drafts)
+ self._aggregate_contribution_dates(
+ Release.objects.filter(
+ repository=repository,
+ published_at__gte=start_date,
+ is_draft=False,
+ ),
+ "published_at",
+ contribution_map,
+ )
+
+ return contribution_map
+
+ def aggregate_project_contributions(
+ self,
+ project: Project,
+ start_date: datetime,
+ ) -> dict[str, int]:
+ """Aggregate contributions for a project across all its repositories.
+
+ Args:
+ project: Project instance
+ start_date: Start date for aggregation
+
+ Returns:
+ Dictionary mapping YYYY-MM-DD to contribution count
+
+ """
+ contribution_map: dict[str, int] = {}
+
+ repositories = list(project.repositories.all())
+ if project.owasp_repository:
+ repositories.append(project.owasp_repository)
+
+ repository_ids = [repo.id for repo in repositories if repo]
+
+ if not repository_ids:
+ return contribution_map
+
+ # Aggregate commits
+ self._aggregate_contribution_dates(
+ Commit.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate issues
+ self._aggregate_contribution_dates(
+ Issue.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate pull requests
+ self._aggregate_contribution_dates(
+ PullRequest.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ),
+ "created_at",
+ contribution_map,
+ )
+
+ # Aggregate releases (exclude drafts)
+ self._aggregate_contribution_dates(
+ Release.objects.filter(
+ repository_id__in=repository_ids,
+ published_at__gte=start_date,
+ is_draft=False,
+ ),
+ "published_at",
+ contribution_map,
+ )
+
+ return contribution_map
+
+ def calculate_chapter_contribution_stats(
+ self,
+ chapter: Chapter,
+ start_date: datetime,
+ ) -> dict[str, int]:
+ """Calculate detailed contribution statistics for a chapter.
+
+ Args:
+ chapter: Chapter instance
+ start_date: Start date for calculation
+
+ Returns:
+ Dictionary with commits, issues, pullRequests, releases counts
+
+ """
+ stats = {
+ "commits": 0,
+ "issues": 0,
+ "pullRequests": 0,
+ "releases": 0,
+ "total": 0,
+ }
+
+ if not chapter.owasp_repository:
+ return stats
+
+ repository = chapter.owasp_repository
+
+ # Count commits
+ stats["commits"] = Commit.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count issues
+ stats["issues"] = Issue.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count pull requests
+ stats["pullRequests"] = PullRequest.objects.filter(
+ repository=repository,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count releases (exclude drafts)
+ stats["releases"] = Release.objects.filter(
+ repository=repository,
+ published_at__gte=start_date,
+ is_draft=False,
+ ).count()
+
+ # Calculate total
+ stats["total"] = (
+ stats["commits"] + stats["issues"] + stats["pullRequests"] + stats["releases"]
+ )
+
+ return stats
+
+ def calculate_project_contribution_stats(
+ self,
+ project: Project,
+ start_date: datetime,
+ ) -> dict[str, int]:
+ """Calculate detailed contribution statistics for a project.
+
+ Args:
+ project: Project instance
+ start_date: Start date for calculation
+
+ Returns:
+ Dictionary with commits, issues, pullRequests, releases counts
+
+ """
+ stats = {
+ "commits": 0,
+ "issues": 0,
+ "pullRequests": 0,
+ "releases": 0,
+ "total": 0,
+ }
+
+ repositories = list(project.repositories.all())
+ if project.owasp_repository:
+ repositories.append(project.owasp_repository)
+
+ repository_ids = [repo.id for repo in repositories if repo]
+
+ if not repository_ids:
+ return stats
+
+ # Count commits
+ stats["commits"] = Commit.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count issues
+ stats["issues"] = Issue.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count pull requests
+ stats["pullRequests"] = PullRequest.objects.filter(
+ repository_id__in=repository_ids,
+ created_at__gte=start_date,
+ ).count()
+
+ # Count releases (exclude drafts)
+ stats["releases"] = Release.objects.filter(
+ repository_id__in=repository_ids,
+ published_at__gte=start_date,
+ is_draft=False,
+ ).count()
+
+ # Calculate total
+ stats["total"] = (
+ stats["commits"] + stats["issues"] + stats["pullRequests"] + stats["releases"]
+ )
+
+ return stats
+
+ def handle(self, *args, **options):
+ """Execute the command."""
+ entity_type = options["entity_type"]
+ days = options["days"]
+ key = options.get("key")
+ offset = options["offset"]
+
+ start_date = timezone.now() - timedelta(days=days)
+
+ self.stdout.write(
+ self.style.SUCCESS(
+ f"Aggregating contributions from {start_date.date()} ({days} days back)",
+ ),
+ )
+
+ # Process chapters
+ if entity_type in ["chapter", "both"]:
+ self._process_chapters(start_date, key, offset)
+
+ # Process projects
+ if entity_type in ["project", "both"]:
+ self._process_projects(start_date, key, offset)
+
+ self.stdout.write(self.style.SUCCESS("Done!"))
+
+ def _process_chapters(self, start_date, key, offset):
+ """Process chapters for contribution aggregation."""
+ chapter_queryset = Chapter.objects.filter(is_active=True).order_by("id")
+
+ if key:
+ chapter_queryset = chapter_queryset.filter(key=key)
+
+ if offset:
+ chapter_queryset = chapter_queryset[offset:]
+
+ chapter_queryset = chapter_queryset.select_related("owasp_repository")
+ chapters = list(chapter_queryset)
+ self.stdout.write(f"Processing {len(chapters)} chapters...")
+
+ for chapter in chapters:
+ contribution_data = self.aggregate_chapter_contributions(
+ chapter,
+ start_date,
+ )
+ contribution_stats = self.calculate_chapter_contribution_stats(
+ chapter,
+ start_date,
+ )
+ chapter.contribution_data = contribution_data
+ chapter.contribution_stats = contribution_stats
+
+ if chapters:
+ Chapter.bulk_save(chapters, fields=("contribution_data", "contribution_stats"))
+ self.stdout.write(
+ self.style.SUCCESS(f"✓ Updated {len(chapters)} chapters"),
+ )
+
+ def _process_projects(self, start_date, key, offset):
+ """Process projects for contribution aggregation."""
+ project_queryset = Project.objects.filter(is_active=True).order_by("id")
+
+ if key:
+ project_queryset = project_queryset.filter(key=key)
+
+ if offset:
+ project_queryset = project_queryset[offset:]
+
+ project_queryset = project_queryset.select_related("owasp_repository").prefetch_related(
+ "repositories"
+ )
+ projects = list(project_queryset)
+ self.stdout.write(f"Processing {len(projects)} projects...")
+
+ for project in projects:
+ contribution_data = self.aggregate_project_contributions(
+ project,
+ start_date,
+ )
+ contribution_stats = self.calculate_project_contribution_stats(
+ project,
+ start_date,
+ )
+ project.contribution_data = contribution_data
+ project.contribution_stats = contribution_stats
+
+ if projects:
+ Project.bulk_save(projects, fields=("contribution_data", "contribution_stats"))
+ self.stdout.write(
+ self.style.SUCCESS(f"✓ Updated {len(projects)} projects"),
+ )
diff --git a/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py
new file mode 100644
index 0000000000..c17d81a438
--- /dev/null
+++ b/backend/apps/owasp/migrations/0066_chapter_contribution_data_project_contribution_data.py
@@ -0,0 +1,32 @@
+# Generated by Django 5.2.8 on 2025-11-16 18:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0065_memberprofile_linkedin_page_id"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="chapter",
+ name="contribution_data",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)",
+ verbose_name="Contribution Data",
+ ),
+ ),
+ migrations.AddField(
+ model_name="project",
+ name="contribution_data",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)",
+ verbose_name="Contribution Data",
+ ),
+ ),
+ ]
diff --git a/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py
new file mode 100644
index 0000000000..191ef43974
--- /dev/null
+++ b/backend/apps/owasp/migrations/0067_chapter_contribution_stats_and_more.py
@@ -0,0 +1,33 @@
+# Generated by Django 5.2.8 on 2025-11-23 13:51
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0066_chapter_contribution_data_project_contribution_data"),
+ ]
+
+
+operations = [
+ migrations.AddField(
+ model_name="chapter",
+ name="contribution_stats",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)",
+ verbose_name="Contribution Statistics",
+ ),
+ ),
+ migrations.AddField(
+ model_name="project",
+ name="contribution_stats",
+ field=models.JSONField(
+ blank=True,
+ default=dict,
+ help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)",
+ verbose_name="Contribution Statistics",
+ ),
+ ),
+]
diff --git a/backend/apps/owasp/models/chapter.py b/backend/apps/owasp/models/chapter.py
index b777718498..1d7b1e6a91 100644
--- a/backend/apps/owasp/models/chapter.py
+++ b/backend/apps/owasp/models/chapter.py
@@ -64,6 +64,19 @@ class Meta:
latitude = models.FloatField(verbose_name="Latitude", blank=True, null=True)
longitude = models.FloatField(verbose_name="Longitude", blank=True, null=True)
+ contribution_data = models.JSONField(
+ verbose_name="Contribution Data",
+ default=dict,
+ blank=True,
+ help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)",
+ )
+ contribution_stats = models.JSONField(
+ verbose_name="Contribution Statistics",
+ default=dict,
+ blank=True,
+ help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)",
+ )
+
# GRs.
members = GenericRelation("owasp.EntityMember")
diff --git a/backend/apps/owasp/models/project.py b/backend/apps/owasp/models/project.py
index b377900bb7..1c40f49b9d 100644
--- a/backend/apps/owasp/models/project.py
+++ b/backend/apps/owasp/models/project.py
@@ -97,6 +97,19 @@ class Meta:
custom_tags = models.JSONField(verbose_name="Custom tags", default=list, blank=True)
track_issues = models.BooleanField(verbose_name="Track issues", default=True)
+ contribution_data = models.JSONField(
+ verbose_name="Contribution Data",
+ default=dict,
+ blank=True,
+ help_text="Daily contribution counts (YYYY-MM-DD -> count mapping)",
+ )
+ contribution_stats = models.JSONField(
+ verbose_name="Contribution Statistics",
+ default=dict,
+ blank=True,
+ help_text="Detailed contribution breakdown (commits, issues, pullRequests, releases)",
+ )
+
# GKs.
members = GenericRelation("owasp.EntityMember")
diff --git a/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py
new file mode 100644
index 0000000000..5a376ce667
--- /dev/null
+++ b/backend/tests/apps/owasp/api/internal/nodes/chapter_test.py
@@ -0,0 +1,58 @@
+"""Test cases for ChapterNode."""
+
+from apps.owasp.api.internal.nodes.chapter import ChapterNode
+
+
+class TestChapterNode:
+ def test_chapter_node_inheritance(self):
+ assert hasattr(ChapterNode, "__strawberry_definition__")
+
+ def test_meta_configuration(self):
+ field_names = {field.name for field in ChapterNode.__strawberry_definition__.fields}
+ expected_field_names = {
+ "contribution_data",
+ "country",
+ "created_at",
+ "is_active",
+ "name",
+ "region",
+ "summary",
+ "key",
+ "geo_location",
+ "suggested_location",
+ "meetup_group",
+ "postal_code",
+ "tags",
+ }
+ assert expected_field_names.issubset(field_names)
+
+ def _get_field_by_name(self, name):
+ return next(
+ (f for f in ChapterNode.__strawberry_definition__.fields if f.name == name), None
+ )
+
+ def test_resolve_key(self):
+ field = self._get_field_by_name("key")
+ assert field is not None
+ assert field.type is str
+
+ def test_resolve_country(self):
+ field = self._get_field_by_name("country")
+ assert field is not None
+ assert field.type is str
+
+ def test_resolve_region(self):
+ field = self._get_field_by_name("region")
+ assert field is not None
+ assert field.type is str
+
+ def test_resolve_is_active(self):
+ field = self._get_field_by_name("is_active")
+ assert field is not None
+ assert field.type is bool
+
+ def test_resolve_contribution_data(self):
+ field = self._get_field_by_name("contribution_data")
+ assert field is not None
+ # JSONField is represented as a Strawberry ScalarWrapper for JSON type
+ assert field.type.__class__.__name__ == "ScalarWrapper"
diff --git a/backend/tests/apps/owasp/api/internal/nodes/project_test.py b/backend/tests/apps/owasp/api/internal/nodes/project_test.py
index 03cff7115b..c3911f10ce 100644
--- a/backend/tests/apps/owasp/api/internal/nodes/project_test.py
+++ b/backend/tests/apps/owasp/api/internal/nodes/project_test.py
@@ -16,6 +16,7 @@ def test_project_node_inheritance(self):
def test_meta_configuration(self):
field_names = {field.name for field in ProjectNode.__strawberry_definition__.fields}
expected_field_names = {
+ "contribution_data",
"contributors_count",
"created_at",
"forks_count",
diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py
new file mode 100644
index 0000000000..186f66e6f7
--- /dev/null
+++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py
@@ -0,0 +1,442 @@
+"""Test cases for owasp_aggregate_contributions management command."""
+
+from datetime import UTC, datetime, timedelta
+from unittest import mock
+
+import pytest
+
+from apps.owasp.management.commands.owasp_aggregate_contributions import Command
+from apps.owasp.models import Chapter, Project
+
+
+class MockQuerySet:
+ """Mock QuerySet that supports slicing and iteration without database access."""
+
+ def __init__(self, items):
+ self._items = items
+
+ def __iter__(self):
+ """Return iterator over items."""
+ return iter(self._items)
+
+ def __getitem__(self, key):
+ """Get item by key or slice."""
+ if isinstance(key, slice):
+ return MockQuerySet(self._items[key])
+ return self._items[key]
+
+ def filter(self, **kwargs):
+ # Return self to support filter chaining
+ return self
+
+ def order_by(self, *_fields):
+ """Mock order_by method."""
+ return self
+
+ def select_related(self, *_):
+ """Mock select_related method."""
+ return self
+
+ def prefetch_related(self, *_):
+ """Mock prefetch_related method."""
+ return self
+
+ def __len__(self):
+ """Return length of items."""
+ return len(self._items)
+
+
+class TestOwaspAggregateContributions:
+ @pytest.fixture
+ def command(self):
+ return Command()
+
+ @pytest.fixture
+ def mock_chapter(self):
+ chapter = mock.Mock(spec=Chapter)
+ chapter.key = "www-chapter-test"
+ chapter.name = "Test Chapter"
+ chapter.owasp_repository = mock.Mock()
+ chapter.owasp_repository.id = 1
+ # Fix Django ORM compatibility
+ chapter.owasp_repository.resolve_expression = mock.Mock(
+ return_value=chapter.owasp_repository
+ )
+ chapter.owasp_repository.get_source_expressions = mock.Mock(return_value=[])
+ return chapter
+
+ @pytest.fixture
+ def mock_project(self):
+ project = mock.Mock(spec=Project)
+ project.key = "www-project-test"
+ project.name = "Test Project"
+ project.owasp_repository = mock.Mock()
+ project.owasp_repository.id = 1
+ # Fix Django ORM compatibility
+ project.owasp_repository.resolve_expression = mock.Mock(
+ return_value=project.owasp_repository
+ )
+ project.owasp_repository.get_source_expressions = mock.Mock(return_value=[])
+
+ # Mock additional repositories
+ additional_repo1 = mock.Mock(id=2)
+ additional_repo1.resolve_expression = mock.Mock(return_value=additional_repo1)
+ additional_repo1.get_source_expressions = mock.Mock(return_value=[])
+
+ additional_repo2 = mock.Mock(id=3)
+ additional_repo2.resolve_expression = mock.Mock(return_value=additional_repo2)
+ additional_repo2.get_source_expressions = mock.Mock(return_value=[])
+
+ project.repositories.all.return_value = [additional_repo1, additional_repo2]
+ return project
+
+ def test_aggregate_contribution_dates_helper(self, command):
+ """Test the helper method that aggregates dates."""
+ contribution_map = {}
+
+ # Create mock queryset with dates
+ mock_dates = [
+ datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC),
+ datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC), # Same day
+ datetime(2024, 11, 17, 9, 0, 0, tzinfo=UTC),
+ None, # Should be skipped
+ ]
+
+ mock_queryset = mock.Mock()
+ mock_queryset.values_list.return_value = mock_dates
+
+ command._aggregate_contribution_dates(
+ mock_queryset,
+ "created_at",
+ contribution_map,
+ )
+
+ assert contribution_map == {
+ "2024-11-16": 2,
+ "2024-11-17": 1,
+ }
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_aggregate_chapter_contributions(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ command,
+ mock_chapter,
+ ):
+ """Test aggregating contributions for a chapter."""
+ start_date = datetime.now(tz=UTC) - timedelta(days=365)
+
+ # Mock querysets
+ mock_commit.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC),
+ ]
+ mock_issue.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 16, 11, 0, 0, tzinfo=UTC),
+ ]
+ mock_pr.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 17, 10, 0, 0, tzinfo=UTC),
+ ]
+ mock_release.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 17, 12, 0, 0, tzinfo=UTC),
+ ]
+
+ result = command.aggregate_chapter_contributions(mock_chapter, start_date)
+
+ assert result == {
+ "2024-11-16": 2, # 1 commit + 1 issue
+ "2024-11-17": 2, # 1 PR + 1 release
+ }
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_aggregate_project_contributions(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ command,
+ mock_project,
+ ):
+ """Test aggregating contributions for a project."""
+ start_date = datetime.now(tz=UTC) - timedelta(days=365)
+
+ # Mock querysets
+ mock_commit.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 16, 10, 0, 0, tzinfo=UTC),
+ datetime(2024, 11, 16, 14, 0, 0, tzinfo=UTC),
+ ]
+ mock_issue.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 17, 11, 0, 0, tzinfo=UTC),
+ ]
+ mock_pr.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 18, 10, 0, 0, tzinfo=UTC),
+ ]
+ mock_release.objects.filter.return_value.values_list.return_value = [
+ datetime(2024, 11, 18, 12, 0, 0, tzinfo=UTC),
+ ]
+
+ result = command.aggregate_project_contributions(mock_project, start_date)
+
+ assert result == {
+ "2024-11-16": 2, # 2 commits
+ "2024-11-17": 1, # 1 issue
+ "2024-11-18": 2, # 1 PR + 1 release
+ }
+
+ def test_aggregate_chapter_without_repository(self, command, mock_chapter):
+ """Test that chapters without repositories return empty map."""
+ mock_chapter.owasp_repository = None
+ start_date = datetime.now(tz=UTC) - timedelta(days=365)
+
+ result = command.aggregate_chapter_contributions(mock_chapter, start_date)
+
+ assert result == {}
+
+ def test_aggregate_project_without_repositories(self, command, mock_project):
+ """Test that projects without repositories return empty map."""
+ mock_project.owasp_repository = None
+ mock_project.repositories.all.return_value = []
+ start_date = datetime.now(tz=UTC) - timedelta(days=365)
+
+ result = command.aggregate_project_contributions(mock_project, start_date)
+
+ assert result == {}
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_chapters_only(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_chapter_model,
+ command,
+ mock_chapter,
+ ):
+ """Test command execution for chapters only."""
+ mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter])
+ mock_chapter_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 5
+ mock_issue.objects.filter.return_value.count.return_value = 3
+ mock_pr.objects.filter.return_value.count.return_value = 2
+ mock_release.objects.filter.return_value.count.return_value = 1
+
+ with mock.patch.object(
+ command,
+ "aggregate_chapter_contributions",
+ return_value={"2024-11-16": 5},
+ ):
+ command.handle(entity_type="chapter", days=365, offset=0)
+
+ assert mock_chapter.contribution_data == {"2024-11-16": 5}
+ assert mock_chapter_model.bulk_save.called
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_projects_only(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_project_model,
+ command,
+ mock_project,
+ ):
+ """Test command execution for projects only."""
+ mock_project_model.objects.filter.return_value = MockQuerySet([mock_project])
+ mock_project_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 8
+ mock_issue.objects.filter.return_value.count.return_value = 4
+ mock_pr.objects.filter.return_value.count.return_value = 3
+ mock_release.objects.filter.return_value.count.return_value = 2
+
+ with mock.patch.object(
+ command,
+ "aggregate_project_contributions",
+ return_value={"2024-11-16": 10},
+ ):
+ command.handle(entity_type="project", days=365, offset=0)
+
+ assert mock_project.contribution_data == {"2024-11-16": 10}
+ assert mock_project_model.bulk_save.called
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Project")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_both_entities(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_project_model,
+ mock_chapter_model,
+ command,
+ mock_chapter,
+ mock_project,
+ ):
+ """Test command execution for both chapters and projects."""
+ mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter])
+ mock_project_model.objects.filter.return_value = MockQuerySet([mock_project])
+ mock_chapter_model.bulk_save = mock.Mock()
+ mock_project_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 5
+ mock_issue.objects.filter.return_value.count.return_value = 3
+ mock_pr.objects.filter.return_value.count.return_value = 2
+ mock_release.objects.filter.return_value.count.return_value = 1
+
+ with (
+ mock.patch.object(
+ command,
+ "aggregate_chapter_contributions",
+ return_value={"2024-11-16": 5},
+ ),
+ mock.patch.object(
+ command,
+ "aggregate_project_contributions",
+ return_value={"2024-11-16": 10},
+ ),
+ ):
+ command.handle(entity_type="both", days=365, offset=0)
+
+ assert mock_chapter_model.bulk_save.called
+ assert mock_project_model.bulk_save.called
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_with_specific_key(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_chapter_model,
+ command,
+ mock_chapter,
+ ):
+ """Test command execution with a specific entity key."""
+ mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter])
+ mock_chapter_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 3
+ mock_issue.objects.filter.return_value.count.return_value = 2
+ mock_pr.objects.filter.return_value.count.return_value = 1
+ mock_release.objects.filter.return_value.count.return_value = 1
+
+ with mock.patch.object(
+ command,
+ "aggregate_chapter_contributions",
+ return_value={"2024-11-16": 3},
+ ):
+ command.handle(entity_type="chapter", key="www-chapter-test", days=365, offset=0)
+
+ # Verify filter was called with the specific key
+ mock_chapter_model.objects.filter.assert_called()
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_with_offset(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_chapter_model,
+ command,
+ mock_chapter,
+ ):
+ """Test command execution with offset parameter."""
+ chapters = [mock_chapter, mock_chapter, mock_chapter]
+ mock_chapter_model.objects.filter.return_value = MockQuerySet(chapters)
+ mock_chapter_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 1
+ mock_issue.objects.filter.return_value.count.return_value = 1
+ mock_pr.objects.filter.return_value.count.return_value = 1
+ mock_release.objects.filter.return_value.count.return_value = 0
+
+ with mock.patch.object(
+ command,
+ "aggregate_chapter_contributions",
+ return_value={"2024-11-16": 1},
+ ) as mock_aggregate:
+ command.handle(entity_type="chapter", offset=2, days=365)
+
+ # Verify that offset was applied - only 1 chapter should be processed (3 total - 2 offset)
+ mock_aggregate.assert_called_once()
+ mock_chapter_model.bulk_save.assert_called_once()
+
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Chapter")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Commit")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Issue")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.PullRequest")
+ @mock.patch("apps.owasp.management.commands.owasp_aggregate_contributions.Release")
+ def test_handle_custom_days(
+ self,
+ mock_release,
+ mock_pr,
+ mock_issue,
+ mock_commit,
+ mock_chapter_model,
+ command,
+ mock_chapter,
+ ):
+ """Test command execution with custom days parameter."""
+ mock_chapter_model.objects.filter.return_value = MockQuerySet([mock_chapter])
+ mock_chapter_model.bulk_save = mock.Mock()
+
+ # Mock ORM queries to return counts
+ mock_commit.objects.filter.return_value.count.return_value = 0
+ mock_issue.objects.filter.return_value.count.return_value = 0
+ mock_pr.objects.filter.return_value.count.return_value = 0
+ mock_release.objects.filter.return_value.count.return_value = 0
+
+ with mock.patch.object(
+ command,
+ "aggregate_chapter_contributions",
+ return_value={},
+ ) as mock_aggregate:
+ command.handle(entity_type="chapter", days=90, offset=0)
+
+ # Verify aggregate was called with correct start_date
+ assert mock_aggregate.called
+ call_args = mock_aggregate.call_args[0]
+ start_date = call_args[1]
+ expected_start = datetime.now(tz=UTC) - timedelta(days=90)
+
+ # Allow 1 second tolerance for test execution time
+ assert abs((expected_start - start_date).total_seconds()) < 1
diff --git a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
index ab62cbc02e..b863a75446 100644
--- a/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
+++ b/frontend/__tests__/unit/components/CardDetailsPage.test.tsx
@@ -891,7 +891,8 @@ describe('CardDetailsPage', () => {
expect(mainContainer).toHaveClass(
'min-h-screen',
'bg-white',
- 'p-8',
+ 'px-4',
+ 'py-6',
'text-gray-600',
'dark:bg-[#212529]',
'dark:text-gray-300'
@@ -902,7 +903,7 @@ describe('CardDetailsPage', () => {
render(
Commits
++ {formatNumber(stats?.commits)} +
+PRs
++ {formatNumber(stats?.pullRequests)} +
+Issues
++ {formatNumber(stats?.issues)} +
+Total
++ {formatNumber(stats?.total)} +
+
Support {title} to help grow global cybersecurity community.
diff --git a/frontend/src/server/queries/chapterQueries.ts b/frontend/src/server/queries/chapterQueries.ts
index 0035194da8..50d8220b4a 100644
--- a/frontend/src/server/queries/chapterQueries.ts
+++ b/frontend/src/server/queries/chapterQueries.ts
@@ -28,6 +28,8 @@ export const GET_CHAPTER_DATA = gql`
summary
updatedAt
url
+ contributionData
+ contributionStats
}
topContributors(chapter: $key) {
id
diff --git a/frontend/src/server/queries/projectQueries.ts b/frontend/src/server/queries/projectQueries.ts
index d2deee4625..1d5f67dc7b 100644
--- a/frontend/src/server/queries/projectQueries.ts
+++ b/frontend/src/server/queries/projectQueries.ts
@@ -89,6 +89,8 @@ export const GET_PROJECT_DATA = gql`
type
updatedAt
url
+ contributionData
+ contributionStats
recentMilestones(limit: 5) {
author {
id
diff --git a/frontend/src/types/__generated__/chapterQueries.generated.ts b/frontend/src/types/__generated__/chapterQueries.generated.ts
index e24187a4e3..d25d20b081 100644
--- a/frontend/src/types/__generated__/chapterQueries.generated.ts
+++ b/frontend/src/types/__generated__/chapterQueries.generated.ts
@@ -6,7 +6,7 @@ export type GetChapterDataQueryVariables = Types.Exact<{
}>;
-export type GetChapterDataQuery = { chapter: { __typename: 'ChapterNode', id: string, isActive: boolean, key: string, name: string, region: string, relatedUrls: Array