Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [AXM-1249] implement accredible issuer level #188

Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 2 additions & 0 deletions credentials/apps/badges/accredible/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ def sync_groups(self, site_id: int) -> int:
"name": raw_group.get("course_name"),
"description": raw_group.get("course_description"),
"icon": self.fetch_design_image(raw_group.get("primary_design_id")),
"created": raw_group.get("created_at"),
"state": AccredibleGroup.STATES.active,
},
)

Expand Down
2 changes: 0 additions & 2 deletions credentials/apps/badges/accredible/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,13 @@ class AccredibleCredential:
recipient (RecipientData): Information about the recipient.
group_id (int): ID of the credential group.
name (str): Title of the credential.
description (str): Description of the credential.
issued_on (datetime): Date when the credential was issued.
complete (bool): Whether the credential process is complete.
"""

recipient: AccredibleRecipient
group_id: int
name: str
description: str
issued_on: datetime
complete: bool

Expand Down
121 changes: 118 additions & 3 deletions credentials/apps/badges/issuers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,30 @@
This module provides classes for issuing badge credentials to users.
"""

from datetime import datetime
from django.contrib.contenttypes.models import ContentType
from django.db import transaction
from django.utils.translation import gettext as _

from credentials.apps.badges.accredible.api_client import AccredibleAPIClient
from credentials.apps.badges.accredible.data import(
AccredibleRecipient,
AccredibleCredential,
AccredibleBadgeData,
AccredibleExpireBadgeData,
AccredibleExpiredCredential,
)
from credentials.apps.badges.credly.api_client import CredlyAPIClient
from credentials.apps.badges.credly.data import CredlyBadgeData
from credentials.apps.badges.credly.exceptions import CredlyAPIError
from credentials.apps.badges.exceptions import BadgeProviderError
from credentials.apps.badges.models import BadgeTemplate, CredlyBadge, CredlyBadgeTemplate, UserCredential
from credentials.apps.badges.models import (
BadgeTemplate,
CredlyBadge,
CredlyBadgeTemplate,
UserCredential,
AccredibleGroup,
AccredibleBadge
)
from credentials.apps.badges.signals.signals import notify_badge_awarded, notify_badge_revoked
from credentials.apps.core.api import get_user_by_username
from credentials.apps.credentials.constants import UserCredentialStatus
Expand Down Expand Up @@ -155,7 +170,7 @@ def revoke_credly_badge(self, credential_id, user_credential):
}
try:
response = credly_api.revoke_badge(user_credential.external_uuid, revoke_data)
except CredlyAPIError:
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise
Expand Down Expand Up @@ -197,3 +212,103 @@ def revoke(self, credential_id, username):
if user_credential.propagated:
self.revoke_credly_badge(credential_id, user_credential)
return user_credential



class AccredibleBadgeTemplateIssuer(BadgeTemplateIssuer):
"""
Issues AccredibleGroup credentials to users.
"""

issued_credential_type = AccredibleGroup
issued_user_credential_type = AccredibleBadge

def issue_accredible_badge(self, *, user_credential):
"""
Requests Accredible service for external badge issuing based on internal user credential (AccredibleBadge).
"""

user = get_user_by_username(user_credential.username)
group = user_credential.credential

accredible_badge_data = AccredibleBadgeData(
credential=AccredibleCredential(
recipient=AccredibleRecipient(
name=user.get_full_name() or user.username,
email=user.email,
),
group_id=group.id,
name=group.name,
issued_on=user_credential.created.strftime("%Y-%m-%d %H:%M:%S %z"),
complete=True,
)
)

try:
accredible_api = AccredibleAPIClient(group.api_config.id)
response = accredible_api.issue_badge(accredible_badge_data)
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise

user_credential.external_id = response.get("credential").get("id")
user_credential.state = AccredibleBadge.STATES.accepted
user_credential.save()

def revoke_accredible_badge(self, credential_id, user_credential):
"""
Requests Accredible service for external badge expiring based on internal user credential (AccredibleBadge).
"""

credential = self.get_credential(credential_id)
accredible_api_client = AccredibleAPIClient(credential.api_config.id)
revoke_badge_data = AccredibleExpireBadgeData(
credential=AccredibleExpiredCredential(expired_on=datetime.now().strftime("%Y-%m-%d %H:%M:%S %z"))
)

try:
accredible_api_client.revoke_badge(user_credential.external_id, revoke_badge_data)
except BadgeProviderError:
user_credential.state = "error"
user_credential.save()
raise

user_credential.state = AccredibleBadge.STATES.expired
user_credential.save()


def award(self, *, username, credential_id):
"""
Awards a Accredible badge.

- Creates user credential record for the group, for a given user;
- Notifies about the awarded badge (public signal);
- Issues external Accredible badge (Accredible API);

Returns: (AccredibleBadge) user credential
"""

accredible_badge = super().award(username=username, credential_id=credential_id)

# do not issue new badges if the badge was issued already
if not accredible_badge.propagated:
self.issue_accredible_badge(user_credential=accredible_badge)

return accredible_badge

def revoke(self, credential_id, username):
"""
Revokes a Accredible badge.

- Changes user credential status to REVOKED, for a given user;
- Notifies about the revoked badge (public signal);
- Expire external Accredible badge (Accredible API);

Returns: (AccredibleBadge) user credential
"""

user_credential = super().revoke(credential_id, username)
if user_credential.propagated:
self.revoke_accredible_badge(credential_id, user_credential)
return user_credential
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.17 on 2024-12-23 15:08

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("badges", "0002_accredibleapiconfig_accrediblebadge_and_more"),
]

operations = [
migrations.AlterField(
model_name="accredibleapiconfig",
name="name",
field=models.CharField(help_text="Accredible API configuration name.", max_length=255, null=True),
),
]
49 changes: 44 additions & 5 deletions credentials/apps/badges/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,7 +538,7 @@ def groups(self):

return {
group: BadgeRequirement.is_group_fulfilled(group=group, template=self.template, username=self.username)
for group in self.template.groups
for group in getattr(self.template, "groups", [])
}

@property
Expand All @@ -553,15 +553,14 @@ def progress(self):
"""
Notify about the progress.
"""

notify_progress_complete(self, self.username, self.template.id)
notify_progress_complete(self, self.username, self.template.id, self.template.origin)

def regress(self):
"""
Notify about the regression.
"""

notify_progress_incomplete(self, self.username, self.template.id)
notify_progress_incomplete(self, self.username, self.template.id, self.template.origin)

def reset(self):
"""
Expand Down Expand Up @@ -684,7 +683,7 @@ class AccredibleAPIConfig(TimeStampedModel):
Accredible API configuration.
"""

name = models.CharField(max_length=255, help_text=_("Accredible API configuration name."), null=True, blank=True)
name = models.CharField(max_length=255, help_text=_("Accredible API configuration name."), null=True, blank=False)
kyrylo-kh marked this conversation as resolved.
Show resolved Hide resolved
api_key = models.CharField(max_length=255, help_text=_("Accredible API key."))

@classmethod
Expand All @@ -694,6 +693,7 @@ def get_all_api_config_ids(cls):
"""
return list(cls.objects.values_list("id", flat=True))


class AccredibleGroup(BadgeTemplate):
"""
Accredible badge group credential type.
Expand Down Expand Up @@ -752,3 +752,42 @@ class AccredibleBadge(UserCredential):
unique=True,
help_text=_("Accredible service badge identifier"),
)


def as_badge_data(self) -> BadgeData:
"""
Represents itself as a BadgeData instance.
"""

user = get_user_by_username(self.username)
group = self.credential

badge_data = BadgeData(
uuid=str(self.uuid),
user=UserData(
pii=UserPersonalData(
username=self.username,
email=user.email,
name=user.get_full_name(),
),
id=user.lms_user_id,
is_active=user.is_active,
),
template=BadgeTemplateData(
uuid=str(group.uuid),
origin=group.origin,
name=group.name,
description=group.description,
image_url=str(group.icon),
),
)

return badge_data

@property
def propagated(self):
"""
Checks if this user credential already has issued (external) Accredible badge.
"""

return self.external_id and (self.state in self.ISSUING_STATES)
18 changes: 12 additions & 6 deletions credentials/apps/badges/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from django.dispatch import receiver
from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals

from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer
from credentials.apps.badges.models import BadgeProgress
from credentials.apps.badges.issuers import CredlyBadgeTemplateIssuer, AccredibleBadgeTemplateIssuer
from credentials.apps.badges.models import BadgeProgress, CredlyBadgeTemplate, AccredibleGroup
from credentials.apps.badges.processing.generic import process_event
from credentials.apps.badges.signals import (
BADGE_PROGRESS_COMPLETE,
Expand Down Expand Up @@ -63,7 +63,7 @@ def handle_requirement_regressed(sender, username, **kwargs):


@receiver(BADGE_PROGRESS_COMPLETE)
def handle_badge_completion(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
def handle_badge_completion(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument
"""
Fires once ALL requirements for a badge template were marked as "done".

Expand All @@ -73,16 +73,22 @@ def handle_badge_completion(sender, username, badge_template_id, **kwargs): # p

logger.debug("BADGES: progress is complete for %s on the %s", username, badge_template_id)

CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)
if origin == CredlyBadgeTemplate.ORIGIN:
CredlyBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)
elif origin == AccredibleGroup.ORIGIN:
AccredibleBadgeTemplateIssuer().award(username=username, credential_id=badge_template_id)


@receiver(BADGE_PROGRESS_INCOMPLETE)
def handle_badge_regression(sender, username, badge_template_id, **kwargs): # pylint: disable=unused-argument
def handle_badge_regression(sender, username, badge_template_id, origin, **kwargs): # pylint: disable=unused-argument
"""
On user's Badge regression (incompletion).

- username
- badge template ID
"""

CredlyBadgeTemplateIssuer().revoke(badge_template_id, username)
if origin == CredlyBadgeTemplate.ORIGIN:
CredlyBadgeTemplateIssuer().revoke(badge_template_id, username)
elif origin == AccredibleGroup.ORIGIN:
AccredibleBadgeTemplateIssuer().revoke(badge_template_id, username)
6 changes: 4 additions & 2 deletions credentials/apps/badges/signals/signals.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def notify_requirement_regressed(*, sender, username, badge_template_id):
)


def notify_progress_complete(sender, username, badge_template_id):
def notify_progress_complete(sender, username, badge_template_id, origin):
"""
Notifies about user's completion on the badge template.
"""
Expand All @@ -57,17 +57,19 @@ def notify_progress_complete(sender, username, badge_template_id):
sender=sender,
username=username,
badge_template_id=badge_template_id,
origin=origin,
)


def notify_progress_incomplete(sender, username, badge_template_id):
def notify_progress_incomplete(sender, username, badge_template_id, origin):
"""
Notifies about user's regression on the badge template.
"""
BADGE_PROGRESS_INCOMPLETE.send(
sender=sender,
username=username,
badge_template_id=badge_template_id,
origin=origin,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ def setUp(self):
recipient=AccredibleRecipient(name="Test name", email="test_name@test.com"),
group_id=123,
name="Test Badge",
description="Test Badge Description",
issued_on="2021-01-01 00:00:00 +0000",
complete=True,
)
Expand Down
10 changes: 5 additions & 5 deletions credentials/apps/badges/tests/test_admin_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
DataRuleExtensionsMixin,
ParentMixin,
)
from credentials.apps.badges.credly.exceptions import CredlyAPIError
from credentials.apps.badges.exceptions import BadgeProviderError
from credentials.apps.badges.models import BadgeRequirement, BadgeTemplate


Expand Down Expand Up @@ -132,7 +132,7 @@ def test_clean_with_invalid_organization(self):
) as mock_get_orgs:
mock_get_orgs.return_value = {"test_uuid": "test_org"}

with self.assertRaises(forms.ValidationError) as cm:
with self.assertRaises(BadgeProviderError) as cm:
form.clean()

self.assertIn("You specified an invalid authorization token.", str(cm.exception))
Expand Down Expand Up @@ -170,13 +170,13 @@ def test_ensure_organization_exists(self):
def test_ensure_organization_exists_with_error(self):
form = CredlyOrganizationAdminForm()
api_client = MagicMock()
api_client.fetch_organization.side_effect = CredlyAPIError("API Error")
api_client.fetch_organization.side_effect = BadgeProviderError("API Error")

with self.assertRaises(forms.ValidationError) as cm:
with self.assertRaises(BadgeProviderError) as cm:
form.ensure_organization_exists(api_client)

api_client.fetch_organization.assert_called_once()
self.assertEqual(str(cm.exception), "['API Error']")
self.assertEqual(str(cm.exception), "API Error")


class TestParentMixin(ParentMixin):
Expand Down
Loading
Loading