From 97880c1fbfb6dec4207d1e9ba22449b418da1fa0 Mon Sep 17 00:00:00 2001 From: Kyrylo Kholodenko Date: Fri, 26 Jan 2024 19:25:27 +0200 Subject: [PATCH] feat: [ACI-387, ACI-393] badge templates synchronization --- .../credly/credly_badges/__init__.py | 1 + .../credly/credly_badges/admin.py | 4 +- .../credly/credly_badges/api_client.py | 132 ++++++++++++++++ .../distribution/credly/credly_badges/apps.py | 2 +- .../distribution/credly/credly_badges/data.py | 21 ++- .../credly/credly_badges/forms.py | 5 +- .../sync_organization_badge_templates.py | 5 +- .../credly/credly_badges/rest_api.py | 142 ++++-------------- .../distribution/credly/credly_badges/urls.py | 7 +- .../credly/credly_badges/utils.py | 49 +++++- 10 files changed, 243 insertions(+), 125 deletions(-) create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/api_client.py diff --git a/credentials/apps/badges/distribution/credly/credly_badges/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/__init__.py index b07e51fdb3..49b144074c 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/__init__.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/__init__.py @@ -4,4 +4,5 @@ from __future__ import unicode_literals + __version__ = "0.0.1" diff --git a/credentials/apps/badges/distribution/credly/credly_badges/admin.py b/credentials/apps/badges/distribution/credly/credly_badges/admin.py index 6601caa789..e87aecfc02 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/admin.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/admin.py @@ -6,9 +6,9 @@ from credentials.apps.badges.toggles import is_badges_enabled -from .models import CredlyOrganization, BadgeTemplate -from .utils import sync_badge_templates_for_organization from .forms import CredlyOrganizationAdminForm +from .models import BadgeTemplate, CredlyOrganization +from .utils import sync_badge_templates_for_organization class CredlyOrganizationAdmin(admin.ModelAdmin): diff --git a/credentials/apps/badges/distribution/credly/credly_badges/api_client.py b/credentials/apps/badges/distribution/credly/credly_badges/api_client.py new file mode 100644 index 0000000000..1e3c809d13 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/api_client.py @@ -0,0 +1,132 @@ +import base64 +import logging +from functools import lru_cache +from urllib.parse import urljoin + +import requests +from attrs import asdict +from django.conf import settings +from requests.exceptions import HTTPError + +from .exceptions import CredlyAPIError + + +logger = logging.getLogger(__name__) + + +class CredlyAPIClient: + """ + A client for interacting with the Credly API. + + This class provides methods for performing various operations on the Credly API, + such as fetching organization details, fetching badge templates, issuing badges, + and revoking badges. + """ + + def __init__(self, organization_id, api_key): + """ + Initializes a CredlyRestAPI object. + + Args: + organization_id (str): ID of the organization. + api_key (str): API key for authentication. + """ + + self.organization_id = organization_id + self.api_key = api_key + self.base_api_url = urljoin(settings.CREDLY_API_BASE_URL, f"organizations/{self.organization_id}/") + + def perform_request(self, method, url_suffix, data=None): + """ + Perform an HTTP request to the specified URL suffix. + + Args: + method (str): HTTP method to use for the request. + url_suffix (str): URL suffix to append to the base Credly API URL. + data (dict, optional): Data to send with the request. + + Returns: + dict: JSON response from the API. + + Raises: + requests.HTTPError: If the API returns an error response. + """ + url = urljoin(self.base_api_url, url_suffix) + response = requests.request(method.upper(), url, headers=self._get_headers(), data=data) + self._raise_for_error(response) + return response.json() + + def fetch_organization(self): + """ + Fetches the organization from the Credly API. + """ + return self.perform_request("get", "") + + def fetch_badge_templates(self): + """ + Fetches the badge templates from the Credly API. + """ + return self.perform_request("get", "badge_templates/") + + def fetch_event_information(self, event_id): + """ + Fetches the event information from the Credly API. + + Args: + event_id (str): ID of the event. + """ + return self.perform_request("get", f"events/{event_id}/") + + def issue_badge(self, issue_badge_data): + """ + Issues a badge using the Credly REST API. + + Args: + issue_badge_data (IssueBadgeData): Data required to issue the badge. + """ + return self.perform_request("post", "badges/", asdict(issue_badge_data)) + + def revoke_badge(self, badge_id): + """ + Revoke a badge with the given badge ID. + + Args: + badge_id (str): ID of the badge to revoke. + """ + return self.perform_request("put", f"badges/{badge_id}/revoke/") + + def _raise_for_error(self, response): + """ + Raises a CredlyAPIError if the response status code indicates an error. + + Args: + response (requests.Response): Response object from the Credly API request. + + Raises: + CredlyAPIError: If the response status code indicates an error. + """ + try: + response.raise_for_status() + except HTTPError: + logger.error(f"Error while processing credly api request: {response.status_code} - {response.text}") + raise CredlyAPIError + + def _get_headers(self): + """ + Returns the headers for making API requests to Credly. + """ + return { + "Accept": "application/json", + "Content-Type": "application/json", + "Authorization": f"Basic {self._build_authorization_token()}", + } + + @lru_cache + def _build_authorization_token(self): + """ + Build the authorization token for the Credly API. + + Returns: + str: Authorization token. + """ + return base64.b64encode(self.api_key.encode("ascii")).decode("ascii") diff --git a/credentials/apps/badges/distribution/credly/credly_badges/apps.py b/credentials/apps/badges/distribution/credly/credly_badges/apps.py index 99d2f685df..e6b4586258 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/apps.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/apps.py @@ -1,5 +1,5 @@ from credentials.apps.badges.apps import BadgesAppConfig -from credentials.apps.badges.toggles import is_badges_enabled, check_badges_enabled +from credentials.apps.badges.toggles import check_badges_enabled, is_badges_enabled from credentials.apps.plugins.constants import PROJECT_TYPE, PluginSettings, PluginURLs, SettingsType diff --git a/credentials/apps/badges/distribution/credly/credly_badges/data.py b/credentials/apps/badges/distribution/credly/credly_badges/data.py index a2d81d4104..6f63ffff62 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/data.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/data.py @@ -1,6 +1,7 @@ -import attr from datetime import datetime +import attr + @attr.s(auto_attribs=True, frozen=True) class IssueBadgeData: @@ -15,8 +16,16 @@ class IssueBadgeData: issued_at (datetime): Timestamp when the badge was issued. """ - recipient_email: attr.ib(type=str) - issued_to_first_name: attr.ib(type=str) - issued_to_last_name: attr.ib(type=str) - badge_template_id: attr.ib(type=str) - issued_at: attr.ib(type=datetime) + recipient_email: str + issued_to_first_name: str + issued_to_last_name: str + badge_template_id: str + issued_at: datetime + + +@attr.s(auto_attribs=True, frozen=True) +class CredlyEventInfoData: + id: str + organization_id: str + event_type: str + occurred_at: datetime diff --git a/credentials/apps/badges/distribution/credly/credly_badges/forms.py b/credentials/apps/badges/distribution/credly/credly_badges/forms.py index 15f320f285..04d6d3a77d 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/forms.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/forms.py @@ -5,9 +5,10 @@ from django import forms from django.utils.translation import gettext_lazy as _ -from .models import CredlyOrganization -from .rest_api import CredlyAPIClient +from .api_client import CredlyAPIClient from .exceptions import CredlyAPIError +from .models import CredlyOrganization + class CredlyOrganizationAdminForm(forms.ModelForm): class Meta: diff --git a/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py index a1330f91c2..91aeba7761 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py @@ -1,7 +1,8 @@ import logging -from django.core.management.base import BaseCommand -from credly_badges.utils import sync_badge_templates_for_organization + from credly_badges.models import CredlyOrganization +from credly_badges.utils import sync_badge_templates_for_organization +from django.core.management.base import BaseCommand logger = logging.getLogger(__name__) diff --git a/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py b/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py index c434edbea3..7d1a74b46d 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py @@ -1,124 +1,48 @@ -import base64 import logging -from functools import lru_cache -from urllib.parse import urljoin -import requests -from attrs import asdict -from django.conf import settings -from requests.exceptions import HTTPError -from .exceptions import CredlyAPIError +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .api_client import CredlyAPIClient +from .data import CredlyEventInfoData +from .models import CredlyOrganization +from .utils import ( + handle_badge_template_changed_event, + handle_badge_template_created_event, + handle_badge_template_deleted_event, +) logger = logging.getLogger(__name__) -class CredlyAPIClient: +class CredlyWebhook(APIView): """ - A client for interacting with the Credly API. - - This class provides methods for performing various operations on the Credly API, - such as fetching organization details, fetching badge templates, issuing badges, - and revoking badges. + Public API that handle Credly webhooks. - TODO: improve client to return data in a more usable format + Usage: + POST /edx_badges/api/credly/v1/webhook """ - def __init__(self, organization_id, api_key): - """ - Initializes a CredlyRestAPI object. - - Args: - organization_id (str): ID of the organization. - api_key (str): API key for authentication. - """ - - self.organization_id = organization_id - self.api_key = api_key - self.base_api_url = urljoin(settings.CREDLY_API_BASE_URL, f"organizations/{self.organization_id}/") - - def perform_request(self, method, url_suffix, data=None): - """ - Perform an HTTP request to the specified URL suffix. - - Args: - method (str): HTTP method to use for the request. - url_suffix (str): URL suffix to append to the base Credly API URL. - data (dict, optional): Data to send with the request. - - Returns: - dict: JSON response from the API. - - Raises: - requests.HTTPError: If the API returns an error response. - """ - url = urljoin(self.base_api_url, url_suffix) - response = requests.request(method.upper(), url, headers=self._get_headers(), data=data) - self._raise_for_error(response) - return response.json() - - def fetch_organization(self): - """ - Fetches the organization from the Credly API. - """ - return self.perform_request("get", "") - - def fetch_badge_templates(self): - """ - Fetches the badge templates from the Credly API. - """ - return self.perform_request("get", "badge_templates/") - - def issue_badge(self, issue_badge_data): - """ - Issues a badge using the Credly REST API. - - Args: - issue_badge_data (IssueBadgeData): Data required to issue the badge. - """ - return self.perform_request("post", "badges/", asdict(issue_badge_data)) - - def revoke_badge(self, badge_id): - """ - Revoke a badge with the given badge ID. - - Args: - badge_id (str): ID of the badge to revoke. - """ - return self.perform_request("put", f"badges/{badge_id}/revoke/") - - def _raise_for_error(self, response): - """ - Raises a CredlyAPIError if the response status code indicates an error. - - Args: - response (requests.Response): Response object from the Credly API request. + authentication_classes = [] + permission_classes = [] - Raises: - CredlyAPIError: If the response status code indicates an error. - """ - try: - response.raise_for_status() - except HTTPError: - logger.error(f"Error while processing credly api request: {response.status_code} - {response.text}") - raise CredlyAPIError + def post(self, request): + event_info_data = CredlyEventInfoData(**request.data) + organization = get_object_or_404(CredlyOrganization, uuid=event_info_data.organization_id) + credly_api_client = CredlyAPIClient(organization.uuid, organization.api_key) - def _get_headers(self): - """ - Returns the headers for making API requests to Credly. - """ - return { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": f"Basic {self._build_authorization_token()}", - } + event_info_response = credly_api_client.fetch_event_information(event_info_data.id) - @lru_cache - def _build_authorization_token(self): - """ - Build the authorization token for the Credly API. + if event_info_data.event_type == "badge_template.created": + handle_badge_template_created_event(event_info_response) + elif event_info_data.event_type == "badge_template.changed": + handle_badge_template_changed_event(event_info_response) + elif event_info_data.event_type == "badge_template.deleted": + handle_badge_template_deleted_event(event_info_response) + else: + logger.error(f"Unknown event type: {event_info_data.event_type}") - Returns: - str: Authorization token. - """ - return base64.b64encode(self.api_key.encode("ascii")).decode("ascii") + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/credentials/apps/badges/distribution/credly/credly_badges/urls.py b/credentials/apps/badges/distribution/credly/credly_badges/urls.py index ef087ccde7..2039388bf4 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/urls.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/urls.py @@ -2,11 +2,16 @@ Credly Badges routing configuration. """ +from django.urls import path + from credentials.apps.badges.toggles import is_badges_enabled +from .rest_api import CredlyWebhook + + urlpatterns = [] if is_badges_enabled(): urlpatterns = [ - # Define urls here + path('api/webhook/', CredlyWebhook.as_view(), name='credly-webhook'), ] \ No newline at end of file diff --git a/credentials/apps/badges/distribution/credly/credly_badges/utils.py b/credentials/apps/badges/distribution/credly/credly_badges/utils.py index 090934a49c..cf492b2302 100644 --- a/credentials/apps/badges/distribution/credly/credly_badges/utils.py +++ b/credentials/apps/badges/distribution/credly/credly_badges/utils.py @@ -1,7 +1,7 @@ from django.shortcuts import get_object_or_404 -from .rest_api import CredlyAPIClient -from .models import CredlyOrganization, BadgeTemplate +from .api_client import CredlyAPIClient +from .models import BadgeTemplate, CredlyOrganization def sync_badge_templates_for_organization(organization_id): @@ -13,6 +13,8 @@ def sync_badge_templates_for_organization(organization_id): Raises: Http404: If organization is not found. + + TODO: define and delete badge templates which was deleted on credly but still exists in our database """ organization = get_object_or_404(CredlyOrganization, uuid=organization_id) @@ -27,3 +29,46 @@ def sync_badge_templates_for_organization(organization_id): 'organization': organization, } ) + + +def handle_badge_template_created_event(data): + """ + Create a new badge template. + """ + badge_template = data.get('data', {}).get('badge_template', {}) + owner = data.get('data', {}).get('badge_template', {}).get('owner', {}) + + organization = get_object_or_404(CredlyOrganization, uuid=owner.get('id')) + + BadgeTemplate.objects.update_or_create( + uuid=badge_template.get('id'), + defaults={ + 'name': badge_template.get('name'), + 'organization': organization, + } + ) + + +def handle_badge_template_changed_event(data): + """ + Change the badge template. + """ + badge_template = data.get('data', {}).get('badge_template', {}) + owner = data.get('data', {}).get('badge_template', {}).get('owner', {}) + + organization = get_object_or_404(CredlyOrganization, uuid=owner.get('id')) + + BadgeTemplate.objects.update_or_create( + uuid=badge_template.get('id'), + defaults={ + 'name': badge_template.get('name'), + 'organization': organization, + } + ) + + +def handle_badge_template_deleted_event(data): + """ + Deletes the badge template by provided uuid. + """ + BadgeTemplate.objects.filter(uuid=data.get('data', {}).get('badge_template', {}).get('id')).delete()