Skip to content

Commit

Permalink
feat: [AXM-1227, AXM-1235, AXM-1242] accredible models, client and sy…
Browse files Browse the repository at this point in the history
…ncing groups
  • Loading branch information
kyrylo-kh committed Dec 18, 2024
1 parent da2b598 commit 1c39ead
Show file tree
Hide file tree
Showing 8 changed files with 610 additions and 0 deletions.
109 changes: 109 additions & 0 deletions credentials/apps/badges/accredible/api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import logging
from django.conf import settings
from django.contrib.sites.models import Site
from attrs import asdict

from credentials.apps.badges.models import AccredibleAPIConfig, AccredibleGroup
from credentials.apps.badges.base_api_client import BaseBadgeProviderClient
from credentials.apps.badges.accredible.data import AccredibleBadgeData, AccredibleExpireBadgeData
from credentials.apps.badges.accredible.utils import get_accredible_api_base_url


logger = logging.getLogger(__name__)


class AccredibleAPIClient(BaseBadgeProviderClient):
"""
A client for interacting with the Accredible API.
This class provides methods for performing various operations on the Accredible API.
"""
PROVIDER_NAME = "Accredible"

def __init__(self, api_config: AccredibleAPIConfig):
"""
Initializes a AccredibleAPIClient object.
Args:
api_config (AccredibleAPIConfig): Configuration object for the Accredible API.
"""
self.api_config = api_config

def _get_base_api_url(self) -> str:
return get_accredible_api_base_url(settings)

def _get_headers(self) -> dict:
"""
Returns the headers for making API requests to Credly.
"""
return {
"Accept": "application/json",
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_config.api_key}",
}

def fetch_all_groups(self) -> dict:
"""
Fetch all groups.
"""
return self.perform_request("get", "issuer/all_groups")

def fetch_design_image(self, design_id: int) -> str:
"""
Fetches the design and return the URL of image.
"""
design_raw = self.perform_request("get", f"designs/{design_id}")
return design_raw.get("design", {}).get("rasterized_content_url")

def issue_badge(self, issue_badge_data: AccredibleBadgeData) -> dict:
"""
Issues a badge using the Accredible REST API.
Args:
issue_badge_data (IssueBadgeData): Data required to issue the badge.
"""
return self.perform_request("post", "credentials", asdict(issue_badge_data))

def revoke_badge(self, badge_id, data: AccredibleExpireBadgeData) -> dict:
"""
Revoke a badge with the given badge ID.
Args:
badge_id (str): ID of the badge to revoke.
data (dict): Additional data for the revocation.
"""
return self.perform_request("patch", f"credentials/{badge_id}", asdict(data))

def sync_groups(self, site_id: int) -> int:
"""
Pull all groups for a given Accredible API config.
Args:
site_id (int): ID of the site.
Returns:
int | None: processed items.
"""
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
logger.error(f"Site with the id {site_id} does not exist!")
raise

groups_data = self.fetch_all_groups()
raw_groups = groups_data.get("groups", [])

for raw_group in raw_groups:
AccredibleGroup.objects.update_or_create(
id=raw_group.get("id"),
api_config=self.api_config,
defaults={
"site": site,
"name": raw_group.get("course_name"),
"state": AccredibleGroup.STATES.active,
"description": raw_group.get("course_description"),
"icon": self.fetch_design_image(raw_group.get("primary_design_id")),
},
)

return len(raw_groups)
57 changes: 57 additions & 0 deletions credentials/apps/badges/accredible/data.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import attr
from datetime import datetime


@attr.s(auto_attribs=True, frozen=True)
class AccredibleRecipient:
"""
Represents the recipient data in the credential.
Attributes:
name (str): The recipient's name.
email (str): The recipient's email address.
"""
name: str
email: str

@attr.s(auto_attribs=True, frozen=True)
class AccredibleCredential:
"""
Represents the credential data.
Attributes:
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

@attr.s(auto_attribs=True, frozen=True)
class AccredibleExpiredCredential:
"""
Represents the data required to expire a credential.
"""
expired_on: datetime

@attr.s(auto_attribs=True, frozen=True)
class AccredibleBadgeData:
"""
Represents the data required to issue a badge.
"""
credential: AccredibleCredential

@attr.s(auto_attribs=True, frozen=True)
class AccredibleExpireBadgeData:
"""
Represents the data required to expire a badge.
"""
credential: AccredibleExpiredCredential
20 changes: 20 additions & 0 deletions credentials/apps/badges/accredible/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
def get_accredible_api_base_url(settings):
"""
Determines the base URL for the Accredible service based on application settings.
Parameters:
- settings: A configuration object containing the application's settings.
Returns:
- str: The base URL for the Accredible service (web site).
This will be the URL for the sandbox environment if `USE_SANDBOX` is
set to a truthy value in the configuration;
otherwise, it will be the production environment's URL.
"""

accredible_config = settings.BADGES_CONFIG["accredible"]

if accredible_config.get("USE_SANDBOX"):
return accredible_config["ACCREDIBLE_SANDBOX_API_BASE_URL"]

return accredible_config["ACCREDIBLE_API_BASE_URL"]
175 changes: 175 additions & 0 deletions credentials/apps/badges/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@
DataRule,
Fulfillment,
PenaltyDataRule,
AccredibleAPIConfig,
AccredibleBadge,
AccredibleGroup,
)
from credentials.apps.badges.toggles import is_badges_enabled

Expand Down Expand Up @@ -541,6 +544,175 @@ def has_add_permission(self, request):
return False


class AccredibleAPIConfigAdmin(admin.ModelAdmin):
"""
Accredible API configuration admin setup.
"""

list_display = (
"id",
"name",
)
actions = ("sync_groups",)

@admin.action(description="Sync groups")
def sync_groups(self, request, queryset):
"""
Sync groups for selected api configs.
"""
site = get_current_site(request)
for api_config in queryset:
call_command(
"sync_accredible_groups",
api_config_id=api_config.id,
site_id=site.id,
)

messages.success(request, _("Accredible groups were successfully updated."))


class AccredibleBadgeAdmin(admin.ModelAdmin):
"""
Accredible badge admin setup.
"""

list_display = (
"uuid",
"username",
"credential",
"status",
"state",
"external_id",
)
list_filter = (
"status",
"state",
)
search_fields = (
"username",
"external_id",
)
readonly_fields = (
"credential_id",
"credential_content_type",
"username",
"download_url",
"state",
"uuid",
"external_id",
)

def has_add_permission(self, request):
return False


class AccredibleGroupAdmin(admin.ModelAdmin):
"""
Accredible group admin setup.
"""

list_display = (
"id",
"api_config",
"name",
"state",
"is_active",
"image",
)
list_filter = (
"api_config",
"is_active",
"state",
)
search_fields = (
"name",
"id",
)
readonly_fields = [
"state",
"origin",
"dashboard_link",
"image",
]
fieldsets = (
(
"Generic",
{
"fields": (
"site",
"is_active",
),
"description": _(
"""
WARNING: avoid configuration updates on activated badges.
Active badge templates are continuously processed and learners may already have progress on them.
Any changes in badge template requirements (including data rules) will affect learners' experience!
"""
),
},
),
(
"Badge template",
{
"fields": (
"name",
"description",
"image",
"origin",
)
},
),
(
"Accredible",
{
"fields": (
"api_config",
"state",
"dashboard_link",
),
},
),
)
inlines = [
BadgeRequirementInline,
BadgePenaltyInline,
]

def has_add_permission(self, request):
return False

def dashboard_link(self, obj):
url = obj.management_url
return format_html("<a href='{url}'>{url}</a>", url=url)

def delete_model(self, request, obj):
"""
Prevent deletion of active badge templates.
"""
if obj.is_active:
messages.set_level(request, messages.ERROR)
messages.error(request, _("Active badge template cannot be deleted."))
return
super().delete_model(request, obj)

def delete_queryset(self, request, queryset):
"""
Prevent deletion of active badge templates.
"""
if queryset.filter(is_active=True).exists():
messages.set_level(request, messages.ERROR)
messages.error(request, _("Active badge templates cannot be deleted."))
return
super().delete_queryset(request, queryset)

def image(self, obj):
"""
Badge template preview image.
"""
if obj.icon:
return format_html('<img src="{}" width="50" height="auto" />', obj.icon)
return None

# register admin configurations with respect to the feature flag
if is_badges_enabled():
admin.site.register(CredlyOrganization, CredlyOrganizationAdmin)
Expand All @@ -549,3 +721,6 @@ def has_add_permission(self, request):
admin.site.register(BadgeRequirement, BadgeRequirementAdmin)
admin.site.register(BadgePenalty, BadgePenaltyAdmin)
admin.site.register(BadgeProgress, BadgeProgressAdmin)
admin.site.register(AccredibleAPIConfig, AccredibleAPIConfigAdmin)
admin.site.register(AccredibleBadge, AccredibleBadgeAdmin)
admin.site.register(AccredibleGroup, AccredibleGroupAdmin)
Loading

0 comments on commit 1c39ead

Please sign in to comment.