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/management/commands/owasp_crp_recalculate_scores.py b/backend/apps/owasp/management/commands/owasp_crp_recalculate_scores.py index 7f982afae8..5b502bda87 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,17 @@ 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']}" ) ) + + if failed_count := result.get("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 7086a0264e..23df5b40a0 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,8 +11,11 @@ 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 +from apps.owasp.services.certificate_service import CertificateService if TYPE_CHECKING: from datetime import date @@ -203,7 +206,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: @@ -269,6 +272,8 @@ def recalculate_all(self) -> dict[str, int]: ) contribution_scores = [] + pending_certificates = [] + failed_certificates: list[tuple[str, Exception]] = [] for user in users_with_contributions: counts = { @@ -295,16 +300,52 @@ 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: + try: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + except CertificateIssuanceError 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)) + 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() if contribution_scores: BulkSaveModel.bulk_save( ContributionScore, contribution_scores, fields=["value", "tier"] ) + for pending_score in pending_certificates: + try: + CertificateService.issue_certificate( + pending_score.github_user, + pending_score.value, + TierChoices(pending_score.tier), + ) + except CertificateIssuanceError 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() logger.info( @@ -317,6 +358,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]: @@ -340,6 +383,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/__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 new file mode 100644 index 0000000000..78c13d1893 --- /dev/null +++ b/backend/apps/owasp/services/certificate_providers.py @@ -0,0 +1,75 @@ +"""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 + +if TYPE_CHECKING: + from apps.github.models.user import User + from apps.owasp.models.crp.recognition_enums import TierChoices + + +class BaseCertificateProvider(ABC): + """Abstract base class for certificate providers.""" + + @abstractmethod + def issue_certificate(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_certificate(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 = settings.CERTIFICATE_PROVIDER + provider_class = cls.PROVIDERS.get(provider_type) + if not provider_class: + 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 new file mode 100644 index 0000000000..42d1ddc3fe --- /dev/null +++ b/backend/apps/owasp/services/certificate_service.py @@ -0,0 +1,66 @@ +"""Certificate service layer for Contributor Recognition Program.""" + +from __future__ import annotations + +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.owasp.models.crp.recognition_enums import TierChoices + +logger = logging.getLogger(__name__) + + +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. + + 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. + + """ + # 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 + if Certificate.objects.filter( + github_user=user, + tier=tier, + is_revoked=False, + ).exists(): + return + + try: + provider = CertificateProviderFactory.get_provider() + except ValueError as e: + logger.exception("Failed to resolve certificate provider") + raise CertificateIssuanceError from e + + logger.info( + "Issuing %s certificate to user %s with score %s", + tier, + user.login, + score, + ) + try: + provider.issue_certificate(user, score, tier) + except Exception as e: + logger.exception( + "Failed to issue %s certificate for user %s", + tier, + user.login, + ) + raise CertificateIssuanceError from e 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