learning-credentials 0.4.1rc6__py3-none-any.whl → 0.5.0rc3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,10 +4,10 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import logging
7
- import uuid
7
+ import uuid as uuid_lib
8
8
  from importlib import import_module
9
9
  from pathlib import Path
10
- from typing import TYPE_CHECKING
10
+ from typing import TYPE_CHECKING, Self
11
11
 
12
12
  import jsonfield
13
13
  from django.conf import settings
@@ -16,6 +16,7 @@ from django.core.exceptions import ValidationError
16
16
  from django.db import models
17
17
  from django.db.models.signals import post_delete
18
18
  from django.dispatch import receiver
19
+ from django.utils import timezone
19
20
  from django.utils.translation import gettext_lazy as _
20
21
  from django_celery_beat.models import IntervalSchedule, PeriodicTask
21
22
  from edx_ace import Message, Recipient, ace
@@ -147,9 +148,8 @@ class CredentialConfiguration(TimeStampedModel):
147
148
  self.periodic_task.args = json.dumps([self.id])
148
149
  self.periodic_task.save()
149
150
 
150
- # Replace the return type with `QuerySet[Self]` after migrating to Python 3.10+.
151
151
  @classmethod
152
- def get_enabled_configurations(cls) -> QuerySet[CredentialConfiguration]:
152
+ def get_enabled_configurations(cls) -> QuerySet[Self]:
153
153
  """
154
154
  Get the list of enabled configurations.
155
155
 
@@ -176,8 +176,7 @@ class CredentialConfiguration(TimeStampedModel):
176
176
  2. Have such a credential with an error status.
177
177
  """
178
178
  users_ids_with_credentials = Credential.objects.filter(
179
- models.Q(learning_context_key=self.learning_context_key),
180
- models.Q(credential_type=self.credential_type),
179
+ models.Q(configuration=self),
181
180
  ~(models.Q(status=Credential.Status.ERROR)),
182
181
  ).values_list('user_id', flat=True)
183
182
 
@@ -198,7 +197,7 @@ class CredentialConfiguration(TimeStampedModel):
198
197
  custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
199
198
  return func(self.learning_context_key, custom_options)
200
199
 
201
- def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
200
+ def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
202
201
  """
203
202
  Celery task for processing a single user's credential.
204
203
 
@@ -209,19 +208,23 @@ class CredentialConfiguration(TimeStampedModel):
209
208
  Args:
210
209
  user_id: The ID of the user to process the credential for.
211
210
  celery_task_id (optional): The ID of the Celery task that is running this function.
211
+
212
+ Returns:
213
+ The generated Credential object.
212
214
  """
213
215
  user = get_user_model().objects.get(id=user_id)
214
216
  # Use the name from the profile if it is not empty. Otherwise, use the first and last name.
215
217
  # We check if the profile exists because it may not exist in some cases (e.g., when a User is created manually).
216
218
  user_full_name = getattr(getattr(user, 'profile', None), 'name', f"{user.first_name} {user.last_name}")
219
+ learning_context_name = get_learning_context_name(self.learning_context_key)
217
220
  custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
218
221
 
219
- credential, _ = Credential.objects.update_or_create(
220
- user_id=user_id,
221
- learning_context_key=self.learning_context_key,
222
- credential_type=self.credential_type.name,
222
+ credential, _ = Credential.objects.exclude(status=Credential.Status.INVALIDATED).update_or_create(
223
+ user=user,
224
+ configuration=self,
223
225
  defaults={
224
226
  'user_full_name': user_full_name,
227
+ 'learning_context_name': learning_context_name,
225
228
  'status': Credential.Status.GENERATING,
226
229
  'generation_task_id': celery_task_id,
227
230
  },
@@ -233,13 +236,13 @@ class CredentialConfiguration(TimeStampedModel):
233
236
  generation_func = getattr(generation_module, generation_func_name)
234
237
 
235
238
  # Run the functions. We do not validate them here, as they are validated in the model's clean() method.
236
- credential.download_url = generation_func(self.learning_context_key, user, credential.uuid, custom_options)
239
+ credential.download_url = generation_func(credential, custom_options)
237
240
  credential.status = Credential.Status.AVAILABLE
238
241
  credential.save()
239
242
  except Exception as exc:
240
243
  credential.status = Credential.Status.ERROR
241
244
  credential.save()
242
- msg = f'Failed to generate the {credential.uuid=} for {user_id=} with {self.id=}.'
245
+ msg = f'Failed to generate the {credential.uuid=} for {user_id=} with {self.id=}.\nReason: {exc}'
243
246
  raise CredentialGenerationError(msg) from exc
244
247
  else:
245
248
  # TODO: In the future, we want to check this before generating the credential.
@@ -247,6 +250,8 @@ class CredentialConfiguration(TimeStampedModel):
247
250
  if user.is_active and user.has_usable_password():
248
251
  credential.send_email()
249
252
 
253
+ return credential
254
+
250
255
 
251
256
  # noinspection PyUnusedLocal
252
257
  @receiver(post_delete, sender=CredentialConfiguration)
@@ -281,17 +286,38 @@ class Credential(TimeStampedModel):
281
286
 
282
287
  uuid = models.UUIDField(
283
288
  primary_key=True,
284
- default=uuid.uuid4,
289
+ default=uuid_lib.uuid4,
285
290
  editable=False,
286
291
  help_text=_('Auto-generated UUID of the credential'),
287
292
  )
288
- user_id = models.IntegerField(help_text=_('ID of the user receiving the credential'))
289
- user_full_name = models.CharField(max_length=255, help_text=_('User receiving the credential'))
290
- learning_context_key = LearningContextKeyField(
293
+ verify_uuid = models.UUIDField(
294
+ default=uuid_lib.uuid4,
295
+ editable=False,
296
+ help_text=_('UUID used for verifying the credential'),
297
+ )
298
+ user = models.ForeignKey(
299
+ settings.AUTH_USER_MODEL,
300
+ on_delete=models.CASCADE,
301
+ help_text=_('User receiving the credential'),
302
+ )
303
+ user_full_name = models.CharField(
291
304
  max_length=255,
292
- help_text=_('ID of a learning context (e.g., a course or a Learning Path) for which the credential was issued'),
305
+ editable=False,
306
+ help_text=_('User receiving the credential. This field is used for validation purposes.'),
307
+ )
308
+ learning_context_name = models.CharField(
309
+ max_length=255,
310
+ editable=False,
311
+ help_text=_(
312
+ 'Name of the learning context for which the credential was issued. '
313
+ 'This field is used for validation purposes.'
314
+ ),
315
+ )
316
+ configuration = models.ForeignKey(
317
+ 'CredentialConfiguration',
318
+ on_delete=models.PROTECT,
319
+ help_text=_('Associated credential configuration'),
293
320
  )
294
- credential_type = models.CharField(max_length=255, help_text=_('Type of the credential'))
295
321
  status = models.CharField(
296
322
  max_length=32,
297
323
  choices=Status.choices,
@@ -301,30 +327,61 @@ class Credential(TimeStampedModel):
301
327
  download_url = models.URLField(blank=True, help_text=_('URL of the generated credential PDF (e.g., to S3)'))
302
328
  legacy_id = models.IntegerField(null=True, help_text=_('Legacy ID of the credential imported from another system'))
303
329
  generation_task_id = models.CharField(max_length=255, help_text=_('Task ID from the Celery queue'))
330
+ invalidated_at = models.DateTimeField(
331
+ null=True, editable=False, help_text=_('Timestamp when the credential was invalidated')
332
+ )
333
+ invalidation_reason = models.CharField(
334
+ max_length=255, blank=True, help_text=_('Reason for invalidating the credential')
335
+ )
304
336
 
305
- class Meta: # noqa: D106
306
- unique_together = (('user_id', 'learning_context_key', 'credential_type'),)
337
+ def __str__(self):
338
+ """Get a string representation of this model's instance."""
339
+ return (
340
+ f"{self.configuration.credential_type.name} for {self.user_full_name} "
341
+ f"in {self.configuration.learning_context_key}"
342
+ )
307
343
 
308
- def __str__(self): # noqa: D105
309
- return f"{self.credential_type} for {self.user_full_name} in {self.learning_context_key}"
344
+ def save(self, *args, **kwargs):
345
+ """If the invalidation reason is set, trigger the invalidation and update the timestamp."""
346
+ if self.invalidation_reason and self.status != Credential.Status.INVALIDATED:
347
+ self._invalidate()
348
+ if self.status == Credential.Status.INVALIDATED and not self.invalidated_at:
349
+ self.invalidated_at = timezone.now()
350
+ super().save(*args, **kwargs)
351
+
352
+ def _invalidate(self):
353
+ """Trigger the invalidation process for the credential."""
354
+ generation_module_name, generation_func_name = self.configuration.credential_type.generation_func.rsplit('.', 1)
355
+ generation_module = import_module(generation_module_name)
356
+ generation_func = getattr(generation_module, generation_func_name)
357
+
358
+ self.download_url = generation_func(self, {}, invalidate=True)
359
+ self.status = Credential.Status.INVALIDATED
310
360
 
311
361
  def send_email(self):
312
362
  """Send a credential link to the student."""
313
- learning_context_name = get_learning_context_name(self.learning_context_key)
314
- user = get_user_model().objects.get(id=self.user_id)
315
363
  msg = Message(
316
364
  name="certificate_generated",
317
365
  app_label="learning_credentials",
318
- recipient=Recipient(lms_user_id=user.id, email_address=user.email),
366
+ recipient=Recipient(lms_user_id=self.user.id, email_address=self.user.email),
319
367
  language='en',
320
368
  context={
321
369
  'certificate_link': self.download_url,
322
- 'course_name': learning_context_name,
370
+ 'course_name': self.learning_context_name,
323
371
  'platform_name': settings.PLATFORM_NAME,
324
372
  },
325
373
  )
326
374
  ace.send(msg)
327
375
 
376
+ def reissue(self) -> Self:
377
+ """Invalidate the current credential and create a new one."""
378
+ if self.invalidation_reason:
379
+ self.invalidation_reason += '\n'
380
+ self.invalidation_reason += 'Reissued'
381
+ self.save()
382
+
383
+ return self.configuration.generate_credential_for_user(self.user.id)
384
+
328
385
 
329
386
  class CredentialAsset(TimeStampedModel):
330
387
  """
@@ -0,0 +1,83 @@
1
+ {% extends "main_django.html" %}
2
+ {% load i18n %}
3
+ {% load static %}
4
+
5
+ {% block bodyextra %}
6
+ <div id="content" class="credential-verification-container content-wrapper main-container">
7
+ <section class="container">
8
+ <h1>{% trans "Credential verification page" %}</h1>
9
+ <form id="credentialVerificationForm" method="post">
10
+ {% csrf_token %}
11
+ <div class="form-group">
12
+ <label for="credentialID">{% trans 'Credential ID' %}</label>
13
+ <input type="text" id="credentialID" class="form-control" name="credentialID" required>
14
+ <div id="formError" class="text-danger mt-2" style="display: none;"></div>
15
+ </div>
16
+ <button type="submit" class="btn btn-primary">
17
+ {% trans 'Verify' %}
18
+ </button>
19
+ </form>
20
+
21
+ <div id="verificationResults" class="p-0 border-0 mt-5"></div>
22
+ </section>
23
+ </div>
24
+
25
+ <script>
26
+ document.getElementById('credentialVerificationForm').onsubmit = async function(event) {
27
+ event.preventDefault();
28
+ let formError = document.getElementById('formError');
29
+ let verificationResults = document.getElementById('verificationResults');
30
+
31
+ let credentialId = document.getElementById('credentialID').value;
32
+ var url = '/api/learning_credentials/v1/metadata/' + encodeURIComponent(credentialId);
33
+
34
+ // Hide previous error messages.
35
+ formError.style.display = 'none';
36
+
37
+ await fetch(url)
38
+ .then(response => response.json())
39
+ .then(data => {
40
+ const table = document.createElement('table');
41
+ table.className = 'table table-striped table-bordered';
42
+ const tbody = document.createElement('tbody');
43
+ Object.entries(data).forEach(([key, value]) => {
44
+ if (!!value) {
45
+ const row = tbody.insertRow();
46
+ const cellKey = row.insertCell();
47
+ const cellValue = row.insertCell();
48
+ cellKey.textContent = key.split('_').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
49
+ cellValue.textContent = value;
50
+ }
51
+ });
52
+ table.appendChild(tbody);
53
+ verificationResults.textContent = '';
54
+ verificationResults.appendChild(table);
55
+ })
56
+ .catch((error) => {
57
+ console.error('Error:', error);
58
+ verificationResults.textContent = '';
59
+ formError.innerHTML = "An error occurred during verification. Please check the Credential ID and try again.";
60
+ formError.style.display = 'block';
61
+ });
62
+ }
63
+
64
+
65
+ $(document).ready(function(){
66
+ // Fill the credential ID field with URL 'credential' query param.
67
+ let urlParams = new URLSearchParams(window.location.search);
68
+ const uuid = urlParams.get('credential');
69
+
70
+ if (uuid) {
71
+ $('#credentialID').val(uuid);
72
+ }
73
+
74
+ // If the field is filled, submit the form automatically.
75
+ if ($('#credentialID').val()) {
76
+ $('#credentialVerificationForm').submit();
77
+ }
78
+
79
+ // Focus the field for user convenience.
80
+ $('#credentialID').focus();
81
+ });
82
+ </script>
83
+ {% endblock %}
@@ -1,9 +1,11 @@
1
1
  """URLs for learning_credentials."""
2
2
 
3
3
  from django.urls import include, path
4
+ from django.views.generic import TemplateView
4
5
 
5
6
  from .api import urls as api_urls
6
7
 
7
8
  urlpatterns = [
8
9
  path('api/learning_credentials/', include(api_urls)),
10
+ path('learning_credentials/verify/', TemplateView.as_view(template_name="learning_credentials/verify.html")),
9
11
  ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.4.1rc6
3
+ Version: 0.5.0rc3
4
4
  Summary: A pluggable service for preparing Open edX credentials.
5
5
  Author-email: OpenCraft <help@opencraft.com>
6
6
  License-Expression: AGPL-3.0-or-later
@@ -11,6 +11,7 @@ Keywords: Python,edx,credentials,django
11
11
  Classifier: Development Status :: 5 - Production/Stable
12
12
  Classifier: Framework :: Django
13
13
  Classifier: Framework :: Django :: 4.2
14
+ Classifier: Framework :: Django :: 5.2
14
15
  Classifier: Intended Audience :: Developers
15
16
  Classifier: Natural Language :: English
16
17
  Classifier: Programming Language :: Python :: 3
@@ -176,7 +177,16 @@ Unreleased
176
177
 
177
178
  *
178
179
 
179
- 0.4.1 - 2025-12-31
180
+ 0.5.0 - 2026-01-29
181
+ ******************
182
+
183
+ Added
184
+ =====
185
+
186
+ * Frontend form and backend API endpoint for verifying credentials.
187
+ * Option to invalidate issued credentials.
188
+
189
+ 0.4.0 - 2026-01-28
180
190
  ******************
181
191
 
182
192
  Added
@@ -1,40 +1,46 @@
1
1
  learning_credentials/__init__.py,sha256=8Q0-3Hdnfmcj41EKu1GSfzEfwWcYNDlItyEEke2r9bs,62
2
- learning_credentials/admin.py,sha256=ynK3tVJwLsIeV7Jk66t1FAVyVsU1G-KRIAdRkycVTmA,10439
2
+ learning_credentials/admin.py,sha256=AkbLqeFyGEmEsv-EGU4tD3KeCOd6ldvopg2uj_sRUAA,12558
3
3
  learning_credentials/apps.py,sha256=trdQxe-JRhUdUaOQoQWiGL1sn6I1sfDiTvdCwy8yGuw,1037
4
- learning_credentials/compat.py,sha256=bTAB6bTh99ZyhUqOsDtM_BuIPzFxCjySFtfvc-_fCd4,4731
4
+ learning_credentials/compat.py,sha256=EVUxlybL3ugzquPgL0sst7yAQ9qpyAplloTzS52jF5o,4826
5
5
  learning_credentials/exceptions.py,sha256=UaqBVXFMWR2Iob7_LMb3j4NNVmWQFAgLi_MNMRUvGsI,290
6
- learning_credentials/generators.py,sha256=gaw3zoEzVhSjo96QM6Q6K70e_iK44LxCuQIQ05p7lP0,14895
7
- learning_credentials/models.py,sha256=t_FahXhxa6IsIQl2nqzgcE4XVGdyb5jVGXMLWjJd5cI,16679
6
+ learning_credentials/generators.py,sha256=2tAeqmEaK5zaygloLXQNHjDXMAalYlwJprx1KmL1pUY,16969
7
+ learning_credentials/models.py,sha256=JF2xtZWDCA7WRY0kBopzxgazPngpqHAM67wdPwdis1M,18602
8
8
  learning_credentials/processors.py,sha256=LkdjmkLBnXc9qeMcksB1T8AQ5ZhYaECyQO__KfHB_aU,15212
9
9
  learning_credentials/tasks.py,sha256=byoFEUvN_ayVaU5K5SlEiA7vu9BRPaSSmKnB9g5toec,1927
10
- learning_credentials/urls.py,sha256=gO_c930rzMylP-riQ9SGHXH9JIMF7ajySDT2Tc-E8x4,188
10
+ learning_credentials/urls.py,sha256=KXZtvPXXl2X_nTREWaCFxcAgY2XET1eWRbcx2rq_6eI,348
11
11
  learning_credentials/api/__init__.py,sha256=q8sLFfwo5RwQu8FY6BJUL_Jrt3TUojbZK-Zlw9v08EM,40
12
12
  learning_credentials/api/urls.py,sha256=wW27hrrJ7D_h8PbFDbSxzeaneNla0R-56gjKy9zISG8,216
13
13
  learning_credentials/api/v1/__init__.py,sha256=A7ZqENtM4QM1A7j_cAfnzw4zn0kuyfXSWtylFIE0_f8,43
14
14
  learning_credentials/api/v1/permissions.py,sha256=TqM50TpR3JGUgZgIgKZF0-R_g1_P2V9bqKzYXgk-VvY,3436
15
- learning_credentials/api/v1/urls.py,sha256=6YqLS4aVfA1cuLOgVe4lFUFa38wVehYKleXBF8ImMm0,287
16
- learning_credentials/api/v1/views.py,sha256=rkdj1AfRBDzrpRC5uGMAxTUf4P1zs-MSF9lpNMIgYLw,3005
15
+ learning_credentials/api/v1/serializers.py,sha256=H7l-vRTwLBplveCBjnNgSawJqpSVskeHTz7wpUiNB3g,417
16
+ learning_credentials/api/v1/urls.py,sha256=RytArViuKZQkWs46sk58VfaVCwLV-QrgTG7cQLE_NtU,408
17
+ learning_credentials/api/v1/views.py,sha256=CJEVPwCXs_ii463agPRpJeX6NCgyyFX9ZIBJh0BAc9I,4926
17
18
  learning_credentials/conf/locale/config.yaml,sha256=jPen2DmckNDKK30axCKEd2Q2ha9oOG3IBxrJ63Pvznk,2280
18
19
  learning_credentials/migrations/0001_initial.py,sha256=61EvThCv-0UAnhCE5feyQVfjRodbp-6cDaAr4CY5PMA,8435
20
+ learning_credentials/migrations/0001_squashed_0010.py,sha256=STQ4MRVfW7F-Dfqb8DrdiezQavn0wlwWfjGpGFmDcqc,10944
19
21
  learning_credentials/migrations/0002_migrate_to_learning_credentials.py,sha256=vUhcnQKDdwOsppkXsjz2zZwOGMwIJ-fkQRsaj-K7l1o,1779
20
22
  learning_credentials/migrations/0003_rename_certificates_to_credentials.py,sha256=YqSaHTB60VNc9k245um2GYVDH6J0l9BrN3ak6WKljjk,4677
21
23
  learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py,sha256=5KaXvASl69qbEaHX5_Ty_3Dr7K4WV6p8VWOx72yJnTU,1919
22
24
  learning_credentials/migrations/0005_rename_processors_and_generators.py,sha256=5UCqjq-CBJnRo1qBAoWs91ngyEuSMN8_tQtfzsuR5SI,5271
23
25
  learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py,sha256=aJs_gOP4TmW9J-Dmr21m94jBfLQxzjAu6-ua7x4uYLE,727
24
26
  learning_credentials/migrations/0007_migrate_to_text_elements_format.py,sha256=_olkaxPPuRys2c2X5fnyQIFVvqEfdoYu-JlApmXuHEM,4758
27
+ learning_credentials/migrations/0008_validation.py,sha256=jcTg4Lnlmcyp1Czc9b-52gPJ_s8W7Dwodvi_LggpVjw,3387
28
+ learning_credentials/migrations/0009_credential_user_fk.py,sha256=yT1YdE1ptZ8ZT7bWVQyTpgxs--UifGxK3V10ehHZ5Ig,2233
29
+ learning_credentials/migrations/0010_credential_configuration_fk.py,sha256=z3OZCgs_AJzxxyBvWVvvUSTXKPjYvBN8xd8VajwYBZA,3415
25
30
  learning_credentials/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
31
  learning_credentials/settings/__init__.py,sha256=tofc5eg3Q2lV13Ff_jjg1ggGgWpKYoeESkP1qxl3H_A,29
27
32
  learning_credentials/settings/common.py,sha256=Cck-nyFt11G1NLiz-bHfKJp8MV6sDZGqTwdbC8_1WE0,360
28
33
  learning_credentials/settings/production.py,sha256=6P0P7JxbpWNsk4Lk8lfyxHirOWMgU4UWOb3EYKLjiVQ,542
29
34
  learning_credentials/templates/learning_credentials/base.html,sha256=wtjBYqfHmOnyEY5tN3VGOmzYLsOD24MXdEUhTZ7OmwI,662
35
+ learning_credentials/templates/learning_credentials/verify.html,sha256=vXTiZMZkTLhp6cnqKBJcQnDu7qUxLvrTzN2m1KM8_9k,3414
30
36
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html,sha256=t-i1Ra9AC4pX-rPRifDJIvBBZuxCxdrFqg1NKTjHBOk,813
31
37
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt,sha256=IF_x8aF_-dORlQB-RCh0IkJDl2ktD489E8qGgLe9M3Y,677
32
38
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt,sha256=-n8tjPSwfwAfeOSZ1WhcCTrpOah4VswzMZ5mh63Pxow,20
33
39
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
34
40
  learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt,sha256=S7Hc5T_sZSsSBXm5_H5HBNNv16Ohl0oZn0nVqqeWL0g,132
35
- learning_credentials-0.4.1rc6.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
36
- learning_credentials-0.4.1rc6.dist-info/METADATA,sha256=_3vW2RqkLIUwho5al3zefc877eWnW9qLPOB-ZjsOvDg,8258
37
- learning_credentials-0.4.1rc6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
38
- learning_credentials-0.4.1rc6.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
39
- learning_credentials-0.4.1rc6.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
40
- learning_credentials-0.4.1rc6.dist-info/RECORD,,
41
+ learning_credentials-0.5.0rc3.dist-info/licenses/LICENSE.txt,sha256=GDpsPnW_1NKhPvZpZL9imz25P2nIpbwJPEhrlq4vPAU,34523
42
+ learning_credentials-0.5.0rc3.dist-info/METADATA,sha256=eOcIS9drEVJVDExK5zGolz5e3apUQWBEnfIzeTa8-vo,8461
43
+ learning_credentials-0.5.0rc3.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
44
+ learning_credentials-0.5.0rc3.dist-info/entry_points.txt,sha256=hHqqLUEdzAN24v5OGBX9Fr-wh3ATDPjQjByKz03eC2Y,91
45
+ learning_credentials-0.5.0rc3.dist-info/top_level.txt,sha256=Ce-4_leZe_nny7CpmkeRiemcDV6jIHpIvLjlcQBuf18,21
46
+ learning_credentials-0.5.0rc3.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5