diff --git a/website/services/team_weekly_stats.py b/website/services/team_weekly_stats.py new file mode 100644 index 0000000000..1b196cfe09 --- /dev/null +++ b/website/services/team_weekly_stats.py @@ -0,0 +1,72 @@ +import logging +from datetime import date +from typing import Any, Dict, List + +from django.core.exceptions import ValidationError +from django.db.models import Sum +from django.db.models.functions import Coalesce + +from website.models import ContributorStats, OrganisationType, Organization + +logger = logging.getLogger(__name__) + + +def get_weekly_team_stats(start_date: date, end_date: date) -> List[Dict[str, Any]]: + """ + Aggregate weekly stats for TEAM organizations. + Returns an empty list if no TEAM organizations or contributor stats + exist in the given date range. + """ + if start_date > end_date: + raise ValidationError("start_date must be before or equal to end_date") + + logger.info("Aggregating weekly team stats from %s to %s", start_date, end_date) + + teams = list(Organization.objects.filter(type=OrganisationType.TEAM.value).only("id", "name")) + + if not teams: + logger.debug("No TEAM organizations found") + return [] + + # ContributorStats are stored at daily granularity. + # Weekly stats are computed by aggregating daily records + # over the given date range. + team_ids = [t.id for t in teams] + stats_queryset = ( + ContributorStats.objects.filter( + repo__organization_id__in=team_ids, + granularity="day", + date__range=(start_date, end_date), + ) + .values("repo__organization_id") + .annotate( + commits=Coalesce(Sum("commits"), 0), + issues_opened=Coalesce(Sum("issues_opened"), 0), + issues_closed=Coalesce(Sum("issues_closed"), 0), + pull_requests=Coalesce(Sum("pull_requests"), 0), + comments=Coalesce(Sum("comments"), 0), + ) + ) + + stats_map = {s["repo__organization_id"]: s for s in stats_queryset} + + team_stats = [] + for team in teams: + s = stats_map.get(team.id, {}) + team_stats.append( + { + "team_id": team.id, + "team_name": team.name, + "start_date": start_date, + "end_date": end_date, + "stats": { + "commits": s.get("commits", 0), + "issues_opened": s.get("issues_opened", 0), + "issues_closed": s.get("issues_closed", 0), + "pull_requests": s.get("pull_requests", 0), + "comments": s.get("comments", 0), + }, + } + ) + + return team_stats diff --git a/website/tests/test_team_weekly_stats.py b/website/tests/test_team_weekly_stats.py new file mode 100644 index 0000000000..5062e60be4 --- /dev/null +++ b/website/tests/test_team_weekly_stats.py @@ -0,0 +1,109 @@ +from datetime import date + +from django.core.exceptions import ValidationError +from django.test import TestCase + +from website.models import Contributor, ContributorStats, OrganisationType, Organization, Repo +from website.services.team_weekly_stats import get_weekly_team_stats + + +class TestWeeklyTeamStats(TestCase): + def test_invalid_date_range_raises_error(self): + with self.assertRaises(ValidationError): + get_weekly_team_stats( + start_date=date(2024, 5, 10), + end_date=date(2024, 5, 1), + ) + + def test_no_teams_returns_empty_list(self): + result = get_weekly_team_stats( + start_date=date(2025, 1, 1), + end_date=date(2025, 1, 7), + ) + self.assertEqual(result, []) + + def test_team_with_no_stats_returns_zeros(self): + team = Organization.objects.create( + name="Test Team", + type=OrganisationType.TEAM.value, + url="https://example.com/test-team", + ) + + result = get_weekly_team_stats( + start_date=date(2025, 1, 1), + end_date=date(2025, 1, 7), + ) + + self.assertEqual(len(result), 1) + team_result = result[0] + + self.assertEqual(team_result["team_id"], team.id) + self.assertEqual(team_result["team_name"], "Test Team") + self.assertEqual( + team_result["stats"], + { + "commits": 0, + "issues_opened": 0, + "issues_closed": 0, + "pull_requests": 0, + "comments": 0, + }, + ) + + def test_single_team_with_stats(self): + team = Organization.objects.create( + name="Team A", + type=OrganisationType.TEAM.value, + url="https://example.com/test-team", + ) + + repo = Repo.objects.create( + name="test-repo", + organization=team, + repo_url="https://github.com/example/test-repo", + ) + + contributor = Contributor.objects.create( + name="Test User", + github_id=12345, + github_url="https://github.com/test-user", + avatar_url="https://avatars.githubusercontent.com/u/12345", + contributor_type="INDIVIDUAL", + contributions=0, + ) + + ContributorStats.objects.create( + repo=repo, + contributor=contributor, + granularity="day", + date=date(2025, 1, 3), + commits=5, + issues_opened=2, + issues_closed=1, + pull_requests=1, + comments=3, + ) + result = get_weekly_team_stats( + start_date=date(2025, 1, 1), + end_date=date(2025, 1, 7), + ) + + self.assertEqual(len(result), 1) + + team_result = result[0] + + self.assertEqual(team_result["team_id"], team.id) + self.assertEqual(team_result["team_name"], "Team A") + self.assertEqual(team_result["start_date"], date(2025, 1, 1)) + self.assertEqual(team_result["end_date"], date(2025, 1, 7)) + + self.assertEqual( + team_result["stats"], + { + "commits": 5, + "issues_opened": 2, + "issues_closed": 1, + "pull_requests": 1, + "comments": 3, + }, + )