From 28ef034d30818aa01365f008c104da42f735dcd1 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Thu, 30 Jul 2026 15:43:23 +0200 Subject: [PATCH 1/9] feat: [Badges] support Credly authorization token refresh Credly authorization tokens expire 180 days after issuance. Track the token issuance/refresh dates on CredlyOrganization, add a client method that rotates the token via the Credly API, and add the refresh_credly_authorization_tokens management command that warns when a token approaches expiration and rotates it shortly before the deadline. Intended to be run periodically (e.g. daily cron). --- credentials/apps/badges/credly/api_client.py | 42 ++++++++ .../refresh_credly_authorization_tokens.py | 102 ++++++++++++++++++ ...yorganization_authorization_token_dates.py | 29 +++++ credentials/apps/badges/models.py | 40 +++++++ .../apps/badges/tests/test_api_client.py | 22 ++++ .../badges/tests/test_management_commands.py | 76 +++++++++++++ credentials/apps/badges/tests/test_models.py | 14 +++ 7 files changed, 325 insertions(+) create mode 100644 credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py create mode 100644 credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index b35eddc7d..a17155cfd 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -8,6 +8,7 @@ from attrs import asdict from django.conf import settings from django.contrib.sites.models import Site +from django.utils import timezone from credentials.apps.badges.base_api_client import BaseBadgeProviderClient from credentials.apps.badges.credly.exceptions import CredlyError @@ -149,6 +150,47 @@ def revoke_badge(self, badge_id, data=None): """ return self.perform_request("put", f"badges/{badge_id}/revoke/", data=data) + def rotate_authorization_token(self): + """ + Rotate (refresh) the Credly authorization token for this organization. + + Calls Credly's token rotation endpoint, which generates and returns a new + authorization token and immediately invalidates the token used to make the + request. The new token and its refresh timestamps are persisted on the + related CredlyOrganization. + + Returns: + str: the newly issued authorization token. + + Raises: + CredlyError: if the rotation response does not contain a new token. + """ + response = self.perform_request("post", "authorization_tokens/rotate") + new_token = response.get("data", {}).get("token") + if not new_token: + raise CredlyError("Credly token rotation response did not contain a new token.") + + organization = self._get_organization(self.organization_id) + now = timezone.now() + if organization.authorization_token_created_at is None: + organization.authorization_token_created_at = now + organization.authorization_token_updated_at = now + organization.api_key = new_token + organization.save( + update_fields=[ + "api_key", + "authorization_token_created_at", + "authorization_token_updated_at", + "modified", + ] + ) + + # Adopt the new token for any subsequent requests and drop the cached header. + self.api_key = new_token + self._build_authorization_token.cache_clear() + + return new_token + def sync_organization_badge_templates(self, site_id): """ Pull active badge templates for a given Credly Organization. diff --git a/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py new file mode 100644 index 000000000..09fb55c4f --- /dev/null +++ b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py @@ -0,0 +1,102 @@ +import logging + +from django.core.management.base import BaseCommand + +from credentials.apps.badges.credly.api_client import CredlyAPIClient +from credentials.apps.badges.exceptions import BadgesError +from credentials.apps.badges.models import CredlyOrganization + +logger = logging.getLogger(__name__) + +# Start warning operators once a token is within this many days of expiration. +WARNING_THRESHOLD_DAYS = 30 +# Automatically rotate a token once fewer than this many days remain. +REFRESH_THRESHOLD_DAYS = 5 + + +class Command(BaseCommand): + help = ( + "Check Credly authorization tokens and keep them alive: log a warning when a token is " + f"within {WARNING_THRESHOLD_DAYS} days of expiration and automatically rotate it once fewer " + f"than {REFRESH_THRESHOLD_DAYS} days remain. Intended to be run periodically (e.g. daily cron)." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--organization_id", + type=str, + help="UUID of a single Credly organization to check. Defaults to all organizations.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Rotate the token immediately, regardless of the remaining lifetime.", + ) + + def handle(self, *args, **options): + """ + Refresh Credly authorization tokens that are close to expiration. + + Usage: + ./manage.py refresh_credly_authorization_tokens + ./manage.py refresh_credly_authorization_tokens --organization_id + ./manage.py refresh_credly_authorization_tokens --force + """ + organization_id = options.get("organization_id") + force = options.get("force") + + if organization_id: + organizations = CredlyOrganization.objects.filter(uuid=organization_id) + if not organizations: + logger.warning(f"No Credly organization found with the uuid {organization_id}.") + else: + organizations = CredlyOrganization.objects.all() + + for organization in organizations: + self._process_organization(organization, force=force) + + logger.info("...completed!") + + def _process_organization(self, organization, force=False): + """ + Inspect a single organization's token and warn and/or rotate as needed. + """ + if force: + logger.info(f"Organization {organization.uuid}: forcing authorization token rotation.") + self._rotate(organization) + return + + days_left = organization.authorization_token_days_until_expiry + + if days_left is None: + logger.warning( + f"Organization {organization.uuid}: authorization token issuance date is unknown, so its " + "expiration cannot be determined. Rotate it manually or run this command with --force." + ) + return + + if days_left < REFRESH_THRESHOLD_DAYS: + logger.warning( + f"Organization {organization.uuid}: authorization token expires in {days_left} day(s); " + "rotating it now." + ) + self._rotate(organization) + elif days_left <= WARNING_THRESHOLD_DAYS: + logger.warning( + f"Organization {organization.uuid}: authorization token expires in {days_left} day(s). " + f"It will be rotated automatically once fewer than {REFRESH_THRESHOLD_DAYS} days remain." + ) + else: + logger.info( + f"Organization {organization.uuid}: authorization token is healthy ({days_left} day(s) left)." + ) + + def _rotate(self, organization): + """ + Rotate a single organization's authorization token, logging the outcome. + """ + try: + CredlyAPIClient(organization.uuid).rotate_authorization_token() + logger.info(f"Organization {organization.uuid}: authorization token rotated successfully.") + except BadgesError as exc: + logger.error(f"Organization {organization.uuid}: failed to rotate authorization token: {exc}") diff --git a/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py new file mode 100644 index 000000000..5bdef1bcf --- /dev/null +++ b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py @@ -0,0 +1,29 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('badges', '0002_accredibleapiconfig_accrediblebadge_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='credlyorganization', + name='authorization_token_created_at', + field=models.DateTimeField( + blank=True, + help_text='When the current Credly authorization token was first issued.', + null=True, + ), + ), + migrations.AddField( + model_name='credlyorganization', + name='authorization_token_updated_at', + field=models.DateTimeField( + blank=True, + help_text='When the current Credly authorization token was last refreshed (rotated).', + null=True, + ), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 5c21fb128..900ca3289 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -5,10 +5,12 @@ import logging import operator import uuid +from datetime import timedelta from urllib.parse import urljoin from django.conf import settings from django.db import models +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from django_extensions.db.models import TimeStampedModel from model_utils import Choices @@ -35,6 +37,9 @@ class CredlyOrganization(TimeStampedModel): Credly Organization configuration. """ + # Credly authorization tokens are long-lived credentials that expire 180 days after issuance. + AUTHORIZATION_TOKEN_LIFETIME = timedelta(days=180) + uuid = models.UUIDField(unique=True, help_text=_("Put your Credly Organization ID here.")) api_key = models.CharField( max_length=255, @@ -47,10 +52,45 @@ class CredlyOrganization(TimeStampedModel): blank=True, help_text=_("Verbose name for Credly Organization."), ) + authorization_token_created_at = models.DateTimeField( + null=True, + blank=True, + help_text=_("When the current Credly authorization token was first issued."), + ) + authorization_token_updated_at = models.DateTimeField( + null=True, + blank=True, + help_text=_("When the current Credly authorization token was last refreshed (rotated)."), + ) def __str__(self): return f"{self.name or self.uuid}" + @property + def authorization_token_expires_at(self): + """ + Estimated expiration datetime of the current authorization token. + + Derived from the most recent issuance/refresh timestamp plus the token lifetime. + Returns None when no issuance/refresh date has been recorded yet. + """ + issued_at = self.authorization_token_updated_at or self.authorization_token_created_at + if issued_at is None: + return None + return issued_at + self.AUTHORIZATION_TOKEN_LIFETIME + + @property + def authorization_token_days_until_expiry(self): + """ + Number of whole days remaining before the authorization token expires. + + Returns None when the expiration cannot be determined (no recorded dates). + """ + expires_at = self.authorization_token_expires_at + if expires_at is None: + return None + return (expires_at - timezone.now()).days + @classmethod def get_all_organization_ids(cls): """ diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 50c173f92..54bf7499b 100644 --- a/credentials/apps/badges/tests/test_api_client.py +++ b/credentials/apps/badges/tests/test_api_client.py @@ -104,6 +104,28 @@ def test_revoke_badge(self): mock_perform_request.assert_called_once_with("put", f"badges/{badge_id}/revoke/", data=data) self.assertEqual(result, {"badge": "revoked"}) + def test_rotate_authorization_token(self): + api_client = CredlyAPIClient(self.organization.uuid) + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"data": {"token": "new-token"}} + result = api_client.rotate_authorization_token() + + mock_perform_request.assert_called_once_with("post", "authorization_tokens/rotate") + self.assertEqual(result, "new-token") + self.assertEqual(api_client.api_key, "new-token") + + self.organization.refresh_from_db() + self.assertEqual(self.organization.api_key, "new-token") + self.assertIsNotNone(self.organization.authorization_token_created_at) + self.assertIsNotNone(self.organization.authorization_token_updated_at) + + def test_rotate_authorization_token_no_token_in_response(self): + api_client = CredlyAPIClient(self.organization.uuid) + with mock.patch.object(CredlyAPIClient, "perform_request") as mock_perform_request: + mock_perform_request.return_value = {"data": {}} + with self.assertRaises(CredlyError): + api_client.rotate_authorization_token() + def test_sync_organization_badge_templates(self): with mock.patch.object(CredlyAPIClient, "fetch_badge_templates") as mock_fetch_badge_templates: mock_fetch_badge_templates.return_value = { diff --git a/credentials/apps/badges/tests/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py index 963819498..906d2a1d9 100644 --- a/credentials/apps/badges/tests/test_management_commands.py +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -1,8 +1,10 @@ +from datetime import timedelta from unittest import mock import faker from django.core.management import call_command from django.test import TestCase +from django.utils import timezone from credentials.apps.badges.models import AccredibleAPIConfig, CredlyOrganization @@ -47,3 +49,77 @@ def test_handle_with_api_config_id(self, mock_accredible_api_client): call_command("sync_accredible_groups", "--api_config_id", self.api_config.id) mock_accredible_api_client.assert_called_once_with(1) mock_accredible_api_client.return_value.sync_groups.assert_called_once_with(1) + + +@mock.patch( + "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens.CredlyAPIClient" +) +class TestRefreshCredlyAuthorizationTokensCommand(TestCase): + """ + Tests for the ``refresh_credly_authorization_tokens`` management command. + + Token expiration is derived from ``authorization_token_updated_at`` plus the 180-day lifetime, so + a token with ``days_left`` remaining is simulated by backdating that field accordingly. A 12-hour + buffer is added to keep the truncated day count off threshold boundaries. + """ + + COMMAND = "refresh_credly_authorization_tokens" + + def setUp(self): + self.faker = faker.Faker() + + def _make_organization(self, days_left=None): + organization = CredlyOrganization.objects.create( + uuid=self.faker.uuid4(), api_key=self.faker.uuid4(), name=self.faker.word() + ) + if days_left is not None: + lifetime = CredlyOrganization.AUTHORIZATION_TOKEN_LIFETIME + organization.authorization_token_updated_at = ( + timezone.now() - lifetime + timedelta(days=days_left, hours=12) + ) + organization.save() + # Reload so ``organization.uuid`` is a UUID instance, as the command sees it when querying the DB. + organization.refresh_from_db() + return organization + + def test_healthy_token_is_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=100) + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + + def test_token_in_warning_window_is_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=20) + with self.assertLogs( + "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens", + level="WARNING", + ) as logs: + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + self.assertTrue(any("expires in" in message for message in logs.output)) + + def test_token_close_to_expiry_is_rotated(self, mock_credly_api_client): + organization = self._make_organization(days_left=2) + call_command(self.COMMAND) + mock_credly_api_client.assert_called_once_with(organization.uuid) + mock_credly_api_client.return_value.rotate_authorization_token.assert_called_once_with() + + def test_token_without_dates_is_warned_not_rotated(self, mock_credly_api_client): + self._make_organization(days_left=None) + with self.assertLogs( + "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens", + level="WARNING", + ): + call_command(self.COMMAND) + mock_credly_api_client.return_value.rotate_authorization_token.assert_not_called() + + def test_force_rotates_regardless_of_expiry(self, mock_credly_api_client): + organization = self._make_organization(days_left=100) + call_command(self.COMMAND, "--force") + mock_credly_api_client.assert_called_once_with(organization.uuid) + mock_credly_api_client.return_value.rotate_authorization_token.assert_called_once_with() + + def test_organization_id_limits_scope(self, mock_credly_api_client): + target = self._make_organization(days_left=2) + self._make_organization(days_left=2) + call_command(self.COMMAND, "--organization_id", target.uuid) + mock_credly_api_client.assert_called_once_with(target.uuid) diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py index 879192f01..ff3096c1a 100644 --- a/credentials/apps/badges/tests/test_models.py +++ b/credentials/apps/badges/tests/test_models.py @@ -1,10 +1,12 @@ import uuid +from datetime import timedelta from unittest.mock import patch from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.contrib.sites.models import Site from django.test import TestCase +from django.utils import timezone from faker import Faker from openedx_events.learning.data import BadgeData, BadgeTemplateData, UserData, UserPersonalData @@ -584,6 +586,18 @@ def test_is_preconfigured(self): self.assertTrue(self.organization.is_preconfigured) mock_get_preconfigured.assert_called_once() + def test_authorization_token_expiry_unknown_without_dates(self): + self.assertIsNone(self.organization.authorization_token_expires_at) + self.assertIsNone(self.organization.authorization_token_days_until_expiry) + + def test_authorization_token_expiry_derived_from_dates(self): + self.organization.authorization_token_updated_at = timezone.now() - timedelta(days=170) + self.assertEqual(self.organization.authorization_token_days_until_expiry, 9) + + def test_authorization_token_expiry_falls_back_to_created_at(self): + self.organization.authorization_token_created_at = timezone.now() - timedelta(days=10) + self.assertEqual(self.organization.authorization_token_days_until_expiry, 169) + class BadgeProgressTestCase(TestCase): def setUp(self): From 179c141a787650a325334cfdd3cdc557b297f15a Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 14:57:19 +0200 Subject: [PATCH 2/9] docs: document Credly authorization token rotation Explain the 180-day token lifetime as part of the Credly setup docs, describe the refresh_credly_authorization_tokens management command, and recommend running it on a daily schedule. --- docs/sharing/badges/configuration/credly.rst | 55 +++++++++++++++++++- docs/sharing/badges/quickstart.rst | 4 ++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/sharing/badges/configuration/credly.rst b/docs/sharing/badges/configuration/credly.rst index be30a503a..5dd6e2ba6 100644 --- a/docs/sharing/badges/configuration/credly.rst +++ b/docs/sharing/badges/configuration/credly.rst @@ -18,11 +18,64 @@ To configure a Credly organization in Open edX Credentials, navigate to ``https: .. note:: - Credly API keys have a limited lifetime of 180 days. Rotate them before expiry. See `Auth Tokens for Authorization `_. + Credly API keys have a limited lifetime of 180 days. Rotate them before expiry — see :ref:`badges-credly-token-rotation`. The system pulls the Organization's details and updates its name. If errors occur, verify the API key and UUID for the Organization. +.. _badges-credly-token-rotation: + +API Key Rotation +---------------- + +Credly authorization tokens (API keys) expire 180 days after they are issued. +Once a token expires, badge issuing, revocation, and template synchronization for that Organization fail until you replace the key. + +Open edX Credentials records when each Organization's token was issued and last rotated, and ships the ``refresh_credly_authorization_tokens`` management command to keep tokens alive: + +- When a token expires in 30 days or fewer, the command logs a warning. +- When fewer than 5 days remain, the command rotates the token through the Credly API and stores the new value on the Credly Organization record. The previous token becomes invalid immediately. +- When the token issuance date is unknown (for example, the Organization was configured before token dates were tracked), the command logs a warning and does not rotate. Rotate such tokens once with ``--force`` to start tracking. + +Check all configured Organizations: + +.. code-block:: bash + + ./manage.py refresh_credly_authorization_tokens + +Check a single Organization: + +.. code-block:: bash + + ./manage.py refresh_credly_authorization_tokens --organization_id + +Rotate immediately, regardless of the remaining lifetime: + +.. code-block:: bash + + ./manage.py refresh_credly_authorization_tokens --force + +.. warning:: + + Rotation invalidates the current token on the Credly side. + If other services authenticate with the same Credly Organization token, they lose access until you share the new token with them. + +Scheduled Rotation +~~~~~~~~~~~~~~~~~~ + +Run the command on a schedule so tokens are checked and rotated without operator involvement. +A daily run is sufficient — the command only acts when a token approaches expiry. + +Use the scheduling mechanism of your deployment: a cron job on the Credentials host, a Kubernetes ``CronJob``, or your job runner of choice. +For example, with cron: + +.. code-block:: bash + + # Check Credly tokens every day at 03:00. + 0 3 * * * cd /path/to/credentials && ./manage.py refresh_credly_authorization_tokens + +Review the command output in your logs: warnings signal tokens nearing expiry, and errors signal failed rotations that need manual attention. + Badge Templates --------------- diff --git a/docs/sharing/badges/quickstart.rst b/docs/sharing/badges/quickstart.rst index 1b60622ef..671a6d35d 100644 --- a/docs/sharing/badges/quickstart.rst +++ b/docs/sharing/badges/quickstart.rst @@ -97,6 +97,10 @@ For **Credly**: #. Verify the system pulls the organization's data and updates its name. + .. note:: + + Credly API keys expire after 180 days. Schedule the ``refresh_credly_authorization_tokens`` command to rotate them automatically — see :ref:`badges-credly-token-rotation`. + For **Accredible**: From ed9646354600ade90da3b3753e03c73c5b22a448 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 15:10:24 +0200 Subject: [PATCH 3/9] docs: align API key rotation docs with Credly terminology Use the API key term consistently with the rest of the badges docs and the admin UI, bridge it once to Credly's authorization token naming, link the Credly rotation API reference and token lifecycle articles, and add a Tutor variant for the scheduled run. --- docs/sharing/badges/configuration/credly.rst | 37 +++++++++++++------- docs/sharing/badges/quickstart.rst | 2 +- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/docs/sharing/badges/configuration/credly.rst b/docs/sharing/badges/configuration/credly.rst index 5dd6e2ba6..091d8995a 100644 --- a/docs/sharing/badges/configuration/credly.rst +++ b/docs/sharing/badges/configuration/credly.rst @@ -18,7 +18,7 @@ To configure a Credly organization in Open edX Credentials, navigate to ``https: .. note:: - Credly API keys have a limited lifetime of 180 days. Rotate them before expiry — see :ref:`badges-credly-token-rotation`. + Credly API keys have a limited lifetime of 180 days. Rotate them before expiry. See :ref:`badges-credly-token-rotation`. The system pulls the Organization's details and updates its name. If errors occur, verify the API key and UUID for the Organization. @@ -28,14 +28,17 @@ If errors occur, verify the API key and UUID for the Organization. API Key Rotation ---------------- -Credly authorization tokens (API keys) expire 180 days after they are issued. -Once a token expires, badge issuing, revocation, and template synchronization for that Organization fail until you replace the key. +Credly API keys expire 180 days after they are issued. +Credly calls this key an *authorization token*, which is why the management command below is named ``refresh_credly_authorization_tokens``. +See `Auth Tokens for Authorization `__ for the Credly-side token lifecycle. -Open edX Credentials records when each Organization's token was issued and last rotated, and ships the ``refresh_credly_authorization_tokens`` management command to keep tokens alive: +Once an API key expires, issuing badges, revoking badges, and synchronizing badge templates for that Organization fail until you replace the key. -- When a token expires in 30 days or fewer, the command logs a warning. -- When fewer than 5 days remain, the command rotates the token through the Credly API and stores the new value on the Credly Organization record. The previous token becomes invalid immediately. -- When the token issuance date is unknown (for example, the Organization was configured before token dates were tracked), the command logs a warning and does not rotate. Rotate such tokens once with ``--force`` to start tracking. +Open edX Credentials records when each Organization's API key was issued and last rotated, and provides the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire: + +- When a key expires in 30 days or fewer, the command logs a warning. +- When fewer than 5 days remain, the command rotates the key through the Credly `rotate authorization token `__ API and stores the new value on the Credly Organization record. As described in `Credly Authentication Methods `__, the previous key immediately becomes unavailable for use. +- When the key issuance date is unknown, the command logs a warning and does not rotate. This applies to every Organization configured before Open edX Credentials started tracking key dates. Rotate such keys once with ``--force`` to record the issuance date and enable automatic rotation from then on. Check all configured Organizations: @@ -57,24 +60,32 @@ Rotate immediately, regardless of the remaining lifetime: .. warning:: - Rotation invalidates the current token on the Credly side. - If other services authenticate with the same Credly Organization token, they lose access until you share the new token with them. + Rotation invalidates the current API key on the Credly side. + If other services authenticate with the same Credly Organization key, they lose access until you share the new key with them. Scheduled Rotation ~~~~~~~~~~~~~~~~~~ -Run the command on a schedule so tokens are checked and rotated without operator involvement. -A daily run is sufficient — the command only acts when a token approaches expiry. +Run the command on a schedule so API keys are checked and rotated without operator involvement. +A daily run is sufficient because the command only acts when a key approaches expiry. Use the scheduling mechanism of your deployment: a cron job on the Credentials host, a Kubernetes ``CronJob``, or your job runner of choice. For example, with cron: .. code-block:: bash - # Check Credly tokens every day at 03:00. + # Check Credly API keys every day at 03:00. 0 3 * * * cd /path/to/credentials && ./manage.py refresh_credly_authorization_tokens -Review the command output in your logs: warnings signal tokens nearing expiry, and errors signal failed rotations that need manual attention. +.. note:: + + With Tutor, run the command inside the Credentials container: + + .. code-block:: bash + + tutor local run credentials ./manage.py refresh_credly_authorization_tokens + +Review the command output in your logs: warnings signal keys nearing expiry, and errors signal failed rotations that need manual attention. Badge Templates --------------- diff --git a/docs/sharing/badges/quickstart.rst b/docs/sharing/badges/quickstart.rst index 671a6d35d..e257b5d27 100644 --- a/docs/sharing/badges/quickstart.rst +++ b/docs/sharing/badges/quickstart.rst @@ -99,7 +99,7 @@ For **Credly**: .. note:: - Credly API keys expire after 180 days. Schedule the ``refresh_credly_authorization_tokens`` command to rotate them automatically — see :ref:`badges-credly-token-rotation`. + Credly API keys expire after 180 days. Schedule the ``refresh_credly_authorization_tokens`` command to rotate them automatically. See :ref:`badges-credly-token-rotation`. For **Accredible**: From 453e2f6c41a2ffbdf4dc7d1f3899e02499035137 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 15:38:00 +0200 Subject: [PATCH 4/9] refactor: track token issuance with a single datetime field Expiry is always derived from the latest issuance/rotation moment, so a separate first-issued timestamp adds no behavior. Replace the authorization_token_created_at/authorization_token_updated_at pair with a single authorization_token_issued_at field. --- credentials/apps/badges/credly/api_client.py | 16 ++-------- ...yorganization_authorization_token_dates.py | 29 ------------------- ...anization_authorization_token_issued_at.py | 22 ++++++++++++++ credentials/apps/badges/models.py | 18 ++++-------- .../apps/badges/tests/test_api_client.py | 3 +- .../badges/tests/test_management_commands.py | 4 +-- credentials/apps/badges/tests/test_models.py | 10 ++----- 7 files changed, 37 insertions(+), 65 deletions(-) delete mode 100644 credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py create mode 100644 credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py diff --git a/credentials/apps/badges/credly/api_client.py b/credentials/apps/badges/credly/api_client.py index a17155cfd..e25610c11 100644 --- a/credentials/apps/badges/credly/api_client.py +++ b/credentials/apps/badges/credly/api_client.py @@ -156,7 +156,7 @@ def rotate_authorization_token(self): Calls Credly's token rotation endpoint, which generates and returns a new authorization token and immediately invalidates the token used to make the - request. The new token and its refresh timestamps are persisted on the + request. The new token and its issuance timestamp are persisted on the related CredlyOrganization. Returns: @@ -171,19 +171,9 @@ def rotate_authorization_token(self): raise CredlyError("Credly token rotation response did not contain a new token.") organization = self._get_organization(self.organization_id) - now = timezone.now() - if organization.authorization_token_created_at is None: - organization.authorization_token_created_at = now - organization.authorization_token_updated_at = now + organization.authorization_token_issued_at = timezone.now() organization.api_key = new_token - organization.save( - update_fields=[ - "api_key", - "authorization_token_created_at", - "authorization_token_updated_at", - "modified", - ] - ) + organization.save(update_fields=["api_key", "authorization_token_issued_at", "modified"]) # Adopt the new token for any subsequent requests and drop the cached header. self.api_key = new_token diff --git a/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py deleted file mode 100644 index 5bdef1bcf..000000000 --- a/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_dates.py +++ /dev/null @@ -1,29 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('badges', '0002_accredibleapiconfig_accrediblebadge_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='credlyorganization', - name='authorization_token_created_at', - field=models.DateTimeField( - blank=True, - help_text='When the current Credly authorization token was first issued.', - null=True, - ), - ), - migrations.AddField( - model_name='credlyorganization', - name='authorization_token_updated_at', - field=models.DateTimeField( - blank=True, - help_text='When the current Credly authorization token was last refreshed (rotated).', - null=True, - ), - ), - ] diff --git a/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py new file mode 100644 index 000000000..ef793303a --- /dev/null +++ b/credentials/apps/badges/migrations/0003_credlyorganization_authorization_token_issued_at.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.11 on 2026-08-10 13:37 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("badges", "0002_accredibleapiconfig_accrediblebadge_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="credlyorganization", + name="authorization_token_issued_at", + field=models.DateTimeField( + blank=True, + help_text="When the current Credly authorization token was issued or last rotated.", + null=True, + ), + ), + ] diff --git a/credentials/apps/badges/models.py b/credentials/apps/badges/models.py index 900ca3289..bfada7179 100644 --- a/credentials/apps/badges/models.py +++ b/credentials/apps/badges/models.py @@ -52,15 +52,10 @@ class CredlyOrganization(TimeStampedModel): blank=True, help_text=_("Verbose name for Credly Organization."), ) - authorization_token_created_at = models.DateTimeField( + authorization_token_issued_at = models.DateTimeField( null=True, blank=True, - help_text=_("When the current Credly authorization token was first issued."), - ) - authorization_token_updated_at = models.DateTimeField( - null=True, - blank=True, - help_text=_("When the current Credly authorization token was last refreshed (rotated)."), + help_text=_("When the current Credly authorization token was issued or last rotated."), ) def __str__(self): @@ -71,13 +66,12 @@ def authorization_token_expires_at(self): """ Estimated expiration datetime of the current authorization token. - Derived from the most recent issuance/refresh timestamp plus the token lifetime. - Returns None when no issuance/refresh date has been recorded yet. + Derived from the recorded issuance timestamp plus the token lifetime. + Returns None when no issuance date has been recorded yet. """ - issued_at = self.authorization_token_updated_at or self.authorization_token_created_at - if issued_at is None: + if self.authorization_token_issued_at is None: return None - return issued_at + self.AUTHORIZATION_TOKEN_LIFETIME + return self.authorization_token_issued_at + self.AUTHORIZATION_TOKEN_LIFETIME @property def authorization_token_days_until_expiry(self): diff --git a/credentials/apps/badges/tests/test_api_client.py b/credentials/apps/badges/tests/test_api_client.py index 54bf7499b..144d0b66d 100644 --- a/credentials/apps/badges/tests/test_api_client.py +++ b/credentials/apps/badges/tests/test_api_client.py @@ -116,8 +116,7 @@ def test_rotate_authorization_token(self): self.organization.refresh_from_db() self.assertEqual(self.organization.api_key, "new-token") - self.assertIsNotNone(self.organization.authorization_token_created_at) - self.assertIsNotNone(self.organization.authorization_token_updated_at) + self.assertIsNotNone(self.organization.authorization_token_issued_at) def test_rotate_authorization_token_no_token_in_response(self): api_client = CredlyAPIClient(self.organization.uuid) diff --git a/credentials/apps/badges/tests/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py index 906d2a1d9..77d4e6f89 100644 --- a/credentials/apps/badges/tests/test_management_commands.py +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -58,7 +58,7 @@ class TestRefreshCredlyAuthorizationTokensCommand(TestCase): """ Tests for the ``refresh_credly_authorization_tokens`` management command. - Token expiration is derived from ``authorization_token_updated_at`` plus the 180-day lifetime, so + Token expiration is derived from ``authorization_token_issued_at`` plus the 180-day lifetime, so a token with ``days_left`` remaining is simulated by backdating that field accordingly. A 12-hour buffer is added to keep the truncated day count off threshold boundaries. """ @@ -74,7 +74,7 @@ def _make_organization(self, days_left=None): ) if days_left is not None: lifetime = CredlyOrganization.AUTHORIZATION_TOKEN_LIFETIME - organization.authorization_token_updated_at = ( + organization.authorization_token_issued_at = ( timezone.now() - lifetime + timedelta(days=days_left, hours=12) ) organization.save() diff --git a/credentials/apps/badges/tests/test_models.py b/credentials/apps/badges/tests/test_models.py index ff3096c1a..4c60be6d6 100644 --- a/credentials/apps/badges/tests/test_models.py +++ b/credentials/apps/badges/tests/test_models.py @@ -586,18 +586,14 @@ def test_is_preconfigured(self): self.assertTrue(self.organization.is_preconfigured) mock_get_preconfigured.assert_called_once() - def test_authorization_token_expiry_unknown_without_dates(self): + def test_authorization_token_expiry_unknown_without_issuance_date(self): self.assertIsNone(self.organization.authorization_token_expires_at) self.assertIsNone(self.organization.authorization_token_days_until_expiry) - def test_authorization_token_expiry_derived_from_dates(self): - self.organization.authorization_token_updated_at = timezone.now() - timedelta(days=170) + def test_authorization_token_expiry_derived_from_issuance_date(self): + self.organization.authorization_token_issued_at = timezone.now() - timedelta(days=170) self.assertEqual(self.organization.authorization_token_days_until_expiry, 9) - def test_authorization_token_expiry_falls_back_to_created_at(self): - self.organization.authorization_token_created_at = timezone.now() - timedelta(days=10) - self.assertEqual(self.organization.authorization_token_days_until_expiry, 169) - class BadgeProgressTestCase(TestCase): def setUp(self): From 7d69b8d5cf8c254b105a39ea766a3bbd27fb287b Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 15:53:20 +0200 Subject: [PATCH 5/9] docs: restructure API key rotation section Drop the token naming aside, introduce the command behavior list with a proper lead-in, show Tutor and direct invocations like other pages do, describe the command options as a definition list, and keep the scheduled rotation guidance without scheduler-specific examples. --- docs/sharing/badges/configuration/credly.rst | 48 ++++++++------------ 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/docs/sharing/badges/configuration/credly.rst b/docs/sharing/badges/configuration/credly.rst index 091d8995a..ccabc00eb 100644 --- a/docs/sharing/badges/configuration/credly.rst +++ b/docs/sharing/badges/configuration/credly.rst @@ -29,34 +29,39 @@ API Key Rotation ---------------- Credly API keys expire 180 days after they are issued. -Credly calls this key an *authorization token*, which is why the management command below is named ``refresh_credly_authorization_tokens``. See `Auth Tokens for Authorization `__ for the Credly-side token lifecycle. Once an API key expires, issuing badges, revoking badges, and synchronizing badge templates for that Organization fail until you replace the key. -Open edX Credentials records when each Organization's API key was issued and last rotated, and provides the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire: +Open edX Credentials records when each Organization's API key was issued or last rotated, and provides the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire. -- When a key expires in 30 days or fewer, the command logs a warning. -- When fewer than 5 days remain, the command rotates the key through the Credly `rotate authorization token `__ API and stores the new value on the Credly Organization record. As described in `Credly Authentication Methods `__, the previous key immediately becomes unavailable for use. -- When the key issuance date is unknown, the command logs a warning and does not rotate. This applies to every Organization configured before Open edX Credentials started tracking key dates. Rotate such keys once with ``--force`` to record the issuance date and enable automatic rotation from then on. +For each checked Organization, the command: -Check all configured Organizations: +- Logs a warning when the key expires in 30 days or fewer. +- Rotates the key through the Credly `rotate authorization token `__ API when fewer than 5 days remain, and stores the new value on the Credly Organization record. As described in `Credly Authentication Methods `__, the previous key immediately becomes unavailable for use. +- Logs a warning and does not rotate when the key issuance date is unknown. This applies to every Organization configured before Open edX Credentials started tracking key dates. Rotate such keys once with ``--force`` to record the issuance date and enable automatic rotation from then on. + +If you are using Tutor: .. code-block:: bash - ./manage.py refresh_credly_authorization_tokens + tutor local exec credentials ./manage.py refresh_credly_authorization_tokens -Check a single Organization: +For other installations, run the command directly in the Credentials service: .. code-block:: bash - ./manage.py refresh_credly_authorization_tokens --organization_id + ./manage.py refresh_credly_authorization_tokens -Rotate immediately, regardless of the remaining lifetime: +By default, the command checks every configured Organization and rotates only the keys that are close to expiry. +The following options change this behavior: -.. code-block:: bash +``--organization_id `` + Check and rotate the key of a single Organization instead of all configured Organizations. - ./manage.py refresh_credly_authorization_tokens --force +``--force`` + Rotate keys immediately, regardless of the remaining lifetime. + Without ``--organization_id``, this rotates the keys of all configured Organizations at once. .. warning:: @@ -66,24 +71,9 @@ Rotate immediately, regardless of the remaining lifetime: Scheduled Rotation ~~~~~~~~~~~~~~~~~~ -Run the command on a schedule so API keys are checked and rotated without operator involvement. +Run the command on a daily schedule so API keys are checked and rotated without operator involvement. A daily run is sufficient because the command only acts when a key approaches expiry. - -Use the scheduling mechanism of your deployment: a cron job on the Credentials host, a Kubernetes ``CronJob``, or your job runner of choice. -For example, with cron: - -.. code-block:: bash - - # Check Credly API keys every day at 03:00. - 0 3 * * * cd /path/to/credentials && ./manage.py refresh_credly_authorization_tokens - -.. note:: - - With Tutor, run the command inside the Credentials container: - - .. code-block:: bash - - tutor local run credentials ./manage.py refresh_credly_authorization_tokens +Use the scheduling mechanism of your deployment, such as a cron job on the Credentials host or a Kubernetes ``CronJob``. Review the command output in your logs: warnings signal keys nearing expiry, and errors signal failed rotations that need manual attention. From f30e5bf0a0644eae89a85ad7e17e2ca4f55c57af Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 15:57:48 +0200 Subject: [PATCH 6/9] docs: turn quickstart key expiry note into a regular paragraph --- docs/sharing/badges/quickstart.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/sharing/badges/quickstart.rst b/docs/sharing/badges/quickstart.rst index e257b5d27..00bf360f2 100644 --- a/docs/sharing/badges/quickstart.rst +++ b/docs/sharing/badges/quickstart.rst @@ -97,9 +97,9 @@ For **Credly**: #. Verify the system pulls the organization's data and updates its name. - .. note:: - - Credly API keys expire after 180 days. Schedule the ``refresh_credly_authorization_tokens`` command to rotate them automatically. See :ref:`badges-credly-token-rotation`. +The API key you configured expires 180 days after Credly issues it. +Schedule the ``refresh_credly_authorization_tokens`` management command to rotate keys before they expire, so the integration keeps working without manual key updates. +See :ref:`badges-credly-token-rotation` for details. For **Accredible**: From 9ed474161a0b2526c57dcb457d3113473a072a0f Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 15:58:30 +0200 Subject: [PATCH 7/9] docs: turn configuration page key expiry note into a regular paragraph --- docs/sharing/badges/configuration/credly.rst | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/sharing/badges/configuration/credly.rst b/docs/sharing/badges/configuration/credly.rst index ccabc00eb..7f1dca030 100644 --- a/docs/sharing/badges/configuration/credly.rst +++ b/docs/sharing/badges/configuration/credly.rst @@ -16,13 +16,12 @@ To configure a Credly organization in Open edX Credentials, navigate to ``https: #. Set the **UUID** to your Credly Organization identifier. #. Set the **API key** used to authenticate with the Credly Organization. -.. note:: - - Credly API keys have a limited lifetime of 180 days. Rotate them before expiry. See :ref:`badges-credly-token-rotation`. - The system pulls the Organization's details and updates its name. If errors occur, verify the API key and UUID for the Organization. +The API key you configured expires 180 days after Credly issues it. +Rotate keys before they expire, as described in :ref:`badges-credly-token-rotation` below. + .. _badges-credly-token-rotation: API Key Rotation From 295357312af5790f425b696e29012aac9f88e078 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 16:08:09 +0200 Subject: [PATCH 8/9] chore: update source translations for the token issuance help text --- .../conf/locale/en/LC_MESSAGES/django.po | 5 +++++ .../conf/locale/eo/LC_MESSAGES/django.mo | Bin 42333 -> 42612 bytes .../conf/locale/eo/LC_MESSAGES/django.po | 7 +++++++ .../conf/locale/eo/LC_MESSAGES/djangojs.mo | Bin 543 -> 535 bytes .../conf/locale/rtl/LC_MESSAGES/django.mo | Bin 28074 -> 28295 bytes .../conf/locale/rtl/LC_MESSAGES/django.po | 6 ++++++ .../conf/locale/rtl/LC_MESSAGES/djangojs.mo | Bin 504 -> 496 bytes 7 files changed, 18 insertions(+) diff --git a/credentials/conf/locale/en/LC_MESSAGES/django.po b/credentials/conf/locale/en/LC_MESSAGES/django.po index 1fafa911d..155b59b1d 100644 --- a/credentials/conf/locale/en/LC_MESSAGES/django.po +++ b/credentials/conf/locale/en/LC_MESSAGES/django.po @@ -98,6 +98,11 @@ msgstr "" msgid "Verbose name for Credly Organization." msgstr "" +#: apps/badges/models.py +msgid "" +"When the current Credly authorization token was issued or last rotated." +msgstr "" + #: apps/badges/models.py msgid "Unique badge template ID." msgstr "" diff --git a/credentials/conf/locale/eo/LC_MESSAGES/django.mo b/credentials/conf/locale/eo/LC_MESSAGES/django.mo index 38f651d08c5ea49584d05ed3cd39859965b72b16..26b1a423571062a16f4249215500c3949b9dddf9 100644 GIT binary patch delta 4717 zcmZA42~-u=8Nl&Rz`8^PB<>=kaYs=>RDvQXf>A(`2yy2rPe4HQ@vKYL2QG0-U82+& zF_nsn3t~`dZBVOY)f%#sQH|Aqr7H}4|S7Kh+q9D_mBFC}F30X}>QcVinofOJA? zF&(d98irD9IJz(z%W*WGMJ|zNI27A5pS>{w_51?V`^u3^qzZjH7w5_34`k*_GU!qpjl6iU+U%^bAgQKt>bp)THf!}s#{BrLR`5I2HS=fGq5cnQ$0B)XYfM6|XL1jp zy=td(Vgx6SA%h?fk!h1}k!hFCtVo@^DAWoPQ9sW>e=NjQEI~Raji?QHfV$Y8VoMBT z#p>sM(7^FNGJ3%R)G64FTG0#S9%;jp)onKzb!}u}AkK4Kj2_BsPaF;$P1XC~uTj6TtPd0MU-SR$a zPp)Dx-p7IX7OU zYDHh+IP~XYU0f-s_buZPfYqoK9z|VrCdT8(s1_z7ymA7fj5fjZ}Hcn@ni9DU8nM3T{# z55txi>y*c$ex8E5n$s{AUDyiup;mMV_5NDS!Ftp+6v|sjua8FEUB##kK7{4?`B29H zE}8jJ_O^?O7P(G&D&E7c!|ZMHSDZ#UC`M#qGZ8n=^P|VZ?OhX1Evxvv5_e)}-abe0 zIG)5zYHdV6-a>l*BGzEuXvTjlnPA3!16Ja8>^X*c#Cp`#ot?k}#MQVMpX2LTlxT0i zOSp~lFqS%x6cclC91CS5*5Cmgk|eSj@1h51`o`N2zK!~T)MWeX^8x;h^6(Va13txc zTsMK&v_GSR+VG31_M2}~8Y`Oe4qSl!xOggV$1;3~+K~B^?ekA?6J=lg6nlSOL%pDN z25%*N8?}eOMcuE1c$&V`7vOZP!jt$MPvEgkk=dBdO+Uo5cm&5WZ#oiBumY!K@v_3l z$a$Z*vqefdaRUo5{tbG8HFyDIsf}mLHyDnoGkAMp5o$v|#F^Ne1CNk$T!r)s>`61R}2ot#vFQJaiY`ViUwXj4SaQ^z|oGTF5sS?nj-9z#@^iupD&? z+7|O`1#hADFp#H};d+e0=G@GVk~oaR<#-#0yuS`T8`VUYW)`$7}2fN{q*hu;J%k3WTU12vcm2PTJZ*f?S z6KKUoyo%MBzKZcbK<3#hkssl1uF@Vwtg#o93rA8uhy2M`9O`fh_YKA#Wp=|da4O}? zn1Joe?J3E^wUqbadhAYtHp)SqhuwVZn13>=KHv$C$9S&3gzHgToV>waBe(G~WqH$H z{U)YR4CA6>=*G7(c$57;Ifxx7M{Txu!E2aBxfW~C7qG>Cwbr7G6aKta%FvB8QXb<$ zEMP%w!$3B-_Mj5=`u3chg3ItEUdKaP-cBO|XxI+c0p-Zu_79UUP`7DIwm^dJ{~R*f z^D0cluTi(vaOR^EW@0}q!j4#h`gtwteK(Ll3EN{&$r9IqpnTS<2h zw^`qs)MKT|j>-*UByoh$1-*y*tzSFZ0G@Bn9aw26$M>APa+LTrF^UKzno<}U?U|+_ zL^?5%U@2JJ4y-ht<1*qD(UV}N#CM5<&f86b_l(pMI($DR%836@oj7rV_!W^rG^JZ) z4m*Vdj=|WQNFmta);fy8NyHd}rJ(({Un*9HL)WH^qt05t~Nl z5}G1ytfMYOlekUHw9eSSl$Mj9LwL0Qvt*c7VFUaxttZ!x(0SIyrHNOA^_9#^(faR5 zo8F2@BT9(N#419QLA+1AL5wCeEhX*{gNfOMZa}Ju42}v^^++I zFA$kTN1}pwiP%A85p9U3lyE^w zA(t(4l^O-5rAytpMv2ELa+P`wPl?y%b>~K&j);uOnUS4u9@VFrM-B6c+34|5^cvqX?KSJn1}--n3iY;SW_77y)|Hl;ja+VUl~Keg>M$F;yFMK~ L?R3tz7ajf!ie-Ny delta 4488 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 cXO;HrEmG>wqa4D@Q&JksnViIDIgLMa z3@>e&(nxONnLNtgY}YEKvr@{XF)9NbSjp7m^ zu_K4MdSBLYFxS#s=>t~tYj$CUd5z;E8Q-g;x6+-=#aygcxrX~_v{OIhZW*MpG$?5b zqa)WA+IJS(>li0`uHZh#g}&r@Y+$~oatH_UA;t(EU00V*T3u+rz}S8t zaX5?3R}Dw;T;4-BluNIvM9=m!I`BE;MBi`}TRBV2)eOc7XM2g=vW9UdPqR69vM1l9 zucq%9BiP>Fi>VsQxS`7!-@lb@WB;#IiN&{>ae~So*-%bk-1%jU4!+E|&|!|?e;A9a zt1rd(F7Z;#WsD2n%UE=qID{`TF8l*y#G9D+F0P-dRAP?DF+O+`^(8$|>nKBvB>Qg22F&SMSlpjk?LIE=?w%RVkL zoU7;_(qZarD$Nbe9zZpWHL!-O_#)$uM%QE`@fiQ9{Sv*E9(49Le4o?!B`t$A!c{7H zBWw5wf5i_A$2;n693RGV=I&DQUV4J<`6;6#Kd~h{8K>|Z<`*gFY2U=_`8i{;PQD-; ziA9{H{VFY&R5dcC3wbePq_;3Wf0RZbms%Nj>mg0xI4xTj?SJ7kF!d9GduJB zLi;;*)o$xHV+4j5oX_aMM%MBmm-Eaq*@*38d902WW3xY_J?LgqE%&gVjd_(`#fj%L zI=YdKc$l%xKBo1UzU5q=F+O{gKgO7%q6yiqXvay~0~vo+SF*Y5r+Ss9+{xSc1~27c z3!n!dWenL~cH=R&V{2z?%d;5ok7qG&?(sQpwb8RS#i2*_JTwoP3!ETmORE7;uC(I-FXkj?ikLPs=L{oCDXGfTWfaGp1?Mo&lr*W zX-B3_jPHNJxRGu%vR}cqtkzzgQ;F5Qm$7gE%Xe6IdG@pU1IAFct@WdVz1WlQGDhxu zUeDGuv)_hGIZpc!OWC0=yP+z^2v6kIyqU3^a-XQg9W=Ni+n-ehXHln9J#BN&tYIo-bRHQ57cJ-5cBeyh@4&o|D= zejseW*05|;M#Z>xYha3C>VGSKlk4kj>cv(@X!~%=*C$W>$15zTx<+l=}66HCm zcJ2ILjsvfXt(yN6iTp5zQEY&G+2?huES1<1%VnO#oZlz0d+w3r#YV_K9Y;J|)bx~G zJQZ%0?S;4h$Ttf0;6`~#9*>4HS9(c>?3Z%!7|lP(U*oefSe};{>$_!#+$B-Y%gE2) z-(#}PS-x!XYCiItcQ+SEtg63Bl)p%o*r)kN;27HD`LfXK`C{sl#g;Eky~g%9M}8-5 zrIkcEJHvkTjkh)W77ldbGh&6O*JY%Pm-TXic(~-tpS?aL4dsOVk?f^2{wj;*N{P9Q zGE`!3?vhw>QJTw}(c>HB45^j&a=h$Si6_zVa=pqaGUfOIT9Em@6Ne7SD^f3aNR-hM zkG+#+mYgc~Sb9%NrCRbO9(6Cv<eu$_BYt zPLgIaO`9%d9S@wmr~QscdT;NyWA|12%l;1vQS*WT diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/django.po b/credentials/conf/locale/rtl/LC_MESSAGES/django.po index 10b56b737..01cf182af 100644 --- a/credentials/conf/locale/rtl/LC_MESSAGES/django.po +++ b/credentials/conf/locale/rtl/LC_MESSAGES/django.po @@ -103,6 +103,12 @@ msgstr "Ȼɹǝdlʎ ȺⱣƗ sɥɐɹǝd sǝɔɹǝʇ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉ msgid "Verbose name for Credly Organization." msgstr "Vǝɹbøsǝ nɐɯǝ ɟøɹ Ȼɹǝdlʎ Øɹƃɐnᴉzɐʇᴉøn." +#: apps/badges/models.py +msgid "" +"When the current Credly authorization token was issued or last rotated." +msgstr "" +"Wɥǝn ʇɥǝ ɔnɹɹǝnʇ Ȼɹǝdlʎ ɐnʇɥøɹᴉzɐʇᴉøn ʇøʞǝn ʍɐs ᴉssnǝd øɹ lɐsʇ ɹøʇɐʇǝd." + #: apps/badges/models.py msgid "Unique badge template ID." msgstr "Ʉnᴉbnǝ bɐdƃǝ ʇǝɯdlɐʇǝ ƗĐ." diff --git a/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/credentials/conf/locale/rtl/LC_MESSAGES/djangojs.mo index 71d10356e7cc079a8f8487e204ad3181ebe94d95..855ece954949f9ea737788986f14876706ba2d52 100644 GIT binary patch delta 56 xcmeyt{DFCbh%Pe&1A`3^gMbGR%K)(_5N9(oFr)!#Ss=ayWCIZc(?-wri~!na2O9tY delta 64 zcmeys{DXOdh$$-r1A`3^gFpZf%K&j85N9(oFysMgSs=azWCIZxFoDR8j_Vl#3@`^h From 371c37941ee5b3783463e6b4432eac8214842422 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Mon, 10 Aug 2026 16:16:25 +0200 Subject: [PATCH 9/9] style: apply black formatting --- .../commands/refresh_credly_authorization_tokens.py | 4 +--- credentials/apps/badges/tests/test_management_commands.py | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py index 09fb55c4f..3d8c570f4 100644 --- a/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py +++ b/credentials/apps/badges/management/commands/refresh_credly_authorization_tokens.py @@ -87,9 +87,7 @@ def _process_organization(self, organization, force=False): f"It will be rotated automatically once fewer than {REFRESH_THRESHOLD_DAYS} days remain." ) else: - logger.info( - f"Organization {organization.uuid}: authorization token is healthy ({days_left} day(s) left)." - ) + logger.info(f"Organization {organization.uuid}: authorization token is healthy ({days_left} day(s) left).") def _rotate(self, organization): """ diff --git a/credentials/apps/badges/tests/test_management_commands.py b/credentials/apps/badges/tests/test_management_commands.py index 77d4e6f89..2bab5c92b 100644 --- a/credentials/apps/badges/tests/test_management_commands.py +++ b/credentials/apps/badges/tests/test_management_commands.py @@ -51,9 +51,7 @@ def test_handle_with_api_config_id(self, mock_accredible_api_client): mock_accredible_api_client.return_value.sync_groups.assert_called_once_with(1) -@mock.patch( - "credentials.apps.badges.management.commands.refresh_credly_authorization_tokens.CredlyAPIClient" -) +@mock.patch("credentials.apps.badges.management.commands.refresh_credly_authorization_tokens.CredlyAPIClient") class TestRefreshCredlyAuthorizationTokensCommand(TestCase): """ Tests for the ``refresh_credly_authorization_tokens`` management command. @@ -74,9 +72,7 @@ def _make_organization(self, days_left=None): ) if days_left is not None: lifetime = CredlyOrganization.AUTHORIZATION_TOKEN_LIFETIME - organization.authorization_token_issued_at = ( - timezone.now() - lifetime + timedelta(days=days_left, hours=12) - ) + organization.authorization_token_issued_at = timezone.now() - lifetime + timedelta(days=days_left, hours=12) organization.save() # Reload so ``organization.uuid`` is a UUID instance, as the command sees it when querying the DB. organization.refresh_from_db()