diff --git a/backend/Makefile b/backend/Makefile index e946e35b6c..1411db9f60 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -210,7 +210,8 @@ update-data: \ github-update-related-organizations \ github-update-users \ owasp-aggregate-projects \ - owasp-aggregate-contributions \ + owasp-aggregate-entity-contributions \ + owasp-aggregate-member-contributions \ owasp-update-events \ owasp-sync-posts \ owasp-update-sponsors \ diff --git a/backend/apps/github/api/internal/nodes/user.py b/backend/apps/github/api/internal/nodes/user.py index e7f635cbfc..8451dd7d60 100644 --- a/backend/apps/github/api/internal/nodes/user.py +++ b/backend/apps/github/api/internal/nodes/user.py @@ -12,6 +12,7 @@ "avatar_url", "bio", "company", + "contribution_data", "contributions_count", "email", "followers_count", diff --git a/backend/apps/github/migrations/0041_user_contribution_data.py b/backend/apps/github/migrations/0041_user_contribution_data.py new file mode 100644 index 0000000000..7ebd1e59b9 --- /dev/null +++ b/backend/apps/github/migrations/0041_user_contribution_data.py @@ -0,0 +1,22 @@ +# Generated by Django 6.0.1 on 2026-01-12 19:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0040_merge_20251117_0136"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Aggregated contribution data as date -> count mapping", + verbose_name="Contribution heatmap data", + ), + ), + ] diff --git a/backend/apps/github/migrations/0042_merge_20260127_2218.py b/backend/apps/github/migrations/0042_merge_20260127_2218.py new file mode 100644 index 0000000000..7585564fd7 --- /dev/null +++ b/backend/apps/github/migrations/0042_merge_20260127_2218.py @@ -0,0 +1,12 @@ +# Generated by Django 6.0.1 on 2026-01-27 22:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0041_milestone_github_milestone_created_at_and_more"), + ("github", "0041_user_contribution_data"), + ] + + operations = [] diff --git a/backend/apps/github/migrations/0043_alter_user_contribution_data.py b/backend/apps/github/migrations/0043_alter_user_contribution_data.py new file mode 100644 index 0000000000..2eed539ff3 --- /dev/null +++ b/backend/apps/github/migrations/0043_alter_user_contribution_data.py @@ -0,0 +1,23 @@ +# Generated by Django 6.0.1 on 2026-01-28 01:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0042_merge_20260127_2218"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="contribution_data", + field=models.JSONField( + blank=True, + default=dict, + help_text="Aggregated contribution data as date -> count mapping", + null=True, + verbose_name="Contribution heatmap data", + ), + ), + ] diff --git a/backend/apps/github/models/user.py b/backend/apps/github/models/user.py index 05a3980db5..72d07acce2 100644 --- a/backend/apps/github/models/user.py +++ b/backend/apps/github/models/user.py @@ -54,6 +54,14 @@ class Meta: verbose_name="Contributions count", default=0 ) + contribution_data = models.JSONField( + verbose_name="Contribution heatmap data", + default=dict, + blank=True, + null=True, + help_text="Aggregated contribution data as date -> count mapping", + ) + def __str__(self) -> str: """Return a human-readable representation of the user. diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 75bdba3b70..c74b6e8362 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -2,10 +2,14 @@ owasp-aggregate-projects: @echo "Aggregating OWASP projects" @CMD="python manage.py owasp_aggregate_projects" $(MAKE) exec-backend-command -owasp-aggregate-contributions: +owasp-aggregate-entity-contributions: @echo "Aggregating OWASP contributions" - @CMD="python manage.py owasp_aggregate_contributions --entity-type chapter" $(MAKE) exec-backend-command - @CMD="python manage.py owasp_aggregate_contributions --entity-type project" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_aggregate_entity_contributions --entity-type chapter" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_aggregate_entity_contributions --entity-type project" $(MAKE) exec-backend-command + +owasp-aggregate-member-contributions: + @echo "Aggregating OWASP community member contributions" + @CMD="python manage.py owasp_aggregate_member_contributions" $(MAKE) exec-backend-command owasp-create-project-metadata-file: @echo "Generating metadata" diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_entity_contributions.py similarity index 100% rename from backend/apps/owasp/management/commands/owasp_aggregate_contributions.py rename to backend/apps/owasp/management/commands/owasp_aggregate_entity_contributions.py diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py new file mode 100644 index 0000000000..2d77aa0e97 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py @@ -0,0 +1,140 @@ +"""Management command to aggregate user contribution data.""" + +from datetime import datetime, timedelta +from typing import Any + +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.db.models.functions import TruncDate +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.user import User + + +class Command(BaseCommand): + """Aggregate contribution data for users.""" + + help = "Aggregate contribution data (commits, PRs, issues) for users" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--user", + type=str, + help="Specific user login to process", + ) + parser.add_argument( + "--days", + type=int, + default=365, + help="Number of days to look back (default: 365)", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Batch size for processing users (default: 100)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + """Handle the command execution.""" + user_login = options.get("user") + days = options.get("days", 365) + batch_size = options.get("batch_size", 100) + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions since {start_date.date()} ({days} days back)" + ) + ) + + if user_login: + users = User.objects.filter(login=user_login) + if not users.exists(): + self.stdout.write(self.style.ERROR(f"Member '{user_login}' not found")) + return + else: + users = User.objects.filter(contributions_count__gt=0) + + total_users = users.count() + self.stdout.write(f"Processing {total_users} members...") + + updated_users = [] + for user in users.iterator(chunk_size=batch_size): + user.contribution_data = self._aggregate_user_contributions(user, start_date) + updated_users.append(user) + + User.bulk_save(updated_users, fields=["contribution_data"]) + + self.stdout.write( + self.style.SUCCESS(f"Successfully aggregated contributions for {total_users} members") + ) + + def _aggregate_user_contributions(self, user: User, start_date: datetime) -> dict[str, int]: + """Aggregate contributions for a user. + + Args: + user: User instance + start_date: Start datetime for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution counts + + """ + contribution_data = {} + current_date = start_date.date() + end_date = timezone.now().date() + + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + contribution_data[date_str] = 0 + current_date += timedelta(days=1) + + commits = ( + Commit.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for commit in commits: + date_str = commit["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + commit["count"] + + prs = ( + PullRequest.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for pr in prs: + date_str = pr["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + pr["count"] + + issues = ( + Issue.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for issue in issues: + date_str = issue["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + issue["count"] + + return contribution_data diff --git a/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak new file mode 100644 index 0000000000..48686fe8f8 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_aggregate_member_contributions.py.bak @@ -0,0 +1,142 @@ +"""Management command to aggregate user contribution data.""" + +from datetime import datetime, timedelta +from typing import Any + +from django.core.management.base import BaseCommand +from django.db.models import Count +from django.db.models.functions import TruncDate +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.user import User + + +class Command(BaseCommand): + """Aggregate contribution data for users.""" + + help = "Aggregate contribution data (commits, PRs, issues) for users" + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--user", + type=str, + help="Specific user login to process", + ) + parser.add_argument( + "--days", + type=int, + default=365, + help="Number of days to look back (default: 365)", + ) + parser.add_argument( + "--batch-size", + type=int, + default=100, + help="Batch size for processing users (default: 100)", + ) + + def handle(self, *args: Any, **options: Any) -> None: + """Handle the command execution.""" + user_login = options.get("user") + days = options.get("days", 365) + batch_size = options.get("batch_size", 100) + + start_date = timezone.now() - timedelta(days=days) + + self.stdout.write( + self.style.SUCCESS( + f"Aggregating contributions since {start_date.date()} ({days} days back)" + ) + ) + + if user_login: + users = User.objects.filter(login=user_login) + if not users.exists(): + self.stdout.write(self.style.ERROR(f"Member '{user_login}' not found")) + return + else: + users = User.objects.filter(contributions_count__gt=0) + + total_users = users.count() + self.stdout.write(f"Processing {total_users} members...") + + updated_users = [] + for user in users.iterator(chunk_size=batch_size): + if not (contribution_data := self._aggregate_user_contributions(user, start_date)): + continue + user.contribution_data = contribution_data + updated_users.append(user) + + User.bulk_save(updated_users, fields=["contribution_data"]) + + self.stdout.write( + self.style.SUCCESS(f"Successfully aggregated contributions for {total_users} members") + ) + + def _aggregate_user_contributions(self, user: User, start_date: datetime) -> dict[str, int]: + """Aggregate contributions for a user. + + Args: + user: User instance + start_date: Start datetime for aggregation + + Returns: + Dictionary mapping YYYY-MM-DD to contribution counts + + """ + contribution_data = {} + current_date = start_date.date() + end_date = timezone.now().date() + + while current_date <= end_date: + date_str = current_date.strftime("%Y-%m-%d") + contribution_data[date_str] = 0 + current_date += timedelta(days=1) + + commits = ( + Commit.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for commit in commits: + date_str = commit["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + commit["count"] + + prs = ( + PullRequest.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for pr in prs: + date_str = pr["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + pr["count"] + + issues = ( + Issue.objects.filter( + author=user, + created_at__gte=start_date, + ) + .annotate(date=TruncDate("created_at")) + .values("date") + .annotate(count=Count("id")) + ) + + for issue in issues: + date_str = issue["date"].strftime("%Y-%m-%d") + contribution_data[date_str] = contribution_data.get(date_str, 0) + issue["count"] + + return contribution_data diff --git a/backend/tests/apps/github/api/internal/nodes/user_test.py b/backend/tests/apps/github/api/internal/nodes/user_test.py index b9356ec5a1..d88120382d 100644 --- a/backend/tests/apps/github/api/internal/nodes/user_test.py +++ b/backend/tests/apps/github/api/internal/nodes/user_test.py @@ -25,6 +25,7 @@ def test_meta_configuration(self): "badges", "bio", "company", + "contribution_data", "contributions_count", "created_at", "email", diff --git a/backend/tests/apps/github/models/user_test.py b/backend/tests/apps/github/models/user_test.py index 609d5174ac..1e7022112f 100644 --- a/backend/tests/apps/github/models/user_test.py +++ b/backend/tests/apps/github/models/user_test.py @@ -215,3 +215,34 @@ def test_get_non_indexable_logins(self, mock_get_logins): "org2", } assert User.get_non_indexable_logins() == expected_logins + + def test_contribution_data_default(self): + """Test that contribution_data defaults to empty dict.""" + user = User(login="testuser", node_id="U_test123") + assert user.contribution_data == {} + + def test_contribution_data_storage(self): + """Test that contribution_data can store and retrieve JSON data.""" + user = User( + login="testuser", + node_id="U_test123", + contribution_data={ + "2025-01-01": 5, + "2025-01-02": 3, + "2025-01-03": 0, + }, + ) + assert user.contribution_data["2025-01-01"] == 5 + assert user.contribution_data["2025-01-02"] == 3 + assert user.contribution_data["2025-01-03"] == 0 + + def test_contribution_data_update(self): + """Test updating contribution_data field.""" + user = User(login="testuser", node_id="U_test123") + user.contribution_data = {"2025-01-01": 10} + assert user.contribution_data == {"2025-01-01": 10} + + # Update with new data + user.contribution_data = {"2025-01-01": 15, "2025-01-02": 5} + assert user.contribution_data["2025-01-01"] == 15 + assert user.contribution_data["2025-01-02"] == 5 diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py similarity index 92% rename from backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py rename to backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py index e988e6d061..b4c4999bf8 100644 --- a/backend/tests/apps/owasp/management/commands/owasp_aggregate_contributions_test.py +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_entity_contributions_test.py @@ -1,11 +1,11 @@ -"""Test cases for owasp_aggregate_contributions management command.""" +"""Test cases for owasp_aggregate_entity_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.management.commands.owasp_aggregate_entity_contributions import Command from apps.owasp.models import Chapter, Project @@ -120,10 +120,10 @@ def test_aggregate_contribution_dates_helper(self, command): "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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_aggregate_chapter_contributions( self, mock_release, @@ -157,10 +157,10 @@ def test_aggregate_chapter_contributions( "2024-11-17": 2, } - @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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_aggregate_project_contributions( self, mock_release, @@ -215,11 +215,11 @@ def test_aggregate_project_without_repositories(self, command, mock_project): 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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_chapters_only( self, mock_release, @@ -250,11 +250,11 @@ def test_handle_chapters_only( 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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_projects_only( self, mock_release, @@ -287,12 +287,12 @@ def test_handle_projects_only( assert "commits" in mock_project.contribution_stats 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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Project") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_both_entities( self, mock_release, @@ -328,11 +328,11 @@ def test_handle_both_entities( 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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_with_specific_key( self, mock_release, @@ -363,11 +363,11 @@ def test_handle_with_specific_key( # 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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_with_offset( self, mock_release, @@ -403,11 +403,11 @@ def test_handle_with_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") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Chapter") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Commit") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Issue") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.PullRequest") + @mock.patch("apps.owasp.management.commands.owasp_aggregate_entity_contributions.Release") def test_handle_custom_days( self, mock_release, diff --git a/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py new file mode 100644 index 0000000000..e61d5dfefa --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_aggregate_member_contributions_test.py @@ -0,0 +1,287 @@ +"""Tests for community member contributions aggregation command.""" + +from datetime import UTC, datetime, timedelta +from unittest import mock + +from apps.owasp.management.commands.owasp_aggregate_member_contributions import Command + + +class MockQuerySet: + """Mock QuerySet that supports iteration and chaining without database access.""" + + def __init__(self, items): + self._items = items + + def __iter__(self): + """Return iterator over items.""" + return iter(self._items) + + def filter(self, **kwargs): + """Mock filter method.""" + return self + + def exists(self): + """Mock exists method.""" + return len(self._items) > 0 + + def count(self): + """Return count of items.""" + return len(self._items) + + def iterator(self, **kwargs): + """Mock iterator method.""" + return iter(self._items) + + def values(self, *args, **kwargs): + """Mock values method.""" + return self + + def annotate(self, **kwargs): + """Mock annotate method.""" + return self + + +class TestAggregateContributionsCommand: + """Test suite for community member contributions aggregation command.""" + + def test_aggregate_user_contributions_empty(self): + """Test aggregation with no contributions.""" + command = Command() + mock_user = mock.Mock() + mock_user.id = 1 + + # Use fixed dates for testing + fixed_now = datetime(2024, 1, 30, tzinfo=UTC) + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone.now" + ) as mock_tz_now, + ): + mock_tz_now.return_value = fixed_now + + mock_commit.objects.filter.return_value = MockQuerySet([]) + mock_pr.objects.filter.return_value = MockQuerySet([]) + mock_issue.objects.filter.return_value = MockQuerySet([]) + + result = command._aggregate_user_contributions(mock_user, start_date) + + assert isinstance(result, dict) + assert len(result) == 30 + assert all(count == 0 for count in result.values()) + + def test_aggregate_user_contributions_with_data(self): + """Test aggregation with contributions.""" + command = Command() + mock_user = mock.Mock() + mock_user.id = 1 + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 5, tzinfo=UTC) + + mock_commit.objects.filter.return_value = MockQuerySet( + [ + {"date": datetime(2024, 1, 1, 10, 0, tzinfo=UTC).date(), "count": 2}, + {"date": datetime(2024, 1, 2, 10, 0, tzinfo=UTC).date(), "count": 1}, + ] + ) + mock_pr.objects.filter.return_value = MockQuerySet( + [{"date": datetime(2024, 1, 1, 11, 0, tzinfo=UTC).date(), "count": 1}] + ) + mock_issue.objects.filter.return_value = MockQuerySet( + [{"date": datetime(2024, 1, 3, 12, 0, tzinfo=UTC).date(), "count": 3}] + ) + + result = command._aggregate_user_contributions(mock_user, start_date) + + assert isinstance(result, dict) + assert len(result) == 5 + assert result["2024-01-01"] == 3 + assert result["2024-01-02"] == 1 + assert result["2024-01-03"] == 3 + assert result["2024-01-04"] == 0 + assert result["2024-01-05"] == 0 + + def test_handle_with_specific_user_found(self): + """Test handle method with specific user that exists.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.ERROR = lambda x: x + command.style.SUCCESS = lambda x: x + + mock_user = mock.Mock() + mock_user.login = "testuser" + mock_user.contribution_data = {} + mock_user.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={"2024-01-01": 5} + ), + ): + mock_queryset = MockQuerySet([mock_user]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user="testuser", days=365, batch_size=100) + + assert mock_user.contribution_data == {"2024-01-01": 5} + assert mock_user_model.bulk_save.called + call_args = mock_user_model.bulk_save.call_args + assert call_args[0][0] == [mock_user] + assert call_args[1]["fields"] == ["contribution_data"] + + def test_handle_with_specific_user_not_found(self): + """Test handle method with specific user that doesn't exist.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.ERROR = lambda x: x + command.style.SUCCESS = lambda x: x + + with mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model: + mock_queryset = MockQuerySet([]) + mock_user_model.objects.filter.return_value = mock_queryset + + command.handle(user="nonexistent", days=365, batch_size=100) + + assert any("not found" in str(call) for call in command.stdout.write.call_args_list) + + def test_handle_all_users(self): + """Test handle method processing all users.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.SUCCESS = lambda x: x + + mock_user1 = mock.Mock() + mock_user1.login = "user1" + mock_user1.contribution_data = {} + mock_user1.save = mock.Mock() + + mock_user2 = mock.Mock() + mock_user2.login = "user2" + mock_user2.contribution_data = {} + mock_user2.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={"2024-01-01": 5} + ), + ): + mock_queryset = MockQuerySet([mock_user1, mock_user2]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user=None, days=365, batch_size=100) + + assert mock_user1.contribution_data == {"2024-01-01": 5} + assert mock_user2.contribution_data == {"2024-01-01": 5} + assert mock_user_model.bulk_save.called + call_args = mock_user_model.bulk_save.call_args + assert len(call_args[0][0]) == 2 + assert mock_user1 in call_args[0][0] + assert mock_user2 in call_args[0][0] + assert call_args[1]["fields"] == ["contribution_data"] + + def test_handle_custom_days_parameter(self): + """Test handle method with custom days parameter.""" + command = Command() + command.stdout = mock.Mock() + command.style = mock.Mock() + command.style.SUCCESS = lambda x: x + + mock_user = mock.Mock() + mock_user.contribution_data = {} + mock_user.save = mock.Mock() + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.User" + ) as mock_user_model, + mock.patch.object( + command, "_aggregate_user_contributions", return_value={} + ) as mock_aggregate, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 31, tzinfo=UTC) + mock_queryset = MockQuerySet([mock_user]) + mock_user_model.objects.filter.return_value = mock_queryset + mock_user_model.bulk_save = mock.Mock() + + command.handle(user=None, days=90, batch_size=100) + + assert mock_aggregate.called + call_args = mock_aggregate.call_args[0] + start_date = call_args[1] + expected_start = datetime(2024, 1, 31, tzinfo=UTC) - timedelta(days=90) + assert abs((expected_start - start_date).total_seconds()) < 1 + + def test_contribution_data_date_format(self): + """Test that contribution data uses correct date format.""" + command = Command() + mock_user = mock.Mock() + start_date = datetime(2024, 1, 1, tzinfo=UTC) + + with ( + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Commit" + ) as mock_commit, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.PullRequest" + ) as mock_pr, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.Issue" + ) as mock_issue, + mock.patch( + "apps.owasp.management.commands.owasp_aggregate_member_contributions.timezone" + ) as mock_tz, + ): + mock_tz.now.return_value = datetime(2024, 1, 3, tzinfo=UTC) + + mock_commit.objects.filter.return_value = MockQuerySet([]) + mock_pr.objects.filter.return_value = MockQuerySet([]) + mock_issue.objects.filter.return_value = MockQuerySet([]) + + result = command._aggregate_user_contributions(mock_user, start_date) + + for date_str in result: + datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=UTC) + assert len(date_str) == 10 + assert date_str[4] == "-" + assert date_str[7] == "-" diff --git a/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx b/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx index 3c680fd334..e93ebe3e5a 100644 --- a/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx +++ b/frontend/__tests__/a11y/pages/UserDetails.a11y.test.tsx @@ -44,6 +44,15 @@ jest.mock('utils/helpers/githubHeatmap', () => ({ drawContributions: jest.fn(() => {}), })) +jest.mock('components/ContributionHeatmap', () => { + const MockContributionHeatmap = () =>
{formattedBio}