-
-
Notifications
You must be signed in to change notification settings - Fork 335
feat: Add Contribution Heatmap to Chapter and Project Pages #2674
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
e2c562e
abd2373
5f4613b
c032255
66f003c
9dd093b
682de60
2af3339
4b34607
281a28f
80b14e6
bc5251c
45d6cc3
2b2f088
11c2789
be9dc34
db17c65
e6553e3
40aab52
7f760de
c0d6673
a47f4cf
5f00d61
1a86d98
3a9e23f
71c051f
080818f
99ec8ea
d5ff8eb
1ed2c05
6a921e1
a633111
da65529
3cd1725
b1f71f7
e559ae2
cc8eaae
b70a242
ec33a79
7401aed
331908f
c259538
c06ea65
783d3cc
76ef770
49d81b4
0b0380a
945bdfc
405163a
c446b1f
57872e9
f6a8561
a8c93b1
e364054
2e69524
bc6d2f7
30244d8
f3cde9f
671d686
ec2519c
29b966b
d8e8166
ff4b67d
677ac8a
3955af7
939ebe6
fd6f425
4254e11
c455613
e7396c3
4634c03
ab0ae8d
db546da
9c8f362
44a0c47
2f20caa
19278c3
71ee147
adb0258
696b32a
28c226b
c5a73e6
77f9b1b
3ad2b39
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,263 @@ | ||
| """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", | ||
| choices=["chapter", "project"], | ||
| help="Entity type to aggregate: chapter, project", | ||
| required=True, | ||
| type=str, | ||
| ) | ||
| parser.add_argument( | ||
| "--days", | ||
| default=365, | ||
| help="Number of days to look back for contributions (default: 365)", | ||
| type=int, | ||
| ) | ||
| parser.add_argument( | ||
| "--key", | ||
| help="Specific chapter or project key to aggregate", | ||
| type=str, | ||
| ) | ||
| parser.add_argument( | ||
| "--offset", | ||
| default=0, | ||
| help="Skip the first N entities", | ||
| type=int, | ||
| ) | ||
|
|
||
| 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 | ||
| """ | ||
| for date_value in queryset.values_list(date_field, flat=True): | ||
| if not date_value: | ||
| continue | ||
|
|
||
| date_key = date_value.date().isoformat() | ||
| contribution_map[date_key] = contribution_map.get(date_key, 0) + 1 | ||
|
|
||
| def _get_repository_ids(self, entity): | ||
| """Extract repository IDs from chapter or project.""" | ||
| repo_ids = [] | ||
|
|
||
| # Handle single owasp_repository | ||
| if hasattr(entity, "owasp_repository") and entity.owasp_repository: | ||
| repo_ids.append(entity.owasp_repository.id) | ||
|
|
||
| # Handle multiple repositories (for projects) | ||
| if hasattr(entity, "repositories"): | ||
| repo_ids.extend([r.id for r in entity.repositories.all()]) | ||
|
|
||
| return repo_ids | ||
|
Comment on lines
+69
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deduplicate repository IDs to avoid double-counting. - repo_ids = []
+ repo_ids: set[int] = set()
...
- repo_ids.append(entity.owasp_repository.id)
+ repo_ids.add(entity.owasp_repository.id)
...
- repo_ids.extend([r.id for r in entity.repositories.all()])
+ repo_ids.update(r.id for r in entity.repositories.all())
...
- return repo_ids
+ return list(repo_ids)Also applies to: 96-99 🤖 Prompt for AI Agents |
||
|
|
||
| def aggregate_contributions(self, entity, start_date: datetime) -> dict[str, int]: | ||
| """Aggregate contributions for a chapter or project. | ||
| Args: | ||
| entity: Chapter or Project instance | ||
| start_date: Start date for aggregation | ||
| Returns: | ||
| Dictionary mapping YYYY-MM-DD to contribution count | ||
| """ | ||
| contribution_map: dict[str, int] = {} | ||
|
|
||
| repo_ids = self._get_repository_ids(entity) | ||
| if not repo_ids: | ||
| return contribution_map | ||
|
|
||
| # Aggregate commits | ||
| self._aggregate_contribution_dates( | ||
| Commit.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ), | ||
| "created_at", | ||
| contribution_map, | ||
| ) | ||
|
|
||
| # Aggregate issues | ||
| self._aggregate_contribution_dates( | ||
| Issue.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ), | ||
| "created_at", | ||
| contribution_map, | ||
| ) | ||
|
|
||
| # Aggregate pull requests | ||
| self._aggregate_contribution_dates( | ||
| PullRequest.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ), | ||
| "created_at", | ||
| contribution_map, | ||
| ) | ||
|
|
||
| # Aggregate releases | ||
| self._aggregate_contribution_dates( | ||
| Release.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| published_at__gte=start_date, | ||
| is_draft=False, | ||
| ), | ||
| "published_at", | ||
| contribution_map, | ||
| ) | ||
|
|
||
| return contribution_map | ||
|
|
||
| def calculate_contribution_stats(self, entity, start_date: datetime) -> dict[str, int]: | ||
| """Calculate contribution statistics for a chapter or project. | ||
| Args: | ||
| entity: Chapter or Project instance | ||
| start_date: Start date for calculation | ||
| Returns: | ||
| Dictionary with commits, issues, pull requests, releases counts | ||
| """ | ||
| stats = { | ||
| "commits": 0, | ||
| "issues": 0, | ||
| "pull_requests": 0, | ||
| "releases": 0, | ||
| "total": 0, | ||
| } | ||
|
|
||
| repo_ids = self._get_repository_ids(entity) | ||
| if not repo_ids: | ||
| return stats | ||
|
|
||
| # Count commits | ||
| stats["commits"] = Commit.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ).count() | ||
|
|
||
| # Count issues | ||
| stats["issues"] = Issue.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ).count() | ||
|
|
||
| # Count pull requests | ||
| stats["pull_requests"] = PullRequest.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| created_at__gte=start_date, | ||
| ).count() | ||
|
|
||
| # Count releases | ||
| stats["releases"] = Release.objects.filter( | ||
| repository_id__in=repo_ids, | ||
| published_at__gte=start_date, | ||
| is_draft=False, | ||
| ).count() | ||
|
|
||
| stats["total"] = ( | ||
| stats["commits"] + stats["issues"] + stats["pull_requests"] + stats["releases"] | ||
| ) | ||
|
|
||
| return stats | ||
|
Comment on lines
+154
to
+195
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Align - stats = {
+ stats = {
"commits": 0,
"issues": 0,
- "pull_requests": 0,
+ "pullRequests": 0,
"releases": 0,
"total": 0,
}
...
- stats["pull_requests"] = PullRequest.objects.filter(...).count()
+ stats["pullRequests"] = PullRequest.objects.filter(...).count()
...
- stats["total"] = (stats["commits"] + stats["issues"] + stats["pull_requests"] + stats["releases"])
+ stats["total"] = (stats["commits"] + stats["issues"] + stats["pullRequests"] + stats["releases"])🤖 Prompt for AI Agents |
||
|
|
||
| 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)", | ||
| ), | ||
| ) | ||
|
|
||
| if entity_type == "chapter": | ||
| self._process_chapters(start_date, key, offset) | ||
| elif entity_type == "project": | ||
| 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.""" | ||
| queryset = Chapter.objects.filter(is_active=True).order_by("id") | ||
|
|
||
| if key: | ||
| queryset = queryset.filter(key=key) | ||
|
|
||
| queryset = queryset.select_related("owasp_repository") | ||
|
|
||
| if offset: | ||
| queryset = queryset[offset:] | ||
|
|
||
| self._process_entities(queryset, start_date, "chapters", Chapter) | ||
|
|
||
| def _process_projects(self, start_date, key, offset): | ||
| """Process projects for contribution aggregation.""" | ||
| queryset = ( | ||
| Project.objects.filter(is_active=True) | ||
| .order_by("id") | ||
| .select_related("owasp_repository") | ||
| .prefetch_related("repositories") | ||
| ) | ||
|
|
||
| if key: | ||
| queryset = queryset.filter(key=key) | ||
|
|
||
| if offset: | ||
| queryset = queryset[offset:] | ||
|
|
||
| self._process_entities(queryset, start_date, "projects", Project) | ||
|
|
||
| def _process_entities(self, queryset, start_date, label, model_class): | ||
| """Process entities (chapters or projects) for contribution aggregation.""" | ||
| count = queryset.count() | ||
| self.stdout.write(f"Processing {count} {label}...") | ||
|
|
||
| entities = [] | ||
| for entity in queryset: | ||
| entity.contribution_data = self.aggregate_contributions(entity, start_date) | ||
| entity.contribution_stats = self.calculate_contribution_stats(entity, start_date) | ||
| entities.append(entity) | ||
|
|
||
| if entities: | ||
| model_class.bulk_save(entities, fields=("contribution_data", "contribution_stats")) | ||
| self.stdout.write(self.style.SUCCESS(f"Updated {count} {label}")) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| ), | ||
| ), | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| # Generated by Django 5.2.8 on 2025-11-29 19:46 | ||
|
|
||
| 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", | ||
| ), | ||
| ), | ||
| ] |
Uh oh!
There was an error while loading. Please reload this page.