Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions backend/apps/owasp/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@

class SnapshotProcessingError(Exception):
"""Exception raised for errors in snapshot processing."""


class CertificateIssuanceError(Exception):
"""Exception raised for errors in certificate issuance."""
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
43 changes: 41 additions & 2 deletions backend/apps/owasp/score_calculator.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from django.db.models import Count, 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
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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
counts = {
Expand All @@ -295,16 +300,46 @@ 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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
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 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(
Expand All @@ -317,6 +352,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]:
Expand All @@ -340,6 +377,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,
Expand Down
1 change: 1 addition & 0 deletions backend/apps/owasp/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Service layer for Contributor Recognition Program."""
75 changes: 75 additions & 0 deletions backend/apps/owasp/services/certificate_providers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"""Certificate provider classes for Contributor Recognition Program."""
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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()
66 changes: 66 additions & 0 deletions backend/apps/owasp/services/certificate_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Certificate service layer for Contributor Recognition Program."""
Comment thread
anurag2787 marked this conversation as resolved.

from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from django.db import IntegrityError, 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 IntegrityError as e:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Outdated
logger.exception(
"Failed to issue %s certificate for user %s",
tier,
user.login,
)
raise CertificateIssuanceError from e
2 changes: 2 additions & 0 deletions backend/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading