From db9356c1b4c7158337fd547e4f7e72060871c712 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 5 Jun 2026 23:08:49 +0530 Subject: [PATCH 01/15] Implement contribution score calculation --- backend/apps/owasp/Makefile | 4 + .../apps/owasp/admin/contribution_score.py | 18 + .../owasp_recognition_recalculate_scores.py | 35 ++ backend/apps/owasp/score_calculator.py | 336 ++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py create mode 100644 backend/apps/owasp/score_calculator.py diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 44734408aa..65429b164f 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -92,3 +92,7 @@ owasp-update-events: owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command + +owasp-recognition-recalculate-scores: + @echo "Recalculating contributor scores" + @CMD="python manage.py owasp_recognition_recalculate_scores" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index 6697fe1cfc..319d3e283e 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -3,6 +3,7 @@ from django.contrib import admin from apps.owasp.models.contribution_score import ContributionScore +from apps.owasp.score_calculator import ContributionScoreCalculator @admin.register(ContributionScore) @@ -14,6 +15,7 @@ class ContributionScoreAdmin(admin.ModelAdmin): list_filter = ("tier", "nest_created_at") search_fields = ("github_user__login", "github_user__name") readonly_fields = ("nest_created_at", "nest_updated_at") + actions = ("recalculate_score",) fieldsets = ( ( @@ -36,3 +38,19 @@ class ContributionScoreAdmin(admin.ModelAdmin): }, ), ) + + def recalculate_score(self, request, queryset): + """Admin action to recalculate scores for selected users.""" + calculator = ContributionScoreCalculator() + updated_count = 0 + + for score_obj in queryset: + calculator.recalculate_user_score(score_obj.github_user) + updated_count += 1 + + self.message_user( + request, + f"Recalculated scores for {updated_count} contributor(s).", + ) + + recalculate_score.short_description = "Recalculate selected contributors' scores" diff --git a/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py new file mode 100644 index 0000000000..45d67a8d16 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py @@ -0,0 +1,35 @@ +cl"""Command to recalculate contributor scores.""" + +import logging + +from django.core.management.base import BaseCommand + +from apps.owasp.score_calculator import ContributionScoreCalculator + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Management command for recalculating contributor scores.""" + + help = "Recalculate contributor scores and tier assignments." + + def handle(self, *_args, **options) -> None: + """Handle the command execution.""" + calculator = ContributionScoreCalculator() + self._recalculate_all_users(calculator) + + def _recalculate_all_users(self, calculator: ContributionScoreCalculator) -> None: + """Recalculate scores for all users.""" + self.stdout.write("Starting score recalculation for all users...") + + result = calculator.recalculate_all_scores() + + self.stdout.write( + self.style.SUCCESS( + f"✓ Score recalculation complete:\n" + f" - Total users: {result['total']}\n" + f" - Created: {result['created']}\n" + f" - Updated: {result['updated']}" + ) + ) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py new file mode 100644 index 0000000000..31907a3197 --- /dev/null +++ b/backend/apps/owasp/score_calculator.py @@ -0,0 +1,336 @@ +"""Contribution score calculation service.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from apps.github.models.issue import Issue +from apps.github.models.pull_request import PullRequest +from apps.github.models.user import User +from apps.owasp.models.contribution_score import ContributionScore +from apps.owasp.models.scoring_weight import ScoringWeight + +if TYPE_CHECKING: + from datetime import date + +logger = logging.getLogger(__name__) + + +class ContributionScoreCalculator: + """Service for calculating contributor scores and assigning tiers.""" + + # Tier thresholds (in points) + TIER_THRESHOLDS = { + "level_1": 0, + "level_2": 100, + "level_3": 250, + "level_4": 500, + } + + def __init__(self): + """Initialize the calculator and load scoring weights.""" + self._scoring_weights = self._load_scoring_weights() + + def _load_scoring_weights(self) -> dict[str, int]: + """Load active scoring weights from database. + + Returns: + dict[str, int]: Dict mapping event types to scores. + + """ + return { + weight.event_type: weight.score + for weight in ScoringWeight.objects.filter(is_active=True) + } + + def calculate_score( + self, + user: User, + start_date: date | None = None, + end_date: date | None = None, + ) -> dict[str, int | dict[str, int]]: + """Calculate contribution score for a user. + + Args: + user (User): The user to calculate score for. + start_date (date, optional): Start date for filtering contributions. + end_date (date, optional): End date for filtering contributions. + + Returns: + dict: Dictionary containing: + - total_score (int): Total calculated score + - breakdown (dict): Score breakdown by event type + + """ + breakdown = self._get_contribution_breakdown(user, start_date, end_date) + total_score = sum(breakdown.values()) + + return { + "total_score": total_score, + "breakdown": breakdown, + } + + def _get_contribution_breakdown( + self, + user: User, + start_date: date | None = None, + end_date: date | None = None, + ) -> dict[str, int]: + """Get score breakdown by contribution type. + + Args: + user (User): The user to calculate for. + start_date (date, optional): Start date filter. + end_date (date, optional): End date filter. + + Returns: + dict[str, int]: Score breakdown by event type. + + """ + breakdown: dict[str, int] = {} + + # Count merged PRs + pr_merged_count = self._count_merged_pull_requests(user, start_date, end_date) + breakdown["pr_merged"] = ( + pr_merged_count * self._scoring_weights.get("pr_merged", 0) + ) + + # Count opened PRs + pr_opened_count = self._count_opened_pull_requests(user, start_date, end_date) + breakdown["pr_opened"] = ( + pr_opened_count * self._scoring_weights.get("pr_opened", 0) + ) + + # Count opened issues + issue_opened_count = self._count_opened_issues(user, start_date, end_date) + breakdown["issue_opened"] = ( + issue_opened_count * self._scoring_weights.get("issue_opened", 0) + ) + + return breakdown + + def _count_merged_pull_requests( + self, + user: User, + start_date: date | None = None, + end_date: date | None = None, + ) -> int: + """Count merged PRs created by user. + + Args: + user (User): The user. + start_date (date, optional): Start date filter. + end_date (date, optional): End date filter. + + Returns: + int: Count of merged PRs. + + """ + query = PullRequest.objects.filter( + author=user, + merged_at__isnull=False, + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + + if start_date: + query = query.filter(merged_at__date__gte=start_date) + if end_date: + query = query.filter(merged_at__date__lte=end_date) + + return query.count() + + def _count_opened_pull_requests( + self, + user: User, + start_date: date | None = None, + end_date: date | None = None, + ) -> int: + """Count opened PRs created by user. + + Args: + user (User): The user. + start_date (date, optional): Start date filter. + end_date (date, optional): End date filter. + + Returns: + int: Count of opened PRs. + + """ + query = PullRequest.objects.filter( + author=user, + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + + if start_date: + query = query.filter(created_at__date__gte=start_date) + if end_date: + query = query.filter(created_at__date__lte=end_date) + + return query.count() + + def _count_opened_issues( + self, + user: User, + start_date: date | None = None, + end_date: date | None = None, + ) -> int: + """Count opened issues created by user. + + Args: + user (User): The user. + start_date (date, optional): Start date filter. + end_date (date, optional): End date filter. + + Returns: + int: Count of opened issues. + + """ + query = Issue.objects.filter( + author=user, + state_reason="completed", + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + + if start_date: + query = query.filter(created_at__date__gte=start_date) + if end_date: + query = query.filter(created_at__date__lte=end_date) + + return query.count() + + def get_tier(self, score: int) -> str: + """Determine contributor tier based on score. + + Args: + score (int): The contributor's total score. + + Returns: + str: The tier level. + + """ + # Tier thresholds mapped to tier values + tiers_by_score = [ + ("level_4", 500), + ("level_3", 250), + ("level_2", 100), + ("level_1", 0), + ] + + for tier_value, threshold in tiers_by_score: + if score >= threshold: + return tier_value + + return "level_1" + + def recalculate_all_scores(self) -> dict[str, int]: + """Recalculate scores for all users. + + Returns: + dict: Statistics about the recalculation. + + """ + # Get all indexed users with contributions + users_with_contributions = User.objects.filter( + created_pull_requests__isnull=False, + ).distinct() + + total_users = users_with_contributions.count() + updated_count = 0 + created_count = 0 + + logger.info(f"Starting score recalculation for {total_users} users") + + contribution_scores = [] + for user in users_with_contributions: + score_data = self.calculate_score(user) + total_score = score_data["total_score"] + assert isinstance(total_score, int) + tier = self.get_tier(total_score) + + try: + score_obj = user.contribution_score + score_obj.value = score_data["total_score"] + score_obj.tier = tier + contribution_scores.append(score_obj) + updated_count += 1 + except ContributionScore.DoesNotExist: + score_obj = ContributionScore( + github_user=user, + value=score_data["total_score"], + tier=tier, + ) + contribution_scores.append(score_obj) + created_count += 1 + + if len(contribution_scores) >= 100: + ContributionScore.objects.bulk_create( + [s for s in contribution_scores if not s.id], + batch_size=100, + ignore_conflicts=False, + ) + ContributionScore.objects.bulk_update( + [s for s in contribution_scores if s.id], + fields=("value", "tier"), + batch_size=100, + ) + contribution_scores.clear() + + # Save remaining scores + if contribution_scores: + ContributionScore.objects.bulk_create( + [s for s in contribution_scores if not s.id], + batch_size=100, + ignore_conflicts=False, + ) + ContributionScore.objects.bulk_update( + [s for s in contribution_scores if s.id], + fields=("value", "tier"), + batch_size=100, + ) + + logger.info( + f"Score recalculation complete. Created: {created_count}, Updated: {updated_count}" + ) + + return { + "total": total_users, + "created": created_count, + "updated": updated_count, + } + + def recalculate_user_score(self, user: User) -> dict[str, int | str]: + """Recalculate score for a single user. + + Args: + user (User): The user to recalculate. + + Returns: + dict: The updated score and tier. + + """ + score_data = self.calculate_score(user) + total_score = score_data["total_score"] + assert isinstance(total_score, int) + tier = self.get_tier(total_score) + + score_obj, created = ContributionScore.objects.update_or_create( + github_user=user, + defaults={ + "value": score_data["total_score"], + "tier": tier, + }, + ) + + logger.info( + f"Recalculated score for {user.login}: {score_data['total_score']} points ({tier})" + ) + + return { + "total_score": score_data["total_score"], + "tier": tier, + "created": created, + } From ab44181c3f11115b0ab69e5cd7218f026462a076 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Mon, 8 Jun 2026 00:13:36 +0530 Subject: [PATCH 02/15] Fixed coderabbit review --- .../apps/owasp/admin/contribution_score.py | 11 ++- .../owasp_recognition_recalculate_scores.py | 6 +- backend/apps/owasp/score_calculator.py | 91 +++++++++++-------- 3 files changed, 63 insertions(+), 45 deletions(-) diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index 319d3e283e..1ce0437ecc 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -43,14 +43,19 @@ def recalculate_score(self, request, queryset): """Admin action to recalculate scores for selected users.""" calculator = ContributionScoreCalculator() updated_count = 0 + failed_count = 0 for score_obj in queryset: - calculator.recalculate_user_score(score_obj.github_user) - updated_count += 1 + try: + calculator.recalculate_user_score(score_obj.github_user) + updated_count += 1 + except (ValueError, TypeError): + failed_count += 1 self.message_user( request, - f"Recalculated scores for {updated_count} contributor(s).", + f"Recalculated scores for {updated_count} contributor(s). " + f"Failed for {failed_count} contributor(s).", ) recalculate_score.short_description = "Recalculate selected contributors' scores" diff --git a/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py index 45d67a8d16..7cb60faa3e 100644 --- a/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py @@ -1,13 +1,9 @@ -cl"""Command to recalculate contributor scores.""" - -import logging +"""Command to recalculate contributor scores.""" from django.core.management.base import BaseCommand from apps.owasp.score_calculator import ContributionScoreCalculator -logger = logging.getLogger(__name__) - class Command(BaseCommand): """Management command for recalculating contributor scores.""" diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 31907a3197..78712af602 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -3,7 +3,9 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypedDict + +from django.db.models import Q from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest @@ -14,19 +16,29 @@ if TYPE_CHECKING: from datetime import date -logger = logging.getLogger(__name__) + +class ScoreData(TypedDict): + """Score calculation result.""" + + total_score: int + breakdown: dict[str, int] + + +class RecalculateResult(TypedDict): + """Result of recalculating a user's score.""" + + total_score: int + tier: str + created: bool + + +logger: logging.Logger = logging.getLogger(__name__) class ContributionScoreCalculator: """Service for calculating contributor scores and assigning tiers.""" - # Tier thresholds (in points) - TIER_THRESHOLDS = { - "level_1": 0, - "level_2": 100, - "level_3": 250, - "level_4": 500, - } + BATCH_SIZE = 100 def __init__(self): """Initialize the calculator and load scoring weights.""" @@ -49,7 +61,7 @@ def calculate_score( user: User, start_date: date | None = None, end_date: date | None = None, - ) -> dict[str, int | dict[str, int]]: + ) -> ScoreData: """Calculate contribution score for a user. Args: @@ -58,9 +70,7 @@ def calculate_score( end_date (date, optional): End date for filtering contributions. Returns: - dict: Dictionary containing: - - total_score (int): Total calculated score - - breakdown (dict): Score breakdown by event type + ScoreData: Dictionary containing total_score and breakdown. """ breakdown = self._get_contribution_breakdown(user, start_date, end_date) @@ -92,20 +102,16 @@ def _get_contribution_breakdown( # Count merged PRs pr_merged_count = self._count_merged_pull_requests(user, start_date, end_date) - breakdown["pr_merged"] = ( - pr_merged_count * self._scoring_weights.get("pr_merged", 0) - ) + breakdown["pr_merged"] = pr_merged_count * self._scoring_weights.get("pr_merged", 0) # Count opened PRs pr_opened_count = self._count_opened_pull_requests(user, start_date, end_date) - breakdown["pr_opened"] = ( - pr_opened_count * self._scoring_weights.get("pr_opened", 0) - ) + breakdown["pr_opened"] = pr_opened_count * self._scoring_weights.get("pr_opened", 0) # Count opened issues issue_opened_count = self._count_opened_issues(user, start_date, end_date) - breakdown["issue_opened"] = ( - issue_opened_count * self._scoring_weights.get("issue_opened", 0) + breakdown["issue_opened"] = issue_opened_count * self._scoring_weights.get( + "issue_opened", 0 ) return breakdown @@ -234,21 +240,28 @@ def recalculate_all_scores(self) -> dict[str, int]: """ # Get all indexed users with contributions + pr_authors = PullRequest.objects.filter( + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ).values("author_id") + issue_authors = Issue.objects.filter( + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ).values("author_id") users_with_contributions = User.objects.filter( - created_pull_requests__isnull=False, + Q(id__in=pr_authors) | Q(id__in=issue_authors), ).distinct() total_users = users_with_contributions.count() updated_count = 0 created_count = 0 - logger.info(f"Starting score recalculation for {total_users} users") + logger.info("Starting score recalculation for %s users", total_users) contribution_scores = [] for user in users_with_contributions: score_data = self.calculate_score(user) total_score = score_data["total_score"] - assert isinstance(total_score, int) tier = self.get_tier(total_score) try: @@ -266,16 +279,16 @@ def recalculate_all_scores(self) -> dict[str, int]: contribution_scores.append(score_obj) created_count += 1 - if len(contribution_scores) >= 100: + if len(contribution_scores) >= self.BATCH_SIZE: ContributionScore.objects.bulk_create( [s for s in contribution_scores if not s.id], - batch_size=100, - ignore_conflicts=False, + batch_size=self.BATCH_SIZE, + ignore_conflicts=True, ) ContributionScore.objects.bulk_update( [s for s in contribution_scores if s.id], fields=("value", "tier"), - batch_size=100, + batch_size=self.BATCH_SIZE, ) contribution_scores.clear() @@ -283,17 +296,19 @@ def recalculate_all_scores(self) -> dict[str, int]: if contribution_scores: ContributionScore.objects.bulk_create( [s for s in contribution_scores if not s.id], - batch_size=100, - ignore_conflicts=False, + batch_size=self.BATCH_SIZE, + ignore_conflicts=True, ) ContributionScore.objects.bulk_update( [s for s in contribution_scores if s.id], fields=("value", "tier"), - batch_size=100, + batch_size=self.BATCH_SIZE, ) logger.info( - f"Score recalculation complete. Created: {created_count}, Updated: {updated_count}" + "Score recalculation complete. Created: %s, Updated: %s", + created_count, + updated_count, ) return { @@ -302,22 +317,21 @@ def recalculate_all_scores(self) -> dict[str, int]: "updated": updated_count, } - def recalculate_user_score(self, user: User) -> dict[str, int | str]: + def recalculate_user_score(self, user: User) -> RecalculateResult: """Recalculate score for a single user. Args: user (User): The user to recalculate. Returns: - dict: The updated score and tier. + RecalculateResult: The updated score, tier, and creation flag. """ score_data = self.calculate_score(user) total_score = score_data["total_score"] - assert isinstance(total_score, int) tier = self.get_tier(total_score) - score_obj, created = ContributionScore.objects.update_or_create( + _, created = ContributionScore.objects.update_or_create( github_user=user, defaults={ "value": score_data["total_score"], @@ -326,7 +340,10 @@ def recalculate_user_score(self, user: User) -> dict[str, int | str]: ) logger.info( - f"Recalculated score for {user.login}: {score_data['total_score']} points ({tier})" + "Recalculated score for %s: %s points (%s)", + user.login, + score_data["total_score"], + tier, ) return { From 6059dca62f3123d1b23c983413fb17a523687196 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Mon, 8 Jun 2026 00:46:47 +0530 Subject: [PATCH 03/15] fix error logging --- backend/apps/owasp/admin/contribution_score.py | 8 ++++++++ backend/apps/owasp/score_calculator.py | 1 + 2 files changed, 9 insertions(+) diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index 1ce0437ecc..aac93f25b9 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -1,10 +1,14 @@ """Django admin configuration for ContributionScore model.""" +import logging + from django.contrib import admin from apps.owasp.models.contribution_score import ContributionScore from apps.owasp.score_calculator import ContributionScoreCalculator +logger: logging.Logger = logging.getLogger(__name__) + @admin.register(ContributionScore) class ContributionScoreAdmin(admin.ModelAdmin): @@ -50,6 +54,10 @@ def recalculate_score(self, request, queryset): calculator.recalculate_user_score(score_obj.github_user) updated_count += 1 except (ValueError, TypeError): + logger.exception( + "Failed to recalculate score for user %s", + score_obj.github_user.login, + ) failed_count += 1 self.message_user( diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 78712af602..aab3fb3f07 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -245,6 +245,7 @@ def recalculate_all_scores(self) -> dict[str, int]: repository__organization__is_owasp_related_organization=True, ).values("author_id") issue_authors = Issue.objects.filter( + state_reason="completed", repository__is_fork=False, repository__organization__is_owasp_related_organization=True, ).values("author_id") From e75c035be78646ab36227b19f06afaa83ad43244 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 12 Jun 2026 21:58:19 +0530 Subject: [PATCH 04/15] Address the review --- .../apps/github/models/generic_issue_model.py | 5 + backend/apps/owasp/Makefile | 4 +- backend/apps/owasp/admin/certificate.py | 2 +- .../apps/owasp/admin/contribution_score.py | 14 +- .../apps/owasp/admin/leaderboard_snapshot.py | 2 +- backend/apps/owasp/admin/scoring_weight.py | 2 +- ...asp_recognition_crp_recalculate_scores.py} | 10 +- .../0074_alter_certificate_table_and_more.py | 29 ++++ backend/apps/owasp/models/__init__.py | 10 +- backend/apps/owasp/models/crp/__init__.py | 0 .../owasp/models/{ => crp}/certificate.py | 4 +- .../models/{ => crp}/contribution_score.py | 4 +- .../models/{ => crp}/leaderboard_snapshot.py | 4 +- .../models/{ => crp}/recognition_enums.py | 0 .../owasp/models/{ => crp}/scoring_weight.py | 4 +- backend/apps/owasp/score_calculator.py | 130 ++++++------------ cspell/custom-dict.txt | 1 + 17 files changed, 107 insertions(+), 118 deletions(-) rename backend/apps/owasp/management/commands/{owasp_recognition_recalculate_scores.py => owasp_recognition_crp_recalculate_scores.py} (73%) create mode 100644 backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py create mode 100644 backend/apps/owasp/models/crp/__init__.py rename backend/apps/owasp/models/{ => crp}/certificate.py (95%) rename backend/apps/owasp/models/{ => crp}/contribution_score.py (91%) rename backend/apps/owasp/models/{ => crp}/leaderboard_snapshot.py (96%) rename backend/apps/owasp/models/{ => crp}/recognition_enums.py (100%) rename backend/apps/owasp/models/{ => crp}/scoring_weight.py (91%) diff --git a/backend/apps/github/models/generic_issue_model.py b/backend/apps/github/models/generic_issue_model.py index 8065ea905a..17d3d73a22 100644 --- a/backend/apps/github/models/generic_issue_model.py +++ b/backend/apps/github/models/generic_issue_model.py @@ -23,6 +23,11 @@ class IssueState(models.TextChoices): OPEN = "open", "Open" CLOSED = "closed", "Closed" + class StateReason(models.TextChoices): + """Issue state reason choices.""" + + COMPLETED = "completed", "Completed" + title = models.CharField(verbose_name="Title", max_length=1000) body = models.TextField(verbose_name="Body", default="") diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 65429b164f..9a3a7369ed 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -93,6 +93,6 @@ owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command -owasp-recognition-recalculate-scores: +owasp-crp-recognition-recalculate-scores: @echo "Recalculating contributor scores" - @CMD="python manage.py owasp_recognition_recalculate_scores" $(MAKE) exec-backend-command + @CMD="python manage.py owasp_crp_recognition_recalculate_scores" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin/certificate.py b/backend/apps/owasp/admin/certificate.py index 0ca96dbc4b..a83c01e582 100644 --- a/backend/apps/owasp/admin/certificate.py +++ b/backend/apps/owasp/admin/certificate.py @@ -2,7 +2,7 @@ from django.contrib import admin -from apps.owasp.models.certificate import Certificate +from apps.owasp.models.crp.certificate import Certificate @admin.register(Certificate) diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index aac93f25b9..4c8ca36cda 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -4,7 +4,7 @@ from django.contrib import admin -from apps.owasp.models.contribution_score import ContributionScore +from apps.owasp.models.crp.contribution_score import ContributionScore from apps.owasp.score_calculator import ContributionScoreCalculator logger: logging.Logger = logging.getLogger(__name__) @@ -19,7 +19,7 @@ class ContributionScoreAdmin(admin.ModelAdmin): list_filter = ("tier", "nest_created_at") search_fields = ("github_user__login", "github_user__name") readonly_fields = ("nest_created_at", "nest_updated_at") - actions = ("recalculate_score",) + actions = ("recalculate",) fieldsets = ( ( @@ -43,20 +43,20 @@ class ContributionScoreAdmin(admin.ModelAdmin): ), ) - def recalculate_score(self, request, queryset): + def recalculate(self, request, queryset): """Admin action to recalculate scores for selected users.""" calculator = ContributionScoreCalculator() updated_count = 0 failed_count = 0 - for score_obj in queryset: + for score in queryset: try: - calculator.recalculate_user_score(score_obj.github_user) + calculator.recalculate_user_score(score.github_user) updated_count += 1 except (ValueError, TypeError): logger.exception( "Failed to recalculate score for user %s", - score_obj.github_user.login, + score.github_user.login, ) failed_count += 1 @@ -66,4 +66,4 @@ def recalculate_score(self, request, queryset): f"Failed for {failed_count} contributor(s).", ) - recalculate_score.short_description = "Recalculate selected contributors' scores" + recalculate.short_description = "Recalculate selected contributors' scores" diff --git a/backend/apps/owasp/admin/leaderboard_snapshot.py b/backend/apps/owasp/admin/leaderboard_snapshot.py index d571ad8309..c1eb6b1869 100644 --- a/backend/apps/owasp/admin/leaderboard_snapshot.py +++ b/backend/apps/owasp/admin/leaderboard_snapshot.py @@ -2,7 +2,7 @@ from django.contrib import admin -from apps.owasp.models.leaderboard_snapshot import LeaderboardSnapshot +from apps.owasp.models.crp.leaderboard_snapshot import LeaderboardSnapshot @admin.register(LeaderboardSnapshot) diff --git a/backend/apps/owasp/admin/scoring_weight.py b/backend/apps/owasp/admin/scoring_weight.py index 512ead5738..6dff697c3a 100644 --- a/backend/apps/owasp/admin/scoring_weight.py +++ b/backend/apps/owasp/admin/scoring_weight.py @@ -2,7 +2,7 @@ from django.contrib import admin -from apps.owasp.models.scoring_weight import ScoringWeight +from apps.owasp.models.crp.scoring_weight import ScoringWeight @admin.register(ScoringWeight) diff --git a/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py similarity index 73% rename from backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py rename to backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py index 7cb60faa3e..5bd3a1c65b 100644 --- a/backend/apps/owasp/management/commands/owasp_recognition_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py @@ -10,20 +10,16 @@ class Command(BaseCommand): help = "Recalculate contributor scores and tier assignments." - def handle(self, *_args, **options) -> None: + def handle(self, *args, **options) -> None: """Handle the command execution.""" - calculator = ContributionScoreCalculator() - self._recalculate_all_users(calculator) - - def _recalculate_all_users(self, calculator: ContributionScoreCalculator) -> None: - """Recalculate scores for all users.""" self.stdout.write("Starting score recalculation for all users...") + calculator = ContributionScoreCalculator() result = calculator.recalculate_all_scores() self.stdout.write( self.style.SUCCESS( - f"✓ Score recalculation complete:\n" + f"Score recalculation complete:\n" f" - Total users: {result['total']}\n" f" - Created: {result['created']}\n" f" - Updated: {result['updated']}" diff --git a/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py b/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py new file mode 100644 index 0000000000..37ea11337c --- /dev/null +++ b/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 6.0.3 on 2026-06-12 16:25 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('owasp', '0073_scoringweight_certificate_contributionscore_and_more'), + ] + + operations = [ + migrations.AlterModelTable( + name='certificate', + table='owasp_crp_recognition_certificates', + ), + migrations.AlterModelTable( + name='contributionscore', + table='owasp_crp_recognition_contribution_scores', + ), + migrations.AlterModelTable( + name='leaderboardsnapshot', + table='owasp_crp_recognition_leaderboard_snapshots', + ), + migrations.AlterModelTable( + name='scoringweight', + table='owasp_crp_recognition_scoring_weights', + ), + ] diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 6cdd1bce78..d81d99a56d 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -1,19 +1,19 @@ from .board_of_directors import BoardOfDirectors -from .certificate import Certificate from .chapter import Chapter from .committee import Committee -from .contribution_score import ContributionScore +from .crp.certificate import Certificate +from .crp.contribution_score import ContributionScore +from .crp.leaderboard_snapshot import LeaderboardSnapshot +from .crp.recognition_enums import EventTypeChoices, TierChoices +from .crp.scoring_weight import ScoringWeight from .entity_channel import EntityChannel from .entity_member import EntityMember from .event import Event -from .leaderboard_snapshot import LeaderboardSnapshot from .member_profile import MemberProfile from .member_snapshot import MemberSnapshot from .post import Post from .project import Project from .project_health_metrics import ProjectHealthMetrics from .project_health_requirements import ProjectHealthRequirements -from .recognition_enums import EventTypeChoices, TierChoices -from .scoring_weight import ScoringWeight from .snapshot import Snapshot from .sponsor import Sponsor diff --git a/backend/apps/owasp/models/crp/__init__.py b/backend/apps/owasp/models/crp/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/owasp/models/certificate.py b/backend/apps/owasp/models/crp/certificate.py similarity index 95% rename from backend/apps/owasp/models/certificate.py rename to backend/apps/owasp/models/crp/certificate.py index f8178760e8..2dbde07335 100644 --- a/backend/apps/owasp/models/certificate.py +++ b/backend/apps/owasp/models/crp/certificate.py @@ -9,7 +9,7 @@ from apps.common.models import TimestampedModel from apps.github.models.user import User -from apps.owasp.models.recognition_enums import TierChoices +from apps.owasp.models.crp.recognition_enums import TierChoices class Certificate(TimestampedModel): @@ -22,7 +22,7 @@ class Certificate(TimestampedModel): class Meta: """Model options.""" - db_table = "recognition_certificates" + db_table = "owasp_crp_recognition_certificates" verbose_name_plural = "Certificates" indexes = [ models.Index(fields=["-issued_at"], name="cert_issued_at_desc"), diff --git a/backend/apps/owasp/models/contribution_score.py b/backend/apps/owasp/models/crp/contribution_score.py similarity index 91% rename from backend/apps/owasp/models/contribution_score.py rename to backend/apps/owasp/models/crp/contribution_score.py index a8fdd506bf..05bf6b3e8d 100644 --- a/backend/apps/owasp/models/contribution_score.py +++ b/backend/apps/owasp/models/crp/contribution_score.py @@ -6,7 +6,7 @@ from apps.common.models import TimestampedModel from apps.github.models.user import User -from apps.owasp.models.recognition_enums import TierChoices +from apps.owasp.models.crp.recognition_enums import TierChoices class ContributionScore(TimestampedModel): @@ -18,7 +18,7 @@ class ContributionScore(TimestampedModel): class Meta: """Model options.""" - db_table = "recognition_contribution_scores" + db_table = "owasp_crp_recognition_contribution_scores" verbose_name_plural = "Contribution Scores" indexes = [ models.Index(fields=["-value"], name="score_total_desc_idx"), diff --git a/backend/apps/owasp/models/leaderboard_snapshot.py b/backend/apps/owasp/models/crp/leaderboard_snapshot.py similarity index 96% rename from backend/apps/owasp/models/leaderboard_snapshot.py rename to backend/apps/owasp/models/crp/leaderboard_snapshot.py index 451df94a33..63a20aef0e 100644 --- a/backend/apps/owasp/models/leaderboard_snapshot.py +++ b/backend/apps/owasp/models/crp/leaderboard_snapshot.py @@ -9,7 +9,7 @@ from apps.github.models.user import User from apps.owasp.models.chapter import Chapter from apps.owasp.models.project import Project -from apps.owasp.models.recognition_enums import TierChoices +from apps.owasp.models.crp.recognition_enums import TierChoices class LeaderboardSnapshot(TimestampedModel): @@ -21,7 +21,7 @@ class LeaderboardSnapshot(TimestampedModel): class Meta: """Model options.""" - db_table = "recognition_leaderboard_snapshots" + db_table = "owasp_crp_recognition_leaderboard_snapshots" verbose_name_plural = "Leaderboard Snapshots" unique_together = ("github_user", "project", "chapter", "snapshot_date") indexes = [ diff --git a/backend/apps/owasp/models/recognition_enums.py b/backend/apps/owasp/models/crp/recognition_enums.py similarity index 100% rename from backend/apps/owasp/models/recognition_enums.py rename to backend/apps/owasp/models/crp/recognition_enums.py diff --git a/backend/apps/owasp/models/scoring_weight.py b/backend/apps/owasp/models/crp/scoring_weight.py similarity index 91% rename from backend/apps/owasp/models/scoring_weight.py rename to backend/apps/owasp/models/crp/scoring_weight.py index 0bb3247eaa..d05b3265f9 100644 --- a/backend/apps/owasp/models/scoring_weight.py +++ b/backend/apps/owasp/models/crp/scoring_weight.py @@ -6,7 +6,7 @@ from django.db import models from apps.common.models import TimestampedModel -from apps.owasp.models.recognition_enums import EventTypeChoices +from apps.owasp.models.crp.recognition_enums import EventTypeChoices class ScoringWeight(TimestampedModel): @@ -18,7 +18,7 @@ class ScoringWeight(TimestampedModel): class Meta: """Model options.""" - db_table = "recognition_scoring_weights" + db_table = "owasp_crp_recognition_scoring_weights" verbose_name_plural = "Scoring Weights" event_type = models.CharField( diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index aab3fb3f07..9704a062c1 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -3,35 +3,20 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, TypedDict +from typing import TYPE_CHECKING from django.db.models import Q from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest from apps.github.models.user import User -from apps.owasp.models.contribution_score import ContributionScore -from apps.owasp.models.scoring_weight import ScoringWeight +from apps.owasp.models.crp.contribution_score import ContributionScore +from apps.owasp.models.crp.scoring_weight import ScoringWeight if TYPE_CHECKING: from datetime import date -class ScoreData(TypedDict): - """Score calculation result.""" - - total_score: int - breakdown: dict[str, int] - - -class RecalculateResult(TypedDict): - """Result of recalculating a user's score.""" - - total_score: int - tier: str - created: bool - - logger: logging.Logger = logging.getLogger(__name__) @@ -42,9 +27,9 @@ class ContributionScoreCalculator: def __init__(self): """Initialize the calculator and load scoring weights.""" - self._scoring_weights = self._load_scoring_weights() + self.scoring_weights = self.load_scoring_weights() - def _load_scoring_weights(self) -> dict[str, int]: + def load_scoring_weights(self) -> dict[str, int]: """Load active scoring weights from database. Returns: @@ -61,7 +46,7 @@ def calculate_score( user: User, start_date: date | None = None, end_date: date | None = None, - ) -> ScoreData: + ) -> tuple[int, dict[str, int]]: """Calculate contribution score for a user. Args: @@ -70,18 +55,15 @@ def calculate_score( end_date (date, optional): End date for filtering contributions. Returns: - ScoreData: Dictionary containing total_score and breakdown. + tuple[int, dict[str, int]]: Total score and contribution breakdown. """ - breakdown = self._get_contribution_breakdown(user, start_date, end_date) + breakdown = self.get_contribution_breakdown(user, start_date, end_date) total_score = sum(breakdown.values()) - return { - "total_score": total_score, - "breakdown": breakdown, - } + return total_score, breakdown - def _get_contribution_breakdown( + def get_contribution_breakdown( self, user: User, start_date: date | None = None, @@ -101,22 +83,22 @@ def _get_contribution_breakdown( breakdown: dict[str, int] = {} # Count merged PRs - pr_merged_count = self._count_merged_pull_requests(user, start_date, end_date) - breakdown["pr_merged"] = pr_merged_count * self._scoring_weights.get("pr_merged", 0) + pr_merged_count = self.count_merged_pull_requests(user, start_date, end_date) + breakdown["pr_merged"] = pr_merged_count * self.scoring_weights.get("pr_merged", 0) # Count opened PRs - pr_opened_count = self._count_opened_pull_requests(user, start_date, end_date) - breakdown["pr_opened"] = pr_opened_count * self._scoring_weights.get("pr_opened", 0) + pr_opened_count = self.count_opened_pull_requests(user, start_date, end_date) + breakdown["pr_opened"] = pr_opened_count * self.scoring_weights.get("pr_opened", 0) # Count opened issues - issue_opened_count = self._count_opened_issues(user, start_date, end_date) - breakdown["issue_opened"] = issue_opened_count * self._scoring_weights.get( + issue_opened_count = self.count_opened_issues(user, start_date, end_date) + breakdown["issue_opened"] = issue_opened_count * self.scoring_weights.get( "issue_opened", 0 ) return breakdown - def _count_merged_pull_requests( + def count_merged_pull_requests( self, user: User, start_date: date | None = None, @@ -147,7 +129,7 @@ def _count_merged_pull_requests( return query.count() - def _count_opened_pull_requests( + def count_opened_pull_requests( self, user: User, start_date: date | None = None, @@ -177,26 +159,16 @@ def _count_opened_pull_requests( return query.count() - def _count_opened_issues( + def count_opened_issues( self, user: User, start_date: date | None = None, end_date: date | None = None, ) -> int: - """Count opened issues created by user. - - Args: - user (User): The user. - start_date (date, optional): Start date filter. - end_date (date, optional): End date filter. - - Returns: - int: Count of opened issues. - - """ + """Count opened issues created by user.""" query = Issue.objects.filter( author=user, - state_reason="completed", + state_reason=Issue.StateReason.COMPLETED, repository__is_fork=False, repository__organization__is_owasp_related_organization=True, ) @@ -244,11 +216,13 @@ def recalculate_all_scores(self) -> dict[str, int]: repository__is_fork=False, repository__organization__is_owasp_related_organization=True, ).values("author_id") + issue_authors = Issue.objects.filter( - state_reason="completed", + state_reason=Issue.StateReason.COMPLETED, repository__is_fork=False, repository__organization__is_owasp_related_organization=True, ).values("author_id") + users_with_contributions = User.objects.filter( Q(id__in=pr_authors) | Q(id__in=issue_authors), ).distinct() @@ -260,50 +234,35 @@ def recalculate_all_scores(self) -> dict[str, int]: logger.info("Starting score recalculation for %s users", total_users) contribution_scores = [] + for user in users_with_contributions: - score_data = self.calculate_score(user) - total_score = score_data["total_score"] + total_score, _ = self.calculate_score(user) tier = self.get_tier(total_score) try: - score_obj = user.contribution_score - score_obj.value = score_data["total_score"] - score_obj.tier = tier - contribution_scores.append(score_obj) + score = user.contribution_score + score.value = total_score + score.tier = tier + contribution_scores.append(score) updated_count += 1 + except ContributionScore.DoesNotExist: - score_obj = ContributionScore( + score = ContributionScore( github_user=user, - value=score_data["total_score"], + value=total_score, tier=tier, ) - contribution_scores.append(score_obj) + contribution_scores.append(score) created_count += 1 if len(contribution_scores) >= self.BATCH_SIZE: - ContributionScore.objects.bulk_create( - [s for s in contribution_scores if not s.id], - batch_size=self.BATCH_SIZE, - ignore_conflicts=True, + ContributionScore.bulk_save( + ContributionScore, contribution_scores, fields=("value", "tier") ) - ContributionScore.objects.bulk_update( - [s for s in contribution_scores if s.id], - fields=("value", "tier"), - batch_size=self.BATCH_SIZE, - ) - contribution_scores.clear() - # Save remaining scores if contribution_scores: - ContributionScore.objects.bulk_create( - [s for s in contribution_scores if not s.id], - batch_size=self.BATCH_SIZE, - ignore_conflicts=True, - ) - ContributionScore.objects.bulk_update( - [s for s in contribution_scores if s.id], - fields=("value", "tier"), - batch_size=self.BATCH_SIZE, + ContributionScore.bulk_save( + ContributionScore, contribution_scores, fields=("value", "tier") ) logger.info( @@ -318,24 +277,23 @@ def recalculate_all_scores(self) -> dict[str, int]: "updated": updated_count, } - def recalculate_user_score(self, user: User) -> RecalculateResult: + def recalculate_user_score(self, user: User) -> dict[str, str | int | bool]: """Recalculate score for a single user. Args: user (User): The user to recalculate. Returns: - RecalculateResult: The updated score, tier, and creation flag. + dict[str, str | int | bool]: The updated score, tier, and creation flag. """ - score_data = self.calculate_score(user) - total_score = score_data["total_score"] + total_score, _ = self.calculate_score(user) tier = self.get_tier(total_score) _, created = ContributionScore.objects.update_or_create( github_user=user, defaults={ - "value": score_data["total_score"], + "value": total_score, "tier": tier, }, ) @@ -343,12 +301,12 @@ def recalculate_user_score(self, user: User) -> RecalculateResult: logger.info( "Recalculated score for %s: %s points (%s)", user.login, - score_data["total_score"], + total_score, tier, ) return { - "total_score": score_data["total_score"], + "total_score": total_score, "tier": tier, "created": created, } diff --git a/cspell/custom-dict.txt b/cspell/custom-dict.txt index 8bc0c5f487..99dcac5346 100644 --- a/cspell/custom-dict.txt +++ b/cspell/custom-dict.txt @@ -101,6 +101,7 @@ collectstatic coraza corsheaders credentialless +crp csrfguard csrfprotector csrftoken From 034b419cc85b5c4438331ea5bb4968201b365014 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 12 Jun 2026 22:03:26 +0530 Subject: [PATCH 05/15] fixed pre-commit check --- .../0074_alter_certificate_table_and_more.py | 19 +++++++++---------- .../owasp/models/crp/leaderboard_snapshot.py | 2 +- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py b/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py index 37ea11337c..50a2e045f9 100644 --- a/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py +++ b/backend/apps/owasp/migrations/0074_alter_certificate_table_and_more.py @@ -4,26 +4,25 @@ class Migration(migrations.Migration): - dependencies = [ - ('owasp', '0073_scoringweight_certificate_contributionscore_and_more'), + ("owasp", "0073_scoringweight_certificate_contributionscore_and_more"), ] operations = [ migrations.AlterModelTable( - name='certificate', - table='owasp_crp_recognition_certificates', + name="certificate", + table="owasp_crp_recognition_certificates", ), migrations.AlterModelTable( - name='contributionscore', - table='owasp_crp_recognition_contribution_scores', + name="contributionscore", + table="owasp_crp_recognition_contribution_scores", ), migrations.AlterModelTable( - name='leaderboardsnapshot', - table='owasp_crp_recognition_leaderboard_snapshots', + name="leaderboardsnapshot", + table="owasp_crp_recognition_leaderboard_snapshots", ), migrations.AlterModelTable( - name='scoringweight', - table='owasp_crp_recognition_scoring_weights', + name="scoringweight", + table="owasp_crp_recognition_scoring_weights", ), ] diff --git a/backend/apps/owasp/models/crp/leaderboard_snapshot.py b/backend/apps/owasp/models/crp/leaderboard_snapshot.py index 63a20aef0e..a334198233 100644 --- a/backend/apps/owasp/models/crp/leaderboard_snapshot.py +++ b/backend/apps/owasp/models/crp/leaderboard_snapshot.py @@ -8,8 +8,8 @@ from apps.common.models import TimestampedModel from apps.github.models.user import User from apps.owasp.models.chapter import Chapter -from apps.owasp.models.project import Project from apps.owasp.models.crp.recognition_enums import TierChoices +from apps.owasp.models.project import Project class LeaderboardSnapshot(TimestampedModel): From 3461f2caa92eff870107478b41871babf0be620a Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 12 Jun 2026 23:00:52 +0530 Subject: [PATCH 06/15] Address coderabbit comment --- .../apps/owasp/admin/contribution_score.py | 2 +- ...asp_crp_recognition_recalculate_scores.py} | 2 +- backend/apps/owasp/score_calculator.py | 19 ++++++++++--------- 3 files changed, 12 insertions(+), 11 deletions(-) rename backend/apps/owasp/management/commands/{owasp_recognition_crp_recalculate_scores.py => owasp_crp_recognition_recalculate_scores.py} (94%) diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index 4c8ca36cda..cac5a688a7 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -51,7 +51,7 @@ def recalculate(self, request, queryset): for score in queryset: try: - calculator.recalculate_user_score(score.github_user) + calculator.recalculate_user(score.github_user) updated_count += 1 except (ValueError, TypeError): logger.exception( diff --git a/backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py similarity index 94% rename from backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py rename to backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py index 5bd3a1c65b..7f982afae8 100644 --- a/backend/apps/owasp/management/commands/owasp_recognition_crp_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py @@ -15,7 +15,7 @@ def handle(self, *args, **options) -> None: self.stdout.write("Starting score recalculation for all users...") calculator = ContributionScoreCalculator() - result = calculator.recalculate_all_scores() + result = calculator.recalculate_all() self.stdout.write( self.style.SUCCESS( diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 9704a062c1..2a09ebec16 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -7,6 +7,7 @@ from django.db.models import Q +from apps.common.models import BulkSaveModel from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest from apps.github.models.user import User @@ -41,7 +42,7 @@ def load_scoring_weights(self) -> dict[str, int]: for weight in ScoringWeight.objects.filter(is_active=True) } - def calculate_score( + def calculate( self, user: User, start_date: date | None = None, @@ -204,7 +205,7 @@ def get_tier(self, score: int) -> str: return "level_1" - def recalculate_all_scores(self) -> dict[str, int]: + def recalculate_all(self) -> dict[str, int]: """Recalculate scores for all users. Returns: @@ -236,7 +237,7 @@ def recalculate_all_scores(self) -> dict[str, int]: contribution_scores = [] for user in users_with_contributions: - total_score, _ = self.calculate_score(user) + total_score, _ = self.calculate(user) tier = self.get_tier(total_score) try: @@ -256,13 +257,13 @@ def recalculate_all_scores(self) -> dict[str, int]: created_count += 1 if len(contribution_scores) >= self.BATCH_SIZE: - ContributionScore.bulk_save( - ContributionScore, contribution_scores, fields=("value", "tier") + BulkSaveModel.bulk_save( + ContributionScore, contribution_scores, fields=["value", "tier"] ) if contribution_scores: - ContributionScore.bulk_save( - ContributionScore, contribution_scores, fields=("value", "tier") + BulkSaveModel.bulk_save( + ContributionScore, contribution_scores, fields=["value", "tier"] ) logger.info( @@ -277,7 +278,7 @@ def recalculate_all_scores(self) -> dict[str, int]: "updated": updated_count, } - def recalculate_user_score(self, user: User) -> dict[str, str | int | bool]: + def recalculate_user(self, user: User) -> dict[str, str | int | bool]: """Recalculate score for a single user. Args: @@ -287,7 +288,7 @@ def recalculate_user_score(self, user: User) -> dict[str, str | int | bool]: dict[str, str | int | bool]: The updated score, tier, and creation flag. """ - total_score, _ = self.calculate_score(user) + total_score, _ = self.calculate(user) tier = self.get_tier(total_score) _, created = ContributionScore.objects.update_or_create( From 839d7725a86022bd6ff4940c0ad88c43d35894b5 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 18:27:07 +0530 Subject: [PATCH 07/15] Create Certificate metadata generation --- backend/apps/owasp/score_calculator.py | 31 +++++++- .../owasp/services/certificate_providers.py | 74 +++++++++++++++++++ .../owasp/services/certificate_service.py | 61 +++++++++++++++ 3 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 backend/apps/owasp/services/certificate_providers.py create mode 100644 backend/apps/owasp/services/certificate_service.py diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 2a09ebec16..0888e91ff8 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -12,7 +12,9 @@ from apps.github.models.pull_request import PullRequest from apps.github.models.user import User from apps.owasp.models.crp.contribution_score import ContributionScore +from apps.owasp.models.crp.recognition_enums import TierChoices from apps.owasp.models.crp.scoring_weight import ScoringWeight +from apps.owasp.services.certificate_service import CertificateService if TYPE_CHECKING: from datetime import date @@ -181,14 +183,14 @@ def count_opened_issues( return query.count() - def get_tier(self, score: int) -> str: + def get_tier(self, score: int) -> TierChoices: """Determine contributor tier based on score. Args: score (int): The contributor's total score. Returns: - str: The tier level. + TierChoices: The tier level choice. """ # Tier thresholds mapped to tier values @@ -201,9 +203,9 @@ def get_tier(self, score: int) -> str: for tier_value, threshold in tiers_by_score: if score >= threshold: - return tier_value + return TierChoices(tier_value) - return "level_1" + return TierChoices("level_1") def recalculate_all(self) -> dict[str, int]: """Recalculate scores for all users. @@ -235,6 +237,7 @@ def recalculate_all(self) -> dict[str, int]: logger.info("Starting score recalculation for %s users", total_users) contribution_scores = [] + pending_certificates = [] for user in users_with_contributions: total_score, _ = self.calculate(user) @@ -256,15 +259,33 @@ def recalculate_all(self) -> dict[str, int]: contribution_scores.append(score) created_count += 1 + pending_certificates.append(score) + if len(contribution_scores) >= self.BATCH_SIZE: BulkSaveModel.bulk_save( ContributionScore, contribution_scores, fields=["value", "tier"] ) + for pending_score in pending_certificates: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + pending_certificates.clear() + contribution_scores.clear() if contribution_scores: BulkSaveModel.bulk_save( ContributionScore, contribution_scores, fields=["value", "tier"] ) + for pending_score in pending_certificates: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + pending_certificates.clear() + contribution_scores.clear() logger.info( "Score recalculation complete. Created: %s, Updated: %s", @@ -299,6 +320,8 @@ def recalculate_user(self, user: User) -> dict[str, str | int | bool]: }, ) + CertificateService.issue_certificate(user, total_score, tier) + logger.info( "Recalculated score for %s: %s points (%s)", user.login, diff --git a/backend/apps/owasp/services/certificate_providers.py b/backend/apps/owasp/services/certificate_providers.py new file mode 100644 index 0000000000..a7529d5dfb --- /dev/null +++ b/backend/apps/owasp/services/certificate_providers.py @@ -0,0 +1,74 @@ +"""Certificate provider classes for Contributor Recognition Program.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from django.conf import settings + +from apps.owasp.models.crp.certificate import Certificate +from apps.owasp.models.crp.recognition_enums import TierChoices + +if TYPE_CHECKING: + from apps.github.models.user import User + + +class BaseCertificateProvider(ABC): + """Abstract base class for certificate providers.""" + + @abstractmethod + def issue(self, user: User, score: int, tier: TierChoices) -> None: + """Issue a certificate. + + Args: + user (User): The user for whom the certificate is issued. + score (int): The score when the certificate is issued. + tier (TierChoices): The tier for which the certificate is issued. + + """ + + +class LocalCertificateProvider(BaseCertificateProvider): + """Certificate provider that records certificate metadata in local database.""" + + def issue(self, user: User, score: int, tier: TierChoices) -> None: + """Record the certificate metadata in the local database. + + Args: + user (User): The user for whom the certificate is issued. + score (int): The score when the certificate is issued. + tier (TierChoices): The tier for which the certificate is issued. + + """ + Certificate.objects.create( + github_user=user, + score=score, + tier=tier, + ) + + +class CertificateProviderFactory: + """Factory for resolving and instantiating certificate providers.""" + + PROVIDERS: dict[str, type[BaseCertificateProvider]] = { + "local": LocalCertificateProvider, + } + + @classmethod + def get_provider(cls) -> BaseCertificateProvider: + """Resolve and instantiate the configured certificate provider. + + Returns: + BaseCertificateProvider: The active certificate provider instance. + + Raises: + ValueError: If the configured provider is unknown. + + """ + provider_type = getattr(settings, "CERTIFICATE_PROVIDER", "local") + provider_class = cls.PROVIDERS.get(provider_type) + if not provider_class: + raise ValueError(f"Unknown certificate provider: {provider_type}") + + return provider_class() diff --git a/backend/apps/owasp/services/certificate_service.py b/backend/apps/owasp/services/certificate_service.py new file mode 100644 index 0000000000..6cbbac17b8 --- /dev/null +++ b/backend/apps/owasp/services/certificate_service.py @@ -0,0 +1,61 @@ +"""Certificate service layer for Contributor Recognition Program.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from apps.owasp.models.crp.certificate import Certificate +from apps.owasp.models.crp.recognition_enums import TierChoices +from apps.owasp.services.certificate_providers import CertificateProviderFactory + +if TYPE_CHECKING: + from apps.github.models.user import User + +logger = logging.getLogger(__name__) + + +class CertificateService: + """Service layer for orchestrating contributor certificate workflows.""" + + @classmethod + def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: + """Issue the certificate for the user's current tier if it does not already exist. + + Args: + user (User): The user to issue certificates for. + score (int): The current contribution score of the user. + tier (TierChoices): The current tier the user qualifies for. + + """ + + # Check if user already has an active certificate for this specific tier + has_active_cert = Certificate.objects.filter( + github_user=user, + tier=tier, + is_revoked=False, + ).exists() + + if has_active_cert: + return + + try: + provider = CertificateProviderFactory.get_provider() + except ValueError as e: + logger.exception("Failed to resolve certificate provider: %s", str(e)) + return + + logger.info( + "Issuing %s certificate to user %s with score %s", + tier, + user.login, + score, + ) + try: + provider.issue(user, score, tier) + except Exception: + logger.exception( + "Failed to issue %s certificate for user %s", + tier, + user.login, + ) From 0359e86160eca6eabf66f2b9a1807d51d6d1cbb5 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 18:37:55 +0530 Subject: [PATCH 08/15] fixed check --- backend/apps/owasp/services/__init__.py | 1 + backend/apps/owasp/services/certificate_providers.py | 5 +++-- backend/apps/owasp/services/certificate_service.py | 7 +++---- 3 files changed, 7 insertions(+), 6 deletions(-) create mode 100644 backend/apps/owasp/services/__init__.py diff --git a/backend/apps/owasp/services/__init__.py b/backend/apps/owasp/services/__init__.py new file mode 100644 index 0000000000..884a3af86f --- /dev/null +++ b/backend/apps/owasp/services/__init__.py @@ -0,0 +1 @@ +"""Service layer for Contributor Recognition Program.""" diff --git a/backend/apps/owasp/services/certificate_providers.py b/backend/apps/owasp/services/certificate_providers.py index a7529d5dfb..288d881839 100644 --- a/backend/apps/owasp/services/certificate_providers.py +++ b/backend/apps/owasp/services/certificate_providers.py @@ -8,10 +8,10 @@ from django.conf import settings from apps.owasp.models.crp.certificate import Certificate -from apps.owasp.models.crp.recognition_enums import TierChoices if TYPE_CHECKING: from apps.github.models.user import User + from apps.owasp.models.crp.recognition_enums import TierChoices class BaseCertificateProvider(ABC): @@ -69,6 +69,7 @@ def get_provider(cls) -> BaseCertificateProvider: provider_type = getattr(settings, "CERTIFICATE_PROVIDER", "local") provider_class = cls.PROVIDERS.get(provider_type) if not provider_class: - raise ValueError(f"Unknown certificate provider: {provider_type}") + error_msg = f"Unknown certificate provider: {provider_type}" + raise ValueError(error_msg) return provider_class() diff --git a/backend/apps/owasp/services/certificate_service.py b/backend/apps/owasp/services/certificate_service.py index 6cbbac17b8..ed1b477e13 100644 --- a/backend/apps/owasp/services/certificate_service.py +++ b/backend/apps/owasp/services/certificate_service.py @@ -6,11 +6,11 @@ from typing import TYPE_CHECKING from apps.owasp.models.crp.certificate import Certificate -from apps.owasp.models.crp.recognition_enums import TierChoices from apps.owasp.services.certificate_providers import CertificateProviderFactory if TYPE_CHECKING: from apps.github.models.user import User + from apps.owasp.models.crp.recognition_enums import TierChoices logger = logging.getLogger(__name__) @@ -28,7 +28,6 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: tier (TierChoices): The current tier the user qualifies for. """ - # Check if user already has an active certificate for this specific tier has_active_cert = Certificate.objects.filter( github_user=user, @@ -41,8 +40,8 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: try: provider = CertificateProviderFactory.get_provider() - except ValueError as e: - logger.exception("Failed to resolve certificate provider: %s", str(e)) + except ValueError: + logger.exception("Failed to resolve certificate provider") return logger.info( From 362b013c0ec19c41faaa7f3841bcd202ecbf37c8 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 19:15:39 +0530 Subject: [PATCH 09/15] address coderabbit comment --- .../apps/owasp/admin/contribution_score.py | 2 +- backend/apps/owasp/exceptions.py | 4 + backend/apps/owasp/score_calculator.py | 82 +++++++++++++++---- .../owasp/services/certificate_service.py | 16 +++- 4 files changed, 84 insertions(+), 20 deletions(-) diff --git a/backend/apps/owasp/admin/contribution_score.py b/backend/apps/owasp/admin/contribution_score.py index cac5a688a7..8e0afbc272 100644 --- a/backend/apps/owasp/admin/contribution_score.py +++ b/backend/apps/owasp/admin/contribution_score.py @@ -53,7 +53,7 @@ def recalculate(self, request, queryset): try: calculator.recalculate_user(score.github_user) updated_count += 1 - except (ValueError, TypeError): + except Exception: logger.exception( "Failed to recalculate score for user %s", score.github_user.login, diff --git a/backend/apps/owasp/exceptions.py b/backend/apps/owasp/exceptions.py index c3a122d727..86ca082944 100644 --- a/backend/apps/owasp/exceptions.py +++ b/backend/apps/owasp/exceptions.py @@ -3,3 +3,7 @@ class SnapshotProcessingError(Exception): """Exception raised for errors in snapshot processing.""" + + +class CertificateIssuanceError(Exception): + """Exception raised for errors in certificate issuance.""" diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 0888e91ff8..50c17665ce 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from django.db.models import Q +from django.db.models import Count, Q from apps.common.models import BulkSaveModel from apps.github.models.issue import Issue @@ -226,9 +226,13 @@ def recalculate_all(self) -> dict[str, int]: repository__organization__is_owasp_related_organization=True, ).values("author_id") - users_with_contributions = User.objects.filter( - Q(id__in=pr_authors) | Q(id__in=issue_authors), - ).distinct() + users_with_contributions = ( + User.objects.filter( + Q(id__in=pr_authors) | Q(id__in=issue_authors), + ) + .distinct() + .prefetch_related("contribution_score") + ) total_users = users_with_contributions.count() updated_count = 0 @@ -236,11 +240,47 @@ def recalculate_all(self) -> dict[str, int]: logger.info("Starting score recalculation for %s users", total_users) + pr_merged_counts: dict[int, int] = dict( + PullRequest.objects.filter( + merged_at__isnull=False, + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + .values("author_id") + .annotate(count=Count("id")) + .values_list("author_id", "count") + ) + + pr_opened_counts: dict[int, int] = dict( + PullRequest.objects.filter( + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + .values("author_id") + .annotate(count=Count("id")) + .values_list("author_id", "count") + ) + + issue_opened_counts: dict[int, int] = dict( + Issue.objects.filter( + state_reason=Issue.StateReason.COMPLETED, + repository__is_fork=False, + repository__organization__is_owasp_related_organization=True, + ) + .values("author_id") + .annotate(count=Count("id")) + .values_list("author_id", "count") + ) + contribution_scores = [] pending_certificates = [] for user in users_with_contributions: - total_score, _ = self.calculate(user) + total_score = ( + pr_merged_counts.get(user.id, 0) * self.scoring_weights.get("pr_merged", 0) + + pr_opened_counts.get(user.id, 0) * self.scoring_weights.get("pr_opened", 0) + + issue_opened_counts.get(user.id, 0) * self.scoring_weights.get("issue_opened", 0) + ) tier = self.get_tier(total_score) try: @@ -266,11 +306,17 @@ def recalculate_all(self) -> dict[str, int]: ContributionScore, contribution_scores, fields=["value", "tier"] ) for pending_score in pending_certificates: - CertificateService.issue_certificate( - pending_score.github_user, - pending_score.value, - TierChoices(pending_score.tier), - ) + try: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + except Exception: + logger.exception( + "Failed to issue certificate for user %s", + pending_score.github_user.login, + ) pending_certificates.clear() contribution_scores.clear() @@ -279,11 +325,17 @@ def recalculate_all(self) -> dict[str, int]: ContributionScore, contribution_scores, fields=["value", "tier"] ) for pending_score in pending_certificates: - CertificateService.issue_certificate( - pending_score.github_user, - pending_score.value, - TierChoices(pending_score.tier), - ) + try: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + except Exception: + logger.exception( + "Failed to issue certificate for user %s", + pending_score.github_user.login, + ) pending_certificates.clear() contribution_scores.clear() diff --git a/backend/apps/owasp/services/certificate_service.py b/backend/apps/owasp/services/certificate_service.py index ed1b477e13..23f9559f11 100644 --- a/backend/apps/owasp/services/certificate_service.py +++ b/backend/apps/owasp/services/certificate_service.py @@ -5,11 +5,14 @@ import logging from typing import TYPE_CHECKING +from django.db import transaction + +from apps.github.models.user import User +from apps.owasp.exceptions import CertificateIssuanceError from apps.owasp.models.crp.certificate import Certificate from apps.owasp.services.certificate_providers import CertificateProviderFactory if TYPE_CHECKING: - from apps.github.models.user import User from apps.owasp.models.crp.recognition_enums import TierChoices logger = logging.getLogger(__name__) @@ -19,6 +22,7 @@ class CertificateService: """Service layer for orchestrating contributor certificate workflows.""" @classmethod + @transaction.atomic def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: """Issue the certificate for the user's current tier if it does not already exist. @@ -28,6 +32,9 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: tier (TierChoices): The current tier the user qualifies for. """ + # Lock the User row to serialize concurrent certificate issuances for this user + user = User.objects.select_for_update().get(id=user.id) + # Check if user already has an active certificate for this specific tier has_active_cert = Certificate.objects.filter( github_user=user, @@ -40,9 +47,9 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: try: provider = CertificateProviderFactory.get_provider() - except ValueError: + except ValueError as e: logger.exception("Failed to resolve certificate provider") - return + raise CertificateIssuanceError from e logger.info( "Issuing %s certificate to user %s with score %s", @@ -52,9 +59,10 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: ) try: provider.issue(user, score, tier) - except Exception: + except Exception as e: logger.exception( "Failed to issue %s certificate for user %s", tier, user.login, ) + raise CertificateIssuanceError from e From d651eedf49749d094ec842d9b7c1b7001c5bba08 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 19:33:24 +0530 Subject: [PATCH 10/15] update --- backend/apps/owasp/score_calculator.py | 41 +++++++++++++------------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 50c17665ce..eeb08deb1f 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -66,6 +66,14 @@ def calculate( return total_score, breakdown + def calculate_score(self, counts: dict[str, int]) -> tuple[int, dict[str, int]]: + """Calculate total score and breakdown from contribution counts.""" + breakdown = { + event_type: count * self.scoring_weights.get(event_type, 0) + for event_type, count in counts.items() + } + return sum(breakdown.values()), breakdown + def get_contribution_breakdown( self, user: User, @@ -83,22 +91,12 @@ def get_contribution_breakdown( dict[str, int]: Score breakdown by event type. """ - breakdown: dict[str, int] = {} - - # Count merged PRs - pr_merged_count = self.count_merged_pull_requests(user, start_date, end_date) - breakdown["pr_merged"] = pr_merged_count * self.scoring_weights.get("pr_merged", 0) - - # Count opened PRs - pr_opened_count = self.count_opened_pull_requests(user, start_date, end_date) - breakdown["pr_opened"] = pr_opened_count * self.scoring_weights.get("pr_opened", 0) - - # Count opened issues - issue_opened_count = self.count_opened_issues(user, start_date, end_date) - breakdown["issue_opened"] = issue_opened_count * self.scoring_weights.get( - "issue_opened", 0 - ) - + counts = { + "pr_merged": self.count_merged_pull_requests(user, start_date, end_date), + "pr_opened": self.count_opened_pull_requests(user, start_date, end_date), + "issue_opened": self.count_opened_issues(user, start_date, end_date), + } + _, breakdown = self.calculate_score(counts) return breakdown def count_merged_pull_requests( @@ -276,11 +274,12 @@ def recalculate_all(self) -> dict[str, int]: pending_certificates = [] for user in users_with_contributions: - total_score = ( - pr_merged_counts.get(user.id, 0) * self.scoring_weights.get("pr_merged", 0) - + pr_opened_counts.get(user.id, 0) * self.scoring_weights.get("pr_opened", 0) - + issue_opened_counts.get(user.id, 0) * self.scoring_weights.get("issue_opened", 0) - ) + counts = { + "pr_merged": pr_merged_counts.get(user.id, 0), + "pr_opened": pr_opened_counts.get(user.id, 0), + "issue_opened": issue_opened_counts.get(user.id, 0), + } + total_score, _ = self.calculate_score(counts) tier = self.get_tier(total_score) try: From 32353e54c0bd12ce6f8df4626f8ee9578cffb8bd Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 19:41:09 +0530 Subject: [PATCH 11/15] fix error handling --- backend/apps/owasp/exceptions.py | 4 ++++ backend/apps/owasp/score_calculator.py | 16 ++++++++++++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/backend/apps/owasp/exceptions.py b/backend/apps/owasp/exceptions.py index 86ca082944..3c60465536 100644 --- a/backend/apps/owasp/exceptions.py +++ b/backend/apps/owasp/exceptions.py @@ -7,3 +7,7 @@ class SnapshotProcessingError(Exception): class CertificateIssuanceError(Exception): """Exception raised for errors in certificate issuance.""" + + +class CertificateBatchIssuanceError(Exception): + """Exception raised when one or more certificates fail in batch recalculation.""" diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index eeb08deb1f..66b2b9f543 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -11,6 +11,7 @@ from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest from apps.github.models.user import User +from apps.owasp.exceptions import CertificateBatchIssuanceError from apps.owasp.models.crp.contribution_score import ContributionScore from apps.owasp.models.crp.recognition_enums import TierChoices from apps.owasp.models.crp.scoring_weight import ScoringWeight @@ -272,6 +273,7 @@ def recalculate_all(self) -> dict[str, int]: contribution_scores = [] pending_certificates = [] + failed_certificates: list[tuple[str, Exception]] = [] for user in users_with_contributions: counts = { @@ -311,11 +313,12 @@ def recalculate_all(self) -> dict[str, int]: pending_score.value, TierChoices(pending_score.tier), ) - except Exception: + except Exception as e: logger.exception( "Failed to issue certificate for user %s", pending_score.github_user.login, ) + failed_certificates.append((pending_score.github_user.login, e)) pending_certificates.clear() contribution_scores.clear() @@ -330,14 +333,23 @@ def recalculate_all(self) -> dict[str, int]: pending_score.value, TierChoices(pending_score.tier), ) - except Exception: + except Exception as e: logger.exception( "Failed to issue certificate for user %s", pending_score.github_user.login, ) + failed_certificates.append((pending_score.github_user.login, e)) pending_certificates.clear() contribution_scores.clear() + if failed_certificates: + failed_usernames = [username for username, _ in failed_certificates] + error_msg = ( + f"Failed to issue certificates for {len(failed_certificates)} user(s): " + f"{', '.join(failed_usernames)}" + ) + raise CertificateBatchIssuanceError(error_msg) + logger.info( "Score recalculation complete. Created: %s, Updated: %s", created_count, From 2839c93528f20ff5cfecc045eec6f5b9bdf252f1 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Sun, 14 Jun 2026 20:05:47 +0530 Subject: [PATCH 12/15] update --- backend/apps/owasp/exceptions.py | 4 ---- .../owasp_crp_recognition_recalculate_scores.py | 16 ++++++++++++++-- backend/apps/owasp/score_calculator.py | 15 ++++----------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/backend/apps/owasp/exceptions.py b/backend/apps/owasp/exceptions.py index 3c60465536..86ca082944 100644 --- a/backend/apps/owasp/exceptions.py +++ b/backend/apps/owasp/exceptions.py @@ -7,7 +7,3 @@ class SnapshotProcessingError(Exception): class CertificateIssuanceError(Exception): """Exception raised for errors in certificate issuance.""" - - -class CertificateBatchIssuanceError(Exception): - """Exception raised when one or more certificates fail in batch recalculation.""" diff --git a/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py index 7f982afae8..e0b0cda897 100644 --- a/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py @@ -1,6 +1,6 @@ """Command to recalculate contributor scores.""" -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from apps.owasp.score_calculator import ContributionScoreCalculator @@ -22,6 +22,18 @@ def handle(self, *args, **options) -> None: f"Score recalculation complete:\n" f" - Total users: {result['total']}\n" f" - Created: {result['created']}\n" - f" - Updated: {result['updated']}" + f" - Updated: {result['updated']}\n" + f" - Failed: {result['failed_count']}" ) ) + + failed_count = result.get("failed_count", 0) + if failed_count > 0: + failures = result.get("failures", []) + failed_users = [username for username, _ in failures] + failed_str = ", ".join(failed_users) + self.stdout.write( + self.style.WARNING(f"Failed to issue certificates for: {failed_str}") + ) + error_msg = f"Failed to issue certificates for {failed_count} user(s)" + raise CommandError(error_msg) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 66b2b9f543..a974be8c43 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from django.db.models import Count, Q @@ -11,7 +11,6 @@ from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest from apps.github.models.user import User -from apps.owasp.exceptions import CertificateBatchIssuanceError from apps.owasp.models.crp.contribution_score import ContributionScore from apps.owasp.models.crp.recognition_enums import TierChoices from apps.owasp.models.crp.scoring_weight import ScoringWeight @@ -206,7 +205,7 @@ def get_tier(self, score: int) -> TierChoices: return TierChoices("level_1") - def recalculate_all(self) -> dict[str, int]: + def recalculate_all(self) -> dict[str, Any]: """Recalculate scores for all users. Returns: @@ -342,14 +341,6 @@ def recalculate_all(self) -> dict[str, int]: pending_certificates.clear() contribution_scores.clear() - if failed_certificates: - failed_usernames = [username for username, _ in failed_certificates] - error_msg = ( - f"Failed to issue certificates for {len(failed_certificates)} user(s): " - f"{', '.join(failed_usernames)}" - ) - raise CertificateBatchIssuanceError(error_msg) - logger.info( "Score recalculation complete. Created: %s, Updated: %s", created_count, @@ -360,6 +351,8 @@ def recalculate_all(self) -> dict[str, int]: "total": total_users, "created": created_count, "updated": updated_count, + "failed_count": len(failed_certificates), + "failures": failed_certificates, } def recalculate_user(self, user: User) -> dict[str, str | int | bool]: From 425ee43375652bb56276652900047d0e3f5fa788 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 19 Jun 2026 17:27:39 +0530 Subject: [PATCH 13/15] Fixed file naming --- .../commands/owasp_crp_recalculate_scores.py | 16 +++++++- ...wasp_crp_recognition_recalculate_scores.py | 39 ------------------- backend/apps/owasp/score_calculator.py | 4 +- 3 files changed, 16 insertions(+), 43 deletions(-) delete mode 100644 backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py diff --git a/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py index 7f982afae8..e0b0cda897 100644 --- a/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py @@ -1,6 +1,6 @@ """Command to recalculate contributor scores.""" -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from apps.owasp.score_calculator import ContributionScoreCalculator @@ -22,6 +22,18 @@ def handle(self, *args, **options) -> None: f"Score recalculation complete:\n" f" - Total users: {result['total']}\n" f" - Created: {result['created']}\n" - f" - Updated: {result['updated']}" + f" - Updated: {result['updated']}\n" + f" - Failed: {result['failed_count']}" ) ) + + failed_count = result.get("failed_count", 0) + if failed_count > 0: + failures = result.get("failures", []) + failed_users = [username for username, _ in failures] + failed_str = ", ".join(failed_users) + self.stdout.write( + self.style.WARNING(f"Failed to issue certificates for: {failed_str}") + ) + error_msg = f"Failed to issue certificates for {failed_count} user(s)" + raise CommandError(error_msg) diff --git a/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py deleted file mode 100644 index e0b0cda897..0000000000 --- a/backend/apps/owasp/management/commands/owasp_crp_recognition_recalculate_scores.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Command to recalculate contributor scores.""" - -from django.core.management.base import BaseCommand, CommandError - -from apps.owasp.score_calculator import ContributionScoreCalculator - - -class Command(BaseCommand): - """Management command for recalculating contributor scores.""" - - help = "Recalculate contributor scores and tier assignments." - - def handle(self, *args, **options) -> None: - """Handle the command execution.""" - self.stdout.write("Starting score recalculation for all users...") - - calculator = ContributionScoreCalculator() - result = calculator.recalculate_all() - - self.stdout.write( - self.style.SUCCESS( - f"Score recalculation complete:\n" - f" - Total users: {result['total']}\n" - f" - Created: {result['created']}\n" - f" - Updated: {result['updated']}\n" - f" - Failed: {result['failed_count']}" - ) - ) - - failed_count = result.get("failed_count", 0) - if failed_count > 0: - failures = result.get("failures", []) - failed_users = [username for username, _ in failures] - failed_str = ", ".join(failed_users) - self.stdout.write( - self.style.WARNING(f"Failed to issue certificates for: {failed_str}") - ) - error_msg = f"Failed to issue certificates for {failed_count} user(s)" - raise CommandError(error_msg) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index b94f11ccc8..ccfe9f35a0 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from django.db.models import Count, Q @@ -205,7 +205,7 @@ def get_tier(self, score: int) -> str: return "level_1" - def recalculate_all(self) -> dict[str, int]: + def recalculate_all(self) -> dict[str, Any]: """Recalculate scores for all users. Returns: From b069ab8a8044a5ba7cfd8a94d1b5530c07fab9b2 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 26 Jun 2026 11:18:37 +0530 Subject: [PATCH 14/15] Address the review --- .../commands/owasp_crp_recalculate_scores.py | 3 +-- backend/apps/owasp/score_calculator.py | 5 +++-- backend/apps/owasp/services/certificate_providers.py | 6 +++--- backend/apps/owasp/services/certificate_service.py | 12 +++++------- backend/settings/base.py | 2 ++ 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py index e0b0cda897..5b502bda87 100644 --- a/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py +++ b/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py @@ -27,8 +27,7 @@ def handle(self, *args, **options) -> None: ) ) - failed_count = result.get("failed_count", 0) - if failed_count > 0: + if failed_count := result.get("failed_count", 0): failures = result.get("failures", []) failed_users = [username for username, _ in failures] failed_str = ", ".join(failed_users) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index ccfe9f35a0..89861d015a 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -11,6 +11,7 @@ from apps.github.models.issue import Issue from apps.github.models.pull_request import PullRequest from apps.github.models.user import User +from apps.owasp.exceptions import CertificateIssuanceError from apps.owasp.models.crp.contribution_score import ContributionScore from apps.owasp.models.crp.recognition_enums import TierChoices from apps.owasp.models.crp.scoring_weight import ScoringWeight @@ -312,7 +313,7 @@ def recalculate_all(self) -> dict[str, Any]: pending_score.value, TierChoices(pending_score.tier), ) - except Exception as e: + except CertificateIssuanceError as e: logger.exception( "Failed to issue certificate for user %s", pending_score.github_user.login, @@ -332,7 +333,7 @@ def recalculate_all(self) -> dict[str, Any]: pending_score.value, TierChoices(pending_score.tier), ) - except Exception as e: + except CertificateIssuanceError as e: logger.exception( "Failed to issue certificate for user %s", pending_score.github_user.login, diff --git a/backend/apps/owasp/services/certificate_providers.py b/backend/apps/owasp/services/certificate_providers.py index 288d881839..78c13d1893 100644 --- a/backend/apps/owasp/services/certificate_providers.py +++ b/backend/apps/owasp/services/certificate_providers.py @@ -18,7 +18,7 @@ class BaseCertificateProvider(ABC): """Abstract base class for certificate providers.""" @abstractmethod - def issue(self, user: User, score: int, tier: TierChoices) -> None: + def issue_certificate(self, user: User, score: int, tier: TierChoices) -> None: """Issue a certificate. Args: @@ -32,7 +32,7 @@ def issue(self, user: User, score: int, tier: TierChoices) -> None: class LocalCertificateProvider(BaseCertificateProvider): """Certificate provider that records certificate metadata in local database.""" - def issue(self, user: User, score: int, tier: TierChoices) -> None: + def issue_certificate(self, user: User, score: int, tier: TierChoices) -> None: """Record the certificate metadata in the local database. Args: @@ -66,7 +66,7 @@ def get_provider(cls) -> BaseCertificateProvider: ValueError: If the configured provider is unknown. """ - provider_type = getattr(settings, "CERTIFICATE_PROVIDER", "local") + provider_type = settings.CERTIFICATE_PROVIDER provider_class = cls.PROVIDERS.get(provider_type) if not provider_class: error_msg = f"Unknown certificate provider: {provider_type}" diff --git a/backend/apps/owasp/services/certificate_service.py b/backend/apps/owasp/services/certificate_service.py index 23f9559f11..2ef43ab7d2 100644 --- a/backend/apps/owasp/services/certificate_service.py +++ b/backend/apps/owasp/services/certificate_service.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from django.db import transaction +from django.db import IntegrityError, transaction from apps.github.models.user import User from apps.owasp.exceptions import CertificateIssuanceError @@ -36,13 +36,11 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: user = User.objects.select_for_update().get(id=user.id) # Check if user already has an active certificate for this specific tier - has_active_cert = Certificate.objects.filter( + if Certificate.objects.filter( github_user=user, tier=tier, is_revoked=False, - ).exists() - - if has_active_cert: + ).exists(): return try: @@ -58,8 +56,8 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: score, ) try: - provider.issue(user, score, tier) - except Exception as e: + provider.issue_certificate(user, score, tier) + except IntegrityError as e: logger.exception( "Failed to issue %s certificate for user %s", tier, diff --git a/backend/settings/base.py b/backend/settings/base.py index 35cb80ddda..8e4571dbde 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -236,6 +236,8 @@ class Base(Configuration): OPEN_AI_SECRET_KEY = values.SecretValue(environ_name="OPEN_AI_SECRET_KEY") + CERTIFICATE_PROVIDER = "local" + SLACK_BOT_TOKEN = values.SecretValue() SLACK_COMMANDS_ENABLED = True SLACK_EVENTS_ENABLED = True From 67388ee53637a8cf04d2661c787b62519fbf6432 Mon Sep 17 00:00:00 2001 From: Anurag Yadav Date: Fri, 26 Jun 2026 13:16:52 +0530 Subject: [PATCH 15/15] Fixed exception --- backend/apps/owasp/score_calculator.py | 6 ++++++ backend/apps/owasp/services/certificate_service.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/apps/owasp/score_calculator.py b/backend/apps/owasp/score_calculator.py index 89861d015a..23df5b40a0 100644 --- a/backend/apps/owasp/score_calculator.py +++ b/backend/apps/owasp/score_calculator.py @@ -319,6 +319,12 @@ def recalculate_all(self) -> dict[str, Any]: pending_score.github_user.login, ) failed_certificates.append((pending_score.github_user.login, e)) + except Exception as e: + logger.exception( + "Unexpected certificate processing error for user %s", + pending_score.github_user.login, + ) + failed_certificates.append((pending_score.github_user.login, e)) pending_certificates.clear() contribution_scores.clear() diff --git a/backend/apps/owasp/services/certificate_service.py b/backend/apps/owasp/services/certificate_service.py index 2ef43ab7d2..42d1ddc3fe 100644 --- a/backend/apps/owasp/services/certificate_service.py +++ b/backend/apps/owasp/services/certificate_service.py @@ -5,7 +5,7 @@ import logging from typing import TYPE_CHECKING -from django.db import IntegrityError, transaction +from django.db import transaction from apps.github.models.user import User from apps.owasp.exceptions import CertificateIssuanceError @@ -57,7 +57,7 @@ def issue_certificate(cls, user: User, score: int, tier: TierChoices) -> None: ) try: provider.issue_certificate(user, score, tier) - except IntegrityError as e: + except Exception as e: logger.exception( "Failed to issue %s certificate for user %s", tier,