From 78f6d6920c41bb6de0cfa4d5a79b20d43ac0721f Mon Sep 17 00:00:00 2001 From: wowkalucky Date: Wed, 13 Mar 2024 17:12:47 +0200 Subject: [PATCH] feat: [badges] add Badges --- credentials/apps/badges/__init__.py | 0 credentials/apps/badges/admin.py | 117 ++++++++++ credentials/apps/badges/apps.py | 39 ++++ credentials/apps/badges/checks.py | 32 +++ .../badges/distribution/credly/LICENSE.txt | 202 ++++++++++++++++++ .../badges/distribution/credly/README.rst | 0 .../credly/credly_badges/__init__.py | 8 + .../credly/credly_badges/admin.py | 68 ++++++ .../credly/credly_badges/api_client.py | 132 ++++++++++++ .../distribution/credly/credly_badges/apps.py | 51 +++++ .../credly/credly_badges/checks.py | 3 + .../distribution/credly/credly_badges/data.py | 23 ++ .../credly/credly_badges/exceptions.py | 6 + .../credly/credly_badges/forms.py | 60 ++++++ .../credly_badges/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../sync_organization_badge_templates.py | 38 ++++ .../credly_badges/migrations/0001_initial.py | 34 +++ ...ter_credlyorganization_api_key_and_more.py | 41 ++++ ...0003_credlybadgetemplate_state_and_more.py | 64 ++++++ .../credly_badges/migrations/__init__.py | 0 .../credly/credly_badges/models.py | 58 +++++ .../credly/credly_badges/rest_api.py | 65 ++++++ .../credly/credly_badges/settings/__init__.py | 0 .../credly/credly_badges/settings/base.py | 7 + .../credly_badges/settings/production.py | 7 + .../credly/credly_badges/settings/test.py | 7 + .../credly/credly_badges/signals/__init__.py | 0 .../credly/credly_badges/signals/handlers.py | 3 + .../distribution/credly/credly_badges/urls.py | 17 ++ .../credly/credly_badges/utils.py | 93 ++++++++ .../apps/badges/distribution/credly/docs | 0 .../badges/distribution/credly/pyproject.toml | 77 +++++++ .../distribution/credly/requirements/base.in | 1 + .../distribution/credly/requirements/base.txt | 0 .../apps/badges/migrations/0001_initial.py | 85 ++++++++ .../apps/badges/migrations/__init__.py | 0 credentials/apps/badges/models.py | 156 ++++++++++++++ credentials/apps/badges/processing.py | 56 +++++ credentials/apps/badges/signals/__init__.py | 0 credentials/apps/badges/signals/handlers.py | 36 ++++ credentials/apps/badges/signals/signals.py | 6 + credentials/apps/badges/tests/__init__.py | 0 credentials/apps/badges/toggles.py | 34 +++ credentials/apps/badges/urls.py | 12 ++ credentials/apps/badges/utils.py | 8 + ..._usercredential_credential_content_type.py | 20 ++ credentials/apps/credentials/models.py | 2 +- credentials/settings/base.py | 31 +++ requirements/all.txt | 9 + requirements/base.in | 3 + requirements/base.txt | 5 + requirements/common_constraints.txt | 5 + requirements/dev.txt | 5 + requirements/production.txt | 5 + requirements/test.txt | 5 + 56 files changed, 1735 insertions(+), 1 deletion(-) create mode 100644 credentials/apps/badges/__init__.py create mode 100644 credentials/apps/badges/admin.py create mode 100644 credentials/apps/badges/apps.py create mode 100644 credentials/apps/badges/checks.py create mode 100644 credentials/apps/badges/distribution/credly/LICENSE.txt create mode 100644 credentials/apps/badges/distribution/credly/README.rst create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/admin.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/api_client.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/apps.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/checks.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/data.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/exceptions.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/forms.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/management/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/management/commands/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/migrations/0001_initial.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/migrations/0002_alter_credlyorganization_api_key_and_more.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/migrations/0003_credlybadgetemplate_state_and_more.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/migrations/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/models.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/rest_api.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/settings/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/settings/base.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/settings/production.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/settings/test.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/signals/__init__.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/signals/handlers.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/urls.py create mode 100644 credentials/apps/badges/distribution/credly/credly_badges/utils.py create mode 100644 credentials/apps/badges/distribution/credly/docs create mode 100644 credentials/apps/badges/distribution/credly/pyproject.toml create mode 100644 credentials/apps/badges/distribution/credly/requirements/base.in create mode 100644 credentials/apps/badges/distribution/credly/requirements/base.txt create mode 100644 credentials/apps/badges/migrations/0001_initial.py create mode 100644 credentials/apps/badges/migrations/__init__.py create mode 100644 credentials/apps/badges/models.py create mode 100644 credentials/apps/badges/processing.py create mode 100644 credentials/apps/badges/signals/__init__.py create mode 100644 credentials/apps/badges/signals/handlers.py create mode 100644 credentials/apps/badges/signals/signals.py create mode 100644 credentials/apps/badges/tests/__init__.py create mode 100644 credentials/apps/badges/toggles.py create mode 100644 credentials/apps/badges/urls.py create mode 100644 credentials/apps/badges/utils.py create mode 100644 credentials/apps/credentials/migrations/0026_alter_usercredential_credential_content_type.py diff --git a/credentials/apps/badges/__init__.py b/credentials/apps/badges/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/admin.py b/credentials/apps/badges/admin.py new file mode 100644 index 000000000..04462d84f --- /dev/null +++ b/credentials/apps/badges/admin.py @@ -0,0 +1,117 @@ +""" +Admin section configuration. +""" + +from django.contrib import admin + +from .models import ( + BadgeProgress, + BadgeRequirement, + BadgeTemplate, + DataRule, + Fulfillment, +) +from .toggles import is_badges_enabled + + +class BadgeRequirementInline(admin.TabularInline): + model = BadgeRequirement + show_change_link = True + extra = 0 + + +class FulfillmentInline(admin.TabularInline): + model = Fulfillment + extra = 0 + + +class DataRuleInline(admin.TabularInline): + model = DataRule + extra = 0 + readonly_fields = ("operator",) + fields = [ + "path", + "operator", + "value", + ] + + +class BadgeRequirementAdmin(admin.ModelAdmin): + """ + Badge template requirement admin setup. + """ + + inlines = [ + DataRuleInline, + ] + + list_display = [ + "id", + "template", + "event_type", + "effect", + ] + list_filter = [ + "template", + "event_type", + "effect", + ] + + +class BadgeTemplateAdmin(admin.ModelAdmin): + """ + Badge template admin setup. + """ + + inlines = [ + BadgeRequirementInline, + ] + + list_display = ( + "name", + "uuid", + "origin", + "is_active", + ) + list_filter = ( + "is_active", + "origin", + ) + search_fields = ( + "name", + "uuid", + ) + readonly_fields = [ + "origin", + ] + + +class BadgeProgressAdmin(admin.ModelAdmin): + """ + Badge template progress admin setup. + """ + + inlines = [ + FulfillmentInline, + ] + list_display = [ + "id", + "template", + "username", + "complete", + ] + list_display_links = ( + "id", + "template", + ) + + @admin.display(boolean=True) + def complete(self, obj): + return bool(getattr(obj, "credential", False)) # FIXME: optimize 100+1 + + +# register admin configurations with respect to the feature flag +if is_badges_enabled(): + admin.site.register(BadgeTemplate, BadgeTemplateAdmin) + admin.site.register(BadgeRequirement, BadgeRequirementAdmin) + admin.site.register(BadgeProgress, BadgeProgressAdmin) diff --git a/credentials/apps/badges/apps.py b/credentials/apps/badges/apps.py new file mode 100644 index 000000000..acc628dd7 --- /dev/null +++ b/credentials/apps/badges/apps.py @@ -0,0 +1,39 @@ +from django.apps import AppConfig +from django.conf import settings + +from .toggles import check_badges_enabled + + +class BadgesAppConfig(AppConfig): + """ + Extended application config with additional Badges-specific logic. + """ + + @property + def verbose_name(self): + return f"Badges: {self.plugin_label}" + + +class BadgesConfig(BadgesAppConfig): + """ + Core badges application configuration. + """ + + default = True + name = "credentials.apps.badges" + verbose_name = "Badges" + + @check_badges_enabled + def ready(self): + """ + Activate installed badges plugins if they are enabled. + + Performs initial registrations for checks, signals, etc. + """ + from . import signals # pylint: disable=unused-import,import-outside-toplevel + from .checks import badges_checks # pylint: disable=unused-import,import-outside-toplevel + from .signals.handlers import listen_to_badging_events + + listen_to_badging_events() + + super().ready() diff --git a/credentials/apps/badges/checks.py b/credentials/apps/badges/checks.py new file mode 100644 index 000000000..25e3a15f4 --- /dev/null +++ b/credentials/apps/badges/checks.py @@ -0,0 +1,32 @@ +""" +Badges app self-checks. +""" + +from django.core.checks import Error, Tags, register + +from .utils import get_badging_event_types + + +@register(Tags.compatibility) +def badges_checks(*args, **kwargs): + """ + Checks the consistency of the badges configurations. + + Raises compatibility Errors upon: + - BADGES_CONFIG['events'] is empty + + Returns: + List of any Errors. + """ + errors = [] + + if not get_badging_event_types(): + errors.append( + Error( + "BADGES_CONFIG['events'] must include at least one event.", + hint="Add at least one event to BADGES_CONFIG['events'] setting.", + id="badges.E001", + ) + ) + + return errors diff --git a/credentials/apps/badges/distribution/credly/LICENSE.txt b/credentials/apps/badges/distribution/credly/LICENSE.txt new file mode 100644 index 000000000..7a4a3ea24 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/LICENSE.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/credentials/apps/badges/distribution/credly/README.rst b/credentials/apps/badges/distribution/credly/README.rst new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/credly_badges/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/__init__.py new file mode 100644 index 000000000..49b144074 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/__init__.py @@ -0,0 +1,8 @@ +""" +Init module for credly_badges. +""" + +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 new file mode 100644 index 000000000..3e3102efe --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/admin.py @@ -0,0 +1,68 @@ +""" +Credly Badges admin configuration. +""" + +from django.contrib import admin + +from credentials.apps.badges.toggles import is_badges_enabled + +from .forms import CredlyOrganizationAdminForm +from .models import CredlyBadgeTemplate, CredlyOrganization +from .utils import sync_badge_templates_for_organization + + +class CredlyOrganizationAdmin(admin.ModelAdmin): + """ + Credly organization admin setup. + """ + + form = CredlyOrganizationAdminForm + list_display = ( + "name", + "uuid", + "api_key", + ) + readonly_fields = [ + "name", + ] + actions = ("sync_organization_badge_templates",) + + @admin.action(description="Sync organization badge templates") + def sync_organization_badge_templates(self, request, queryset): + """ + Sync badge templates for selected organizations. + """ + for organization in queryset: + sync_badge_templates_for_organization(organization.uuid) + + +class CredlyBadgeTemplateAdmin(admin.ModelAdmin): + """ + Badge template admin setup. + """ + + list_display = ( + "organization", + "state", + "name", + "uuid", + "is_active", + ) + list_filter = ( + "organization", + "is_active", + "state", + ) + search_fields = ( + "name", + "uuid", + ) + readonly_fields = [ + "organization", + "state", + ] + + +if is_badges_enabled(): + admin.site.register(CredlyOrganization, CredlyOrganizationAdmin) + admin.site.register(CredlyBadgeTemplate, CredlyBadgeTemplateAdmin) 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 000000000..154e47e5c --- /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 Credly Organization data. + """ + 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(f"Credly API:{response.text}({response.status_code})") + + 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 new file mode 100644 index 000000000..7a5ce3b73 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/apps.py @@ -0,0 +1,51 @@ +from credentials.apps.badges.apps import BadgesAppConfig +from credentials.apps.badges.toggles import check_badges_enabled +from credentials.apps.plugins.constants import ( + PROJECT_TYPE, + PluginSettings, + PluginURLs, + SettingsType, +) + + +class CredlyBadgesConfig(BadgesAppConfig): + """ + Credly distribution backend. + + This app is the Credential service plugin. + It is built on the top of the `credentials.apps.badges`. + It allows configuration and issuance specific to the Credly (by Pearson) badges from Open edX. + + In addition in a context of Credly Organization: + - organization badge templates are used to setup Open edX badge templates; + - earned badges are distributed to the Credly service; + """ + + name = "credly_badges" + plugin_label = "Credly (by Pearson)" + default = True + + plugin_app = { + PluginURLs.CONFIG: { + PROJECT_TYPE: { + PluginURLs.NAMESPACE: "credly_badges", + PluginURLs.REGEX: "credly-badges/", + PluginURLs.RELATIVE_PATH: "urls", + } + }, + PluginSettings.CONFIG: { + PROJECT_TYPE: { + SettingsType.BASE: {PluginSettings.RELATIVE_PATH: "settings.base"}, + SettingsType.PRODUCTION: {PluginSettings.RELATIVE_PATH: "settings.production"}, + SettingsType.TEST: {PluginSettings.RELATIVE_PATH: "settings.test"}, + }, + }, + # register signal handlers? + } + + @check_badges_enabled + def ready(self): + """ + Performs initial registrations for checks, signals, etc. + """ + super().ready() diff --git a/credentials/apps/badges/distribution/credly/credly_badges/checks.py b/credentials/apps/badges/distribution/credly/credly_badges/checks.py new file mode 100644 index 000000000..b9f1da572 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/checks.py @@ -0,0 +1,3 @@ +""" +Credly Badges checks. +""" diff --git a/credentials/apps/badges/distribution/credly/credly_badges/data.py b/credentials/apps/badges/distribution/credly/credly_badges/data.py new file mode 100644 index 000000000..fde7740ff --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/data.py @@ -0,0 +1,23 @@ +from datetime import datetime + +import attr + + +@attr.s(auto_attribs=True, frozen=True) +class IssueBadgeData: + """ + Represents the data required to issue a badge. + + Attributes: + recipient_email (str): Email address of the badge recipient. + issued_to_first_name (str): First name of the badge recipient. + issued_to_last_name (str): Last name of the badge recipient. + badge_template_id (str): ID of the badge template. + issued_at (datetime): Timestamp when the badge was issued. + """ + + recipient_email: str + issued_to_first_name: str + issued_to_last_name: str + badge_template_id: str + issued_at: datetime diff --git a/credentials/apps/badges/distribution/credly/credly_badges/exceptions.py b/credentials/apps/badges/distribution/credly/credly_badges/exceptions.py new file mode 100644 index 000000000..6e0fb9071 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/exceptions.py @@ -0,0 +1,6 @@ +class CredlyAPIError(Exception): + """ + Exception raised for errors that occur during interactions with the Credly API. + """ + + pass diff --git a/credentials/apps/badges/distribution/credly/credly_badges/forms.py b/credentials/apps/badges/distribution/credly/credly_badges/forms.py new file mode 100644 index 000000000..963889423 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/forms.py @@ -0,0 +1,60 @@ +""" +Credly Badges admin forms. +""" + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from .api_client import CredlyAPIClient +from .exceptions import CredlyAPIError +from .models import CredlyOrganization + + +class CredlyOrganizationAdminForm(forms.ModelForm): + """ + Additional actions for Credly Organization items. + """ + + api_data = {} + + class Meta: + model = CredlyOrganization + fields = "__all__" + + def clean(self): + """ + Perform Credly API check for given organization ID. + + - Credly Organization exists; + - fetch additional data for such organization; + """ + cleaned_data = super().clean() + + uuid = cleaned_data.get("uuid") + api_key = cleaned_data.get("api_key") + + credly_api_client = CredlyAPIClient(uuid, api_key) + self._ensure_organization_exists(credly_api_client) + + return cleaned_data + + def save(self, commit=True): + """ + Auto-fill addition properties. + """ + instance = super().save(commit=False) + instance.name = self.api_data.get("name") + instance.save() + + return instance + + def _ensure_organization_exists(self, api_client): + """ + Try to fetch organization data by the configured Credly Organization ID. + """ + try: + response_json = api_client.fetch_organization() + if org_data := response_json.get("data"): + self.api_data = org_data + except CredlyAPIError as err: + raise forms.ValidationError(message=str(err)) diff --git a/credentials/apps/badges/distribution/credly/credly_badges/management/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/credly_badges/management/commands/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb 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 new file mode 100644 index 000000000..1bc6d2981 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/management/commands/sync_organization_badge_templates.py @@ -0,0 +1,38 @@ +import logging + +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__) + + +class Command(BaseCommand): + help = "Sync badge templates for a specific organization or all organizations" + + def add_arguments(self, parser): + parser.add_argument("--organization_id", type=str, help="UUID of the organization.") + + def handle(self, *args, **options): + """ + Sync badge templates for a specific organization or all organizations. + + Usage: + ./manage.py sync_organization_badge_templates + ./manage.py sync_organization_badge_templates --organization_id c117c179-81b1-4f7e-a3a1-e6ae30568c13 + """ + organization_id = options.get("organization_id") + + if organization_id: + logger.info(f"Syncing badge templates for single organization: {organization_id}") + sync_badge_templates_for_organization(organization_id) + else: + all_organization_ids = CredlyOrganization.get_all_organization_ids() + logger.info( + f"Organization id was not provided. Syncing badge templates for all organizations: {all_organization_ids}" + ) + for organization_id in all_organization_ids: + sync_badge_templates_for_organization(organization_id) + + logger.info("Done.") diff --git a/credentials/apps/badges/distribution/credly/credly_badges/migrations/0001_initial.py b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0001_initial.py new file mode 100644 index 000000000..4cac206bf --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0001_initial.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.9 on 2024-01-16 12:03 + +from django.db import migrations, models +import django_extensions.db.fields + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="CredlyOrganization", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "created", + django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name="created"), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name="modified"), + ), + ("uuid", models.UUIDField(help_text="Unique credly organization ID.", unique=True)), + ("name", models.CharField(help_text="Name of credly organization.", max_length=255)), + ("api_key", models.CharField(help_text="Credly organization API bearer secret.", max_length=255)), + ], + options={ + "get_latest_by": "modified", + "abstract": False, + }, + ), + ] diff --git a/credentials/apps/badges/distribution/credly/credly_badges/migrations/0002_alter_credlyorganization_api_key_and_more.py b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0002_alter_credlyorganization_api_key_and_more.py new file mode 100644 index 000000000..5f2cbcefb --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0002_alter_credlyorganization_api_key_and_more.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.7 on 2024-02-06 14:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0001_initial'), + ('credly_badges', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='credlyorganization', + name='api_key', + field=models.CharField(help_text='Credly API shared secret for organization.', max_length=255), + ), + migrations.AlterField( + model_name='credlyorganization', + name='name', + field=models.CharField(help_text='Organization display name.', max_length=255), + ), + migrations.AlterField( + model_name='credlyorganization', + name='uuid', + field=models.UUIDField(help_text='Unique organization ID.', unique=True), + ), + migrations.CreateModel( + name='CredlyBadgeTemplate', + fields=[ + ('badgetemplate_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='badges.badgetemplate')), + ('organization', models.ForeignKey(help_text='Organization of the credly badge template.', on_delete=django.db.models.deletion.CASCADE, to='credly_badges.credlyorganization')), + ], + options={ + 'abstract': False, + }, + bases=('badges.badgetemplate',), + ), + ] diff --git a/credentials/apps/badges/distribution/credly/credly_badges/migrations/0003_credlybadgetemplate_state_and_more.py b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0003_credlybadgetemplate_state_and_more.py new file mode 100644 index 000000000..dd278f370 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/migrations/0003_credlybadgetemplate_state_and_more.py @@ -0,0 +1,64 @@ +# Generated by Django 4.2.10 on 2024-02-11 16:14 + +from django.db import migrations, models +import django.db.models.deletion +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("credly_badges", "0002_alter_credlyorganization_api_key_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="credlybadgetemplate", + name="state", + field=model_utils.fields.StatusField( + choices=[ + ("draft", "draft"), + ("active", "active"), + ("archived", "archived"), + ], + default="draft", + help_text="Credly badge template state (auto-managed).", + max_length=100, + no_check_for_status=True, + ), + ), + migrations.AlterField( + model_name="credlybadgetemplate", + name="organization", + field=models.ForeignKey( + help_text="Credly Organization - template owner.", + on_delete=django.db.models.deletion.CASCADE, + to="credly_badges.credlyorganization", + ), + ), + migrations.AlterField( + model_name="credlyorganization", + name="api_key", + field=models.CharField( + help_text="Credly API shared secret for Credly Organization.", + max_length=255, + ), + ), + migrations.AlterField( + model_name="credlyorganization", + name="name", + field=models.CharField( + blank=True, + help_text="Verbose name for Credly Organization.", + max_length=255, + null=True, + ), + ), + migrations.AlterField( + model_name="credlyorganization", + name="uuid", + field=models.UUIDField( + help_text="Put your Credly Organization ID here.", unique=True + ), + ), + ] diff --git a/credentials/apps/badges/distribution/credly/credly_badges/migrations/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/credly_badges/models.py b/credentials/apps/badges/distribution/credly/credly_badges/models.py new file mode 100644 index 000000000..ee5e92840 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/models.py @@ -0,0 +1,58 @@ +""" +Credly Badges DB models. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from credentials.apps.credentials.models import UserCredential +from django_extensions.db.models import TimeStampedModel +from model_utils import Choices +from model_utils.fields import StatusField + +from credentials.apps.badges.models import BadgeTemplate + + +class CredlyOrganization(TimeStampedModel): + """ + Credly Organization configuration. + """ + + uuid = models.UUIDField(unique=True, help_text=_("Put your Credly Organization ID here.")) + api_key = models.CharField(max_length=255, help_text=_("Credly API shared secret for Credly Organization.")) + name = models.CharField(max_length=255, null=True, blank=True, help_text=_("Verbose name for Credly Organization.")) + + def __str__(self): + return f"{self.name or self.uuid}" + + @classmethod + def get_all_organization_ids(cls): + """ + Get all organization IDs. + """ + return cls.objects.values_list("uuid", flat=True) + + +class CredlyBadgeTemplate(BadgeTemplate): + """ + Credly badge template. + """ + + TYPE = "credly" + STATES = Choices("draft", "active", "archived") + + organization = models.ForeignKey( + CredlyOrganization, + on_delete=models.CASCADE, + help_text=_("Credly Organization - template owner."), + ) + state = StatusField( + choices_name="STATES", + help_text=_("Credly badge template state (auto-managed)."), + ) + + +class CredlyBadge(UserCredential): + """ + Earned Credly badge template for user. + """ + # TODO: check if we can fetch pii for username from LMS for badge issuing? \ No newline at end of file diff --git a/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py b/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py new file mode 100644 index 000000000..1426b58ab --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/rest_api.py @@ -0,0 +1,65 @@ +import logging + +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 .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 CredlyWebhook(APIView): + """ + Public API (webhook endpoint) to handle incoming Credly updates. + + Usage: + POST /credly-badges/api/webhook/ + """ + + authentication_classes = [] + permission_classes = [] + + def post(self, request): + """ + Handle incoming update events from the Credly service. + + https://sandbox.credly.com/docs/webhooks#requirements + + Handled events: + - badge_template.created + - badge_template.changed + - badge_template.deleted + + - tries to recognize Credly Organization context; + - validates event type and its payload; + - performs corresponding item (badge template) updates; + + Returned statuses: + - 204 + - 404 + """ + organization = get_object_or_404(CredlyOrganization, uuid=request.data.get("organization_id")) + credly_api_client = CredlyAPIClient(organization.uuid, organization.api_key) + + event_info_response = credly_api_client.fetch_event_information(request.data.get("id")) + event_type = request.data.get("event_type") + + if event_type == "badge_template.created": + handle_badge_template_created_event(event_info_response) + elif event_type == "badge_template.changed": + handle_badge_template_changed_event(event_info_response) + elif event_type == "badge_template.deleted": + handle_badge_template_deleted_event(event_info_response) + else: + logger.error(f"Unknown event type: {event_type}") + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/credentials/apps/badges/distribution/credly/credly_badges/settings/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/settings/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/credly_badges/settings/base.py b/credentials/apps/badges/distribution/credly/credly_badges/settings/base.py new file mode 100644 index 000000000..17186a93a --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/settings/base.py @@ -0,0 +1,7 @@ +""" +Credly Badges base settings. +""" + + +def plugin_settings(settings): # pylint: disable=unused-argument + settings.CREDLY_API_BASE_URL = "https://sandbox-api.credly.com/v1/" diff --git a/credentials/apps/badges/distribution/credly/credly_badges/settings/production.py b/credentials/apps/badges/distribution/credly/credly_badges/settings/production.py new file mode 100644 index 000000000..691921e2e --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/settings/production.py @@ -0,0 +1,7 @@ +""" +Credly Badges production settings. +""" + + +def plugin_settings(settings): # pylint: disable=unused-argument + settings.CREDLY_API_BASE_URL = "https://api.credly.com/v1/" diff --git a/credentials/apps/badges/distribution/credly/credly_badges/settings/test.py b/credentials/apps/badges/distribution/credly/credly_badges/settings/test.py new file mode 100644 index 000000000..3e1b81335 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/settings/test.py @@ -0,0 +1,7 @@ +""" +Credly Badges test settings. +""" + + +def plugin_settings(settings): # pylint: disable=unused-argument + pass diff --git a/credentials/apps/badges/distribution/credly/credly_badges/signals/__init__.py b/credentials/apps/badges/distribution/credly/credly_badges/signals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/credly_badges/signals/handlers.py b/credentials/apps/badges/distribution/credly/credly_badges/signals/handlers.py new file mode 100644 index 000000000..c2176e911 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/signals/handlers.py @@ -0,0 +1,3 @@ +""" +Credly Badges signal handlers. +""" diff --git a/credentials/apps/badges/distribution/credly/credly_badges/urls.py b/credentials/apps/badges/distribution/credly/credly_badges/urls.py new file mode 100644 index 000000000..65ce3380c --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/urls.py @@ -0,0 +1,17 @@ +""" +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 = [ + path("api/webhook/", CredlyWebhook.as_view(), name="credly-webhook"), + ] diff --git a/credentials/apps/badges/distribution/credly/credly_badges/utils.py b/credentials/apps/badges/distribution/credly/credly_badges/utils.py new file mode 100644 index 000000000..eb4d17906 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/credly_badges/utils.py @@ -0,0 +1,93 @@ +from django.shortcuts import get_object_or_404 +from crum import get_current_request + +from .api_client import CredlyAPIClient +from .models import CredlyBadgeTemplate, CredlyOrganization + + +def sync_badge_templates_for_organization(organization_id): + """ + Pull active badge templates for a given Credly Organization. + + Args: + organization_id (str): UUID of the organization. + ready_states (list): badge templates states filter to process. + + 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) + + credly_api_client = CredlyAPIClient(organization_id, organization.api_key) + badge_templates_data = credly_api_client.fetch_badge_templates() + + for badge_template_data in badge_templates_data.get("data", []): + CredlyBadgeTemplate.objects.update_or_create( + uuid=badge_template_data.get("id"), + defaults={ + "site": get_current_request().site, + "organization": organization, + "name": badge_template_data.get("name"), + "state": badge_template_data.get("state"), + "description": badge_template_data.get("description"), + "icon": badge_template_data.get("image_url"), + }, + ) + + +def handle_badge_template_created_event(data): + """ + Create a new badge template. + """ + # TODO: dry it + 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")) + + CredlyBadgeTemplate.objects.update_or_create( + uuid=badge_template.get("id"), + defaults={ + "site": get_current_request().site, + "organization": organization, + "name": badge_template.get("name"), + "state": badge_template.get("state"), + "description": badge_template.get("description"), + "icon": badge_template.get("image_url"), + }, + ) + + +def handle_badge_template_changed_event(data): + """ + Change the badge template. + """ + # TODO: dry it + 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")) + + CredlyBadgeTemplate.objects.update_or_create( + uuid=badge_template.get("id"), + defaults={ + "site": get_current_request().site, + "organization": organization, + "name": badge_template.get("name"), + "state": badge_template.get("state"), + "description": badge_template.get("description"), + "icon": badge_template.get("image_url"), + }, + ) + + +def handle_badge_template_deleted_event(data): + """ + Deletes the badge template by provided uuid. + """ + CredlyBadgeTemplate.objects.filter( + uuid=data.get("data", {}).get("badge_template", {}).get("id"), + site=get_current_request().site, + ).delete() diff --git a/credentials/apps/badges/distribution/credly/docs b/credentials/apps/badges/distribution/credly/docs new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/distribution/credly/pyproject.toml b/credentials/apps/badges/distribution/credly/pyproject.toml new file mode 100644 index 000000000..eb6bb8658 --- /dev/null +++ b/credentials/apps/badges/distribution/credly/pyproject.toml @@ -0,0 +1,77 @@ +[build-system] +# https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html +build-backend = "setuptools.build_meta" +requires = ["setuptools"] + +[project] +# https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ +name = "openedx-badges-credly" +authors = [ + { name = "Kyrylo Kholodenko", email = "kyrylo.kholodenko@raccoongang.com" }, + { name = "Volodymyr Bergman", email = "volodymyr.bergman@raccoongang.com" }, +] +description = "Open edX Credentials plugin - Badges app extension for Credly (by Pearson) service." +readme = "README.rst" +requires-python = ">=3.8" +keywords = ["credentials", "badges", "credly"] +license = { file = "LICENSE.txt" } +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Framework :: Django :: 4", +] +dynamic = ["version", "dependencies", "optional-dependencies"] + +[project.entry-points."credentials.djangoapp"] +badges = "credly_badges.apps:CredlyBadgesConfig" + +[project.urls] +Repository = "https://github.com/openedx/credentials.git" + +[tool.setuptools.dynamic] +version = { attr = "credly_badges.__version__" } +dependencies = { file = "requirements/base.txt" } + +[tool.ruff] +# https://docs.astral.sh/ruff/configuration/ +line-length = 120 +exclude = ["migrations"] + +[tool.ruff.lint] +# https://docs.astral.sh/ruff/configuration/ + +[tool.ruff.lint.per-file-ignores] +"**/{tests,docs,tools}/*" = ["E402"] +"credly_badges/admin.py" = ["F403"] +"credly_badges/models.py" = ["F403"] +"credly_badges/signals.py" = ["F403"] + +[tool.ruff.format] +# https://docs.astral.sh/ruff/configuration/ +exclude = ["*.pyi"] + +[tool.pytest.ini_options] +# https://docs.pytest.org/en/stable/reference/customize.html +# https://pytest-cov.readthedocs.io/en/latest/config.html +# https://pytest-django.readthedocs.io/en/latest/managing_python_path.html +DJANGO_SETTINGS_MODULE = "credly_badges.settings.test" +pythonpath = ". credly_badges" +python_files = "test_*.py" +addopts = [ + "-p no:warnings", + "--strict-markers", + "--no-migrations", + "--reuse-db", + "--cov=credly_badges", + "--cov-report=term", + "--cov-report=html", + "--cov-report=xml", +] + +[tool.coverage.run] +# https://coverage.readthedocs.io/en/latest/config.html +omit = ["*/migrations/*"] diff --git a/credentials/apps/badges/distribution/credly/requirements/base.in b/credentials/apps/badges/distribution/credly/requirements/base.in new file mode 100644 index 000000000..7e49e82ea --- /dev/null +++ b/credentials/apps/badges/distribution/credly/requirements/base.in @@ -0,0 +1 @@ +# Main requirements of the plugin application. diff --git a/credentials/apps/badges/distribution/credly/requirements/base.txt b/credentials/apps/badges/distribution/credly/requirements/base.txt new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/migrations/0001_initial.py b/credentials/apps/badges/migrations/0001_initial.py new file mode 100644 index 000000000..6b9716123 --- /dev/null +++ b/credentials/apps/badges/migrations/0001_initial.py @@ -0,0 +1,85 @@ +# Generated by Django 3.2.20 on 2024-03-13 13:35 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('credentials', '0025_change_usercredentialdateoverride_date'), + ('sites', '0002_alter_domain_unique'), + ] + + operations = [ + migrations.CreateModel( + name='BadgeProgress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=255)), + ('credential', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='credentials.usercredential')), + ], + options={ + 'verbose_name_plural': 'badge progress records', + }, + ), + migrations.CreateModel( + name='BadgeRequirement', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(help_text='Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"', max_length=255)), + ('effect', models.CharField(choices=[('award', 'award'), ('revoke', 'revoke')], default='award', help_text='Defines how this requirement contributes to badge earning.', max_length=32)), + ('description', models.TextField(blank=True, help_text='Provide more details if needed.', null=True)), + ], + ), + migrations.CreateModel( + name='Fulfillment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('progress', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='badges.badgeprogress')), + ('requirement', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='badges.badgerequirement')), + ], + ), + migrations.CreateModel( + name='DataRule', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('path', models.CharField(help_text='Public signal\'s data payload nested property path, e.g: "user.pii.username".', max_length=255, verbose_name='key path')), + ('operator', models.CharField(choices=[('eq', '=')], default='eq', help_text='Expected value comparison operator. https://docs.python.org/3/library/operator.html', max_length=32, verbose_name='action')), + ('value', models.CharField(help_text='Expected value for the nested property, e.g: "cucumber1997".', max_length=255, verbose_name='expected value')), + ('requirement', models.ForeignKey(help_text='Parent requirement for this data rule.', on_delete=django.db.models.deletion.CASCADE, to='badges.badgerequirement')), + ], + ), + migrations.CreateModel( + name='BadgeTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('is_active', models.BooleanField(default=False)), + ('uuid', models.UUIDField(default=uuid.uuid4, help_text='Unique badge template ID.', unique=True)), + ('name', models.CharField(help_text='Badge template name.', max_length=255)), + ('description', models.TextField(blank=True, help_text='Badge template description.', null=True)), + ('icon', models.ImageField(blank=True, null=True, upload_to='badge_templates/icons')), + ('origin', models.CharField(blank=True, help_text='Badge template type.', max_length=128, null=True)), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='badgerequirement', + name='template', + field=models.ForeignKey(help_text='Badge template this requirement serves for.', on_delete=django.db.models.deletion.CASCADE, to='badges.badgetemplate'), + ), + migrations.AddField( + model_name='badgeprogress', + name='template', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='badges.badgetemplate'), + ), + ] diff --git a/credentials/apps/badges/migrations/__init__.py b/credentials/apps/badges/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py new file mode 100644 index 000000000..b4eff6a5d --- /dev/null +++ b/credentials/apps/badges/models.py @@ -0,0 +1,156 @@ +""" +Badges DB models. +""" + +import uuid + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from model_utils import Choices + +from credentials.apps.credentials.models import AbstractCredential, UserCredential + + +class BadgeTemplate(AbstractCredential): + """ + Describes badge credential type. + """ + + TYPE = "openedx" + + uuid = models.UUIDField( + unique=True, default=uuid.uuid4, help_text=_("Unique badge template ID.") + ) + name = models.CharField(max_length=255, help_text=_("Badge template name.")) + description = models.TextField( + null=True, blank=True, help_text=_("Badge template description.") + ) + icon = models.ImageField(upload_to="badge_templates/icons", null=True, blank=True) + origin = models.CharField( + max_length=128, null=True, blank=True, help_text=_("Badge template type.") + ) + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + super().save() + # auto-evaluate type: + if not self.origin: + self.origin = self.TYPE + self.save(*args, **kwargs) + + +class BadgeRequirement(models.Model): + """ + Describes what must happen and its effect for badge template. + + NOTE: all requirement for a single badge template follow "AND" processing logic by default. + """ + + EFFECTS = Choices("award", "revoke") + + template = models.ForeignKey( + BadgeTemplate, + on_delete=models.CASCADE, + help_text=_("Badge template this requirement serves for."), + ) + event_type = models.CharField( + max_length=255, + help_text=_( + 'Public signal type. Use namespaced types, e.g: "org.openedx.learning.student.registration.completed.v1"' + ), + ) + effect = models.CharField( + max_length=32, + choices=EFFECTS, + default=EFFECTS.award, + help_text=_("Defines how this requirement contributes to badge earning."), + ) + description = models.TextField( + null=True, blank=True, help_text=_("Provide more details if needed.") + ) + + def __str__(self): + return f"BadgeRequirement:{self.id}:{self.template.uuid}" + + +class DataRule(models.Model): + """ + Specifies expected data attribute value for event payload. + + NOTE: all data rules for a single requirement follow "AND" processing logic. + """ + + OPERATORS = Choices( + ("eq", "="), + # ('lt', '<'), + # ('gt', '>'), + ) + + requirement = models.ForeignKey( + BadgeRequirement, + on_delete=models.CASCADE, + help_text=_("Parent requirement for this data rule."), + ) + path = models.CharField( + max_length=255, + help_text=_( + 'Public signal\'s data payload nested property path, e.g: "user.pii.username".' + ), + verbose_name=_("key path"), + ) + operator = models.CharField( + max_length=32, + choices=OPERATORS, + default=OPERATORS.eq, + help_text=_( + "Expected value comparison operator. https://docs.python.org/3/library/operator.html" + ), + verbose_name=_("action"), + ) + value = models.CharField( + max_length=255, + help_text=_('Expected value for the nested property, e.g: "cucumber1997".'), + verbose_name=_("expected value"), + ) + + +class BadgeProgress(models.Model): + """ + Tracks a single badge template progress for user. + """ + + credential = models.OneToOneField( + UserCredential, + models.SET_NULL, + blank=True, + null=True, + ) + username = models.CharField(max_length=255) # index + template = models.ForeignKey( + BadgeTemplate, + models.SET_NULL, + blank=True, + null=True, + ) + + class Meta: + verbose_name_plural = _("badge progress records") + + def __str__(self): + return f"BadgeProgress:{self.username}" + + +class Fulfillment(models.Model): + """ + Completed badge template requirement for user. + """ + + progress = models.ForeignKey(BadgeProgress, on_delete=models.CASCADE) + requirement = models.ForeignKey( + BadgeRequirement, + models.SET_NULL, + blank=True, + null=True, + ) diff --git a/credentials/apps/badges/processing.py b/credentials/apps/badges/processing.py new file mode 100644 index 000000000..562b93f51 --- /dev/null +++ b/credentials/apps/badges/processing.py @@ -0,0 +1,56 @@ +""" +Badge templates progress evaluation. +""" + +from openedx_events.learning.data import BadgeData, BadgeTemplateData +from openedx_events.learning.signals import BADGE_AWARDED, BADGE_REVOKED + +from .models import BadgeTemplate + + +def process(signal, sender, **kwargs): + """ + Processes incoming public signal consumed from event bus and re-emitted within the service. + """ + + # find all REQUIREMENTs for the signal; + # if no requirements - drop (signal is not used); + # personalize: associate signal's USER (event "author"); + # if not identified - drop (no user info?); + # check each relevant requirement: + # if the REQUIREMENT is already fulfilled for USER - drop; + # if data rules attached - apply each rule: + # try traverse deep key - if not exists - drop; + # get expected value (TODO: allow empty strings?) + # apply operator (default: string comparison) + + # signal processing drop == StopEventProcessingException(reason=NO_REQUIREMENTS) + # requirement processing drop == StopRequirementProcessingException(reason=CANNOT_PERSONALIZE | DATA_ATTR_NOT_FOUND...) + + # FIXME: this is a temporary solution for testing purposes + badge_template = BadgeTemplate.objects.last() + badge_data = BadgeData( + uuid="badge-uuid", + user=kwargs.get("user_course_data").user, + template=BadgeTemplateData( + uuid=str(badge_template.uuid), + type=badge_template.origin, + name=badge_template.name, + description=badge_template.description, + image_url=badge_template.icon.url, + ), + ) + + if sender == "org.openedx.learning.course.grade.now.passed.v1": + BADGE_AWARDED.send_event(badge=badge_data) + elif sender == "org.openedx.learning.course.grade.now.failed.v1": + BADGE_REVOKED.send_event(badge=badge_data) + + +def collect(sender, **kwargs): + """ """ + pass + + +# TODO: use cases +# 1. Active Badge Template configuration updates (forbid active badge templates changes!) diff --git a/credentials/apps/badges/signals/__init__.py b/credentials/apps/badges/signals/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/signals/handlers.py b/credentials/apps/badges/signals/handlers.py new file mode 100644 index 000000000..d809fd7ae --- /dev/null +++ b/credentials/apps/badges/signals/handlers.py @@ -0,0 +1,36 @@ +""" +These signal handlers are auto-subscribed to all expected badging signals (event types). + +See: +""" +import logging + +from openedx_events.tooling import OpenEdxPublicSignal, load_all_signals + +from ..utils import get_badging_event_types +from ..processing import process + + +logger = logging.getLogger(__name__) + + +def listen_to_badging_events(): + """ + Connects event handler to pre-configured public signals subset. + """ + + load_all_signals() + + for event_type in get_badging_event_types(): + signal = OpenEdxPublicSignal.get_signal_by_type(event_type) + signal.connect(event_handler) + + +def event_handler(sender, signal, **kwargs): + """ + Generic signal handler. + """ + logger.debug(f"Received signal {signal}") + + # NOTE (performance): all consumed messages from event bus trigger this. + process(signal, sender=sender, **kwargs) \ No newline at end of file diff --git a/credentials/apps/badges/signals/signals.py b/credentials/apps/badges/signals/signals.py new file mode 100644 index 000000000..637bdba39 --- /dev/null +++ b/credentials/apps/badges/signals/signals.py @@ -0,0 +1,6 @@ +""" +define internal signals: +- BADGE_REQUIREMENT_FULFILLED - a single specific requirement has finished; +- BADGE_REQUIREMENTS_COMPLETE - all badge template requirements are finished; +- BADGE_REQUIREMENTS_NOT_COMPLETE - a reason for earned badge revocation; +""" diff --git a/credentials/apps/badges/tests/__init__.py b/credentials/apps/badges/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/credentials/apps/badges/toggles.py b/credentials/apps/badges/toggles.py new file mode 100644 index 000000000..c335e1879 --- /dev/null +++ b/credentials/apps/badges/toggles.py @@ -0,0 +1,34 @@ +""" +Badges app toggles. +""" + +from edx_toggles.toggles import SettingToggle + +# .. toggle_name: BADGES_ENABLED +# .. toggle_implementation: DjangoSetting +# .. toggle_default: False +# .. toggle_description: Determines if the Credentials IDA uses badges functionality. +# .. toggle_life_expectancy: permanent +# .. toggle_permanent_justification: Badges are optional for usage. +# .. toggle_creation_date: 2024-01-12 +# .. toggle_use_cases: open_edx +ENABLE_BADGES = SettingToggle("BADGES_ENABLED", default=False, module_name=__name__) + + +def is_badges_enabled(): + """ + Check main feature flag. + """ + return ENABLE_BADGES.is_enabled() + + +def check_badges_enabled(func): + """ + Decorator for checking the applicability of a badges app. + """ + + def wrapper(*args, **kwargs): + if is_badges_enabled(): + return func(*args, **kwargs) + + return wrapper diff --git a/credentials/apps/badges/urls.py b/credentials/apps/badges/urls.py new file mode 100644 index 000000000..c199ef120 --- /dev/null +++ b/credentials/apps/badges/urls.py @@ -0,0 +1,12 @@ +""" +URLs for badges. +""" + +from .toggles import is_badges_enabled + +urlpatterns = [] + +if is_badges_enabled(): + urlpatterns = [ + # Define urls here + ] diff --git a/credentials/apps/badges/utils.py b/credentials/apps/badges/utils.py new file mode 100644 index 000000000..5169a77a8 --- /dev/null +++ b/credentials/apps/badges/utils.py @@ -0,0 +1,8 @@ +from django.conf import settings + + +def get_badging_event_types(): + """ + Figures out which events are available for badges. + """ + return settings.BADGES_CONFIG.get('events', []) \ No newline at end of file diff --git a/credentials/apps/credentials/migrations/0026_alter_usercredential_credential_content_type.py b/credentials/apps/credentials/migrations/0026_alter_usercredential_credential_content_type.py new file mode 100644 index 000000000..008246557 --- /dev/null +++ b/credentials/apps/credentials/migrations/0026_alter_usercredential_credential_content_type.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.20 on 2024-03-13 13:35 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('credentials', '0025_change_usercredentialdateoverride_date'), + ] + + operations = [ + migrations.AlterField( + model_name='usercredential', + name='credential_content_type', + field=models.ForeignKey(limit_choices_to={'model__in': ('coursecertificate', 'programcertificate', 'badgetemplate')}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + ] diff --git a/credentials/apps/credentials/models.py b/credentials/apps/credentials/models.py index 5380fed6d..c9d6af276 100644 --- a/credentials/apps/credentials/models.py +++ b/credentials/apps/credentials/models.py @@ -162,7 +162,7 @@ class UserCredential(TimeStampedModel): credential_content_type = models.ForeignKey( ContentType, - limit_choices_to={"model__in": ("coursecertificate", "programcertificate")}, + limit_choices_to={"model__in": ("coursecertificate", "programcertificate", "badgetemplate")}, on_delete=models.CASCADE, ) credential_id = models.PositiveIntegerField() diff --git a/credentials/settings/base.py b/credentials/settings/base.py index 027d988b5..dafdab2b4 100644 --- a/credentials/settings/base.py +++ b/credentials/settings/base.py @@ -75,6 +75,7 @@ "credentials.apps.credentials_theme_openedx", "credentials.apps.records", "credentials.apps.plugins", + "credentials.apps.badges", ] INSTALLED_APPS += THIRD_PARTY_APPS @@ -581,6 +582,26 @@ "enabled": False, }, }, + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.awarded.v1'] + # ['learning-badge-lifecycle']['enabled'] + # .. toggle_implementation: SettingToggle + # .. toggle_default: True + # .. toggle_description: Enables sending org.openedx.learning.badge.awarded.v1 events over the event bus. + # .. toggle_warning: The default may be changed in a later release. + # .. toggle_use_cases: opt_in + "org.openedx.learning.badge.awarded.v1": { + "learning-badge-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, + }, + # .. setting_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.badge.revoked.v1'] + # ['learning-badge-lifecycle']['enabled'] + # .. toggle_implementation: SettingToggle + # .. toggle_default: True + # .. toggle_description: Enables sending org.openedx.learning.badge.revoked.v1 events over the event bus. + # .. toggle_warning: The default may be changed in a later release. + # .. toggle_use_cases: opt_in + "org.openedx.learning.badge.revoked.v1": { + "learning-badge-lifecycle": {"event_key_field": "badge.uuid", "enabled": True}, + }, } # .. toggle_name: LOG_INCOMING_REQUESTS @@ -598,3 +619,13 @@ # Plugin Django Apps INSTALLED_APPS.extend(get_plugin_apps(PROJECT_TYPE)) add_plugins(__name__, PROJECT_TYPE, SettingsType.BASE) +# Badges settings +# .. setting_name: BADGES_CONFIG +# .. setting_description: Dictionary with badges settings including enabled badge events, processors, collectors, etc. +BADGES_CONFIG = { + # these events become available in rules setup: + "events": [ + "org.openedx.learning.course.grade.now.passed.v1", + "org.openedx.learning.course.grade.now.failed.v1", + ], +} \ No newline at end of file diff --git a/requirements/all.txt b/requirements/all.txt index 26c8952f6..e11f7ccbb 100644 --- a/requirements/all.txt +++ b/requirements/all.txt @@ -147,6 +147,7 @@ django==3.2.20 # django-extensions # django-filter # django-hijack + # django-model-utils # django-ses # django-statici18n # django-storages @@ -195,6 +196,10 @@ django-hijack==2.3.0 # -c requirements/constraints.txt # -r requirements/dev.txt # -r requirements/production.txt +django-model-utils==4.4.0 + # via + # -r requirements/dev.txt + # -r requirements/production.txt django-ratelimit==3.0.1 # via # -r requirements/dev.txt @@ -449,6 +454,10 @@ openapi-codec==1.3.2 # -r requirements/dev.txt # -r requirements/production.txt # django-rest-swagger +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly + # via + # -r requirements/dev.txt + # -r requirements/production.txt openedx-events @ git+https://github.com/raccoongang/openedx-events.git@aci.main # via # -r requirements/dev.txt diff --git a/requirements/base.in b/requirements/base.in index 1e61e85d3..8e89f61d4 100644 --- a/requirements/base.in +++ b/requirements/base.in @@ -17,6 +17,7 @@ django-cors-headers django-hijack django-extensions django-filter +django-model-utils django-ratelimit django-rest-swagger django-simple-history @@ -47,6 +48,8 @@ requests social-auth-app-django xss-utils +# Badges baked-in backends: +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly # TODO Install in configuration git+https://github.com/openedx/credentials-themes.git@0.1.121#egg=edx_credentials_themes==0.1.121 diff --git a/requirements/base.txt b/requirements/base.txt index 4b7fdbe0b..10c26d641 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -61,6 +61,7 @@ django==3.2.20 # django-extensions # django-filter # django-hijack + # django-model-utils # django-statici18n # django-storages # djangorestframework @@ -94,6 +95,8 @@ django-hijack==2.3.0 # via # -c requirements/constraints.txt # -r requirements/base.in +django-model-utils==4.4.0 + # via -r requirements/base.in django-ratelimit==3.0.1 # via -r requirements/base.in django-rest-swagger==2.2.0 @@ -196,6 +199,8 @@ oauthlib==3.2.1 # social-auth-core openapi-codec==1.3.2 # via django-rest-swagger +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly + # via -r requirements/base.in openedx-events @ git+https://github.com/raccoongang/openedx-events.git@aci.main # via # -r requirements/base.in diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index 01c4ab381..583f8c3fe 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -23,6 +23,11 @@ # See BOM-2721 for more details. # Below is the copied and edited version of common_constraints +# This is a temporary solution to override the real common_constraints.txt +# In edx-lint, until the pyjwt constraint in edx-lint has been removed. +# See BOM-2721 for more details. +# Below is the copied and edited version of common_constraints + # A central location for most common version constraints # (across edx repos) for pip-installation. # diff --git a/requirements/dev.txt b/requirements/dev.txt index faef4dd5e..cd24deff7 100644 --- a/requirements/dev.txt +++ b/requirements/dev.txt @@ -116,6 +116,7 @@ django==3.2.20 # django-extensions # django-filter # django-hijack + # django-model-utils # django-statici18n # django-storages # djangorestframework @@ -154,6 +155,8 @@ django-hijack==2.3.0 # via # -c requirements/constraints.txt # -r requirements/test.txt +django-model-utils==4.4.0 + # via -r requirements/test.txt django-ratelimit==3.0.1 # via -r requirements/test.txt django-rest-swagger==2.2.0 @@ -330,6 +333,8 @@ openapi-codec==1.3.2 # via # -r requirements/test.txt # django-rest-swagger +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly + # via -r requirements/test.txt openedx-events @ git+https://github.com/raccoongang/openedx-events.git@aci.main # via # -r requirements/test.txt diff --git a/requirements/production.txt b/requirements/production.txt index c35125b15..2cba27a8e 100644 --- a/requirements/production.txt +++ b/requirements/production.txt @@ -85,6 +85,7 @@ django==3.2.20 # django-extensions # django-filter # django-hijack + # django-model-utils # django-ses # django-statici18n # django-storages @@ -122,6 +123,8 @@ django-hijack==2.3.0 # via # -c requirements/constraints.txt # -r requirements/base.txt +django-model-utils==4.4.0 + # via -r requirements/base.txt django-ratelimit==3.0.1 # via -r requirements/base.txt django-rest-swagger==2.2.0 @@ -263,6 +266,8 @@ openapi-codec==1.3.2 # via # -r requirements/base.txt # django-rest-swagger +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly + # via -r requirements/base.txt openedx-events @ git+https://github.com/raccoongang/openedx-events.git@aci.main # via # -r requirements/base.txt diff --git a/requirements/test.txt b/requirements/test.txt index 11b95a57c..0fb9abe72 100644 --- a/requirements/test.txt +++ b/requirements/test.txt @@ -102,6 +102,7 @@ distlib==0.3.6 # django-extensions # django-filter # django-hijack + # django-model-utils # django-statici18n # django-storages # djangorestframework @@ -138,6 +139,8 @@ django-hijack==2.3.0 # via # -c requirements/constraints.txt # -r requirements/base.txt +django-model-utils==4.4.0 + # via -r requirements/base.txt django-ratelimit==3.0.1 # via -r requirements/base.txt django-rest-swagger==2.2.0 @@ -290,6 +293,8 @@ openapi-codec==1.3.2 # via # -r requirements/base.txt # django-rest-swagger +openedx-badges-credly @ git+https://github.com/raccoongang/credentials.git@aci.main#subdirectory=credentials/apps/badges/distribution/credly + # via -r requirements/base.txt openedx-events @ git+https://github.com/raccoongang/openedx-events.git@aci.main # via # -r requirements/base.txt