learning-credentials 0.5.0rc4__tar.gz → 0.5.1__tar.gz

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.
Files changed (60) hide show
  1. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/CHANGELOG.rst +34 -2
  2. {learning_credentials-0.5.0rc4/learning_credentials.egg-info → learning_credentials-0.5.1}/PKG-INFO +35 -3
  3. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/admin.py +8 -10
  4. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/permissions.py +17 -0
  5. learning_credentials-0.5.1/learning_credentials/api/v1/serializers.py +48 -0
  6. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/urls.py +6 -1
  7. learning_credentials-0.5.1/learning_credentials/api/v1/views.py +277 -0
  8. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/generators.py +1 -1
  9. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/models.py +27 -6
  10. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/processors.py +150 -57
  11. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1/learning_credentials.egg-info}/PKG-INFO +35 -3
  12. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/pyproject.toml +1 -1
  13. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_admin.py +11 -30
  14. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_models.py +12 -0
  15. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_processors.py +130 -69
  16. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_views.py +151 -0
  17. learning_credentials-0.5.0rc4/learning_credentials/api/v1/serializers.py +0 -13
  18. learning_credentials-0.5.0rc4/learning_credentials/api/v1/views.py +0 -143
  19. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/LICENSE.txt +0 -0
  20. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/MANIFEST.in +0 -0
  21. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/README.rst +0 -0
  22. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/__init__.py +0 -0
  23. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/__init__.py +0 -0
  24. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/urls.py +0 -0
  25. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/__init__.py +0 -0
  26. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/apps.py +0 -0
  27. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/compat.py +0 -0
  28. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/conf/locale/config.yaml +0 -0
  29. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/exceptions.py +0 -0
  30. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0001_initial.py +0 -0
  31. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  32. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  33. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  34. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  35. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  36. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
  37. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0008_validation.py +0 -0
  38. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0009_credential_user_fk.py +0 -0
  39. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0010_credential_configuration_fk.py +0 -0
  40. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/__init__.py +0 -0
  41. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/__init__.py +0 -0
  42. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/common.py +0 -0
  43. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/production.py +0 -0
  44. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/tasks.py +0 -0
  45. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/base.html +0 -0
  46. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  47. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  48. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
  49. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  50. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  51. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/urls.py +0 -0
  52. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/SOURCES.txt +0 -0
  53. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/dependency_links.txt +0 -0
  54. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/entry_points.txt +0 -0
  55. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/requires.txt +0 -0
  56. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/top_level.txt +0 -0
  57. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/setup.cfg +0 -0
  58. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_generators.py +0 -0
  59. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_migrations.py +0 -0
  60. {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_tasks.py +0 -0
@@ -16,14 +16,46 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.5.1 - 2026-03-17
20
+ ******************
21
+
22
+ Added
23
+ =====
24
+
25
+ * Credential eligibility check endpoint (``GET /api/learning_credentials/v1/eligibility/<learning_context_key>/``)
26
+ with detailed progress information.
27
+
28
+ Changed
29
+ =======
30
+
31
+ * Processor functions now return ``dict[int, dict[str, Any]]`` with detailed eligibility information instead of ``list[int]`` of eligible user IDs.
32
+ * Processor functions now accept an optional ``user_id`` parameter for single-user eligibility checks.
33
+
19
34
  0.5.0 - 2026-01-29
20
35
  ******************
21
36
 
22
37
  Added
23
38
  =====
24
39
 
25
- * Frontend form and backend API endpoint for verifying credentials.
26
- * Option to invalidate issued credentials.
40
+ * Credential validation and invalidation system with ``INVALIDATED`` status.
41
+ * ``verify_uuid`` field on credentials — a separate UUID used for third-party verification, distinct from the primary key.
42
+ * ``invalidated_at`` timestamp and ``invalidation_reason`` fields on the ``Credential`` model.
43
+ * ``learning_context_name`` cached field on credentials for use in validation display.
44
+ * Public API endpoint (``/api/learning_credentials/v1/metadata/<uuid>/``) to retrieve credential metadata by verification UUID.
45
+ * "Reissue credential" admin action that invalidates the current credential and generates a new one.
46
+ * ``{verify_uuid}`` placeholder support in PDF text elements.
47
+ * Admin test suite (``test_admin.py``) and included it in test coverage.
48
+
49
+ Changed
50
+ =======
51
+
52
+ * Replaced ``user_id`` (IntegerField) with a ``user`` ForeignKey to the User model.
53
+ * Added ``configuration`` ForeignKey on ``Credential`` linking to ``CredentialConfiguration``, replacing ``credential_type`` and ``learning_context_key`` lookups.
54
+ * Simplified ``generate_pdf_credential()`` signature to accept a ``Credential`` object instead of separate parameters.
55
+ * Changed the PDF output directory from hardcoded ``external_certificates/`` to the configurable ``LEARNING_CREDENTIALS_OUTPUT_DIR`` setting.
56
+ * Credential issue date on PDFs now uses the credential's creation date instead of the current time.
57
+ * ``get_localized_credential_date()`` now accepts an explicit ``datetime`` parameter.
58
+ * Disabled add and delete permissions for credentials in the Django admin.
27
59
 
28
60
  0.4.0 - 2026-01-28
29
61
  ******************
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.5.0rc4
3
+ Version: 0.5.1
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
@@ -177,14 +177,46 @@ Unreleased
177
177
 
178
178
  *
179
179
 
180
+ 0.5.1 - 2026-03-17
181
+ ******************
182
+
183
+ Added
184
+ =====
185
+
186
+ * Credential eligibility check endpoint (``GET /api/learning_credentials/v1/eligibility/<learning_context_key>/``)
187
+ with detailed progress information.
188
+
189
+ Changed
190
+ =======
191
+
192
+ * Processor functions now return ``dict[int, dict[str, Any]]`` with detailed eligibility information instead of ``list[int]`` of eligible user IDs.
193
+ * Processor functions now accept an optional ``user_id`` parameter for single-user eligibility checks.
194
+
180
195
  0.5.0 - 2026-01-29
181
196
  ******************
182
197
 
183
198
  Added
184
199
  =====
185
200
 
186
- * Frontend form and backend API endpoint for verifying credentials.
187
- * Option to invalidate issued credentials.
201
+ * Credential validation and invalidation system with ``INVALIDATED`` status.
202
+ * ``verify_uuid`` field on credentials — a separate UUID used for third-party verification, distinct from the primary key.
203
+ * ``invalidated_at`` timestamp and ``invalidation_reason`` fields on the ``Credential`` model.
204
+ * ``learning_context_name`` cached field on credentials for use in validation display.
205
+ * Public API endpoint (``/api/learning_credentials/v1/metadata/<uuid>/``) to retrieve credential metadata by verification UUID.
206
+ * "Reissue credential" admin action that invalidates the current credential and generates a new one.
207
+ * ``{verify_uuid}`` placeholder support in PDF text elements.
208
+ * Admin test suite (``test_admin.py``) and included it in test coverage.
209
+
210
+ Changed
211
+ =======
212
+
213
+ * Replaced ``user_id`` (IntegerField) with a ``user`` ForeignKey to the User model.
214
+ * Added ``configuration`` ForeignKey on ``Credential`` linking to ``CredentialConfiguration``, replacing ``credential_type`` and ``learning_context_key`` lookups.
215
+ * Simplified ``generate_pdf_credential()`` signature to accept a ``Credential`` object instead of separate parameters.
216
+ * Changed the PDF output directory from hardcoded ``external_certificates/`` to the configurable ``LEARNING_CREDENTIALS_OUTPUT_DIR`` setting.
217
+ * Credential issue date on PDFs now uses the credential's creation date instead of the current time.
218
+ * ``get_localized_credential_date()`` now accepts an explicit ``datetime`` parameter.
219
+ * Disabled add and delete permissions for credentials in the Django admin.
188
220
 
189
221
  0.4.0 - 2026-01-28
190
222
  ******************
@@ -27,7 +27,7 @@ from .models import (
27
27
  )
28
28
  from .tasks import generate_credentials_for_config_task
29
29
 
30
- if TYPE_CHECKING: # pragma: no cover
30
+ if TYPE_CHECKING:
31
31
  from collections.abc import Generator
32
32
 
33
33
  from django.http import HttpRequest
@@ -177,7 +177,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
177
177
  ]
178
178
  list_display = ('learning_context_key', 'credential_type', 'enabled', 'interval')
179
179
  search_fields = ('learning_context_key', 'credential_type__name')
180
- list_filter = ('learning_context_key', 'credential_type')
180
+ list_filter = ('learning_context_key', 'credential_type', 'periodic_task__enabled')
181
181
 
182
182
  def get_inline_instances(
183
183
  self,
@@ -263,14 +263,12 @@ class CredentialAdmin(DjangoObjectActions, admin.ModelAdmin): # noqa: D101
263
263
  list_filter = ("configuration__learning_context_key", "configuration__credential_type", "status")
264
264
  change_actions = ('reissue_credential',)
265
265
 
266
- def save_model(self, request: HttpRequest, obj: Credential, _form: forms.ModelForm, _change: bool): # noqa: FBT001
267
- """Display validation errors as messages in the admin interface."""
268
- try:
269
- obj.save()
270
- except ValidationError as e:
271
- self.message_user(request, e.message or "Invalid data", level=messages.ERROR)
272
- # Optionally, redirect to the change form with the error message
273
- return
266
+ def get_change_actions(self, request: HttpRequest, object_id: str, form_url: str) -> list[str]:
267
+ """Hide the reissue button when the credential is already invalidated."""
268
+ actions = list(super().get_change_actions(request, object_id, form_url))
269
+ if self.model.objects.filter(pk=object_id, status=Credential.Status.INVALIDATED).exists():
270
+ actions.remove('reissue_credential')
271
+ return actions
274
272
 
275
273
  def get_form(self, request: HttpRequest, obj: Credential | None = None, **kwargs) -> forms.ModelForm:
276
274
  """Hide the download_url field."""
@@ -19,6 +19,23 @@ if TYPE_CHECKING:
19
19
  from rest_framework.views import APIView
20
20
 
21
21
 
22
+ class IsAdminOrSelf(BasePermission):
23
+ """
24
+ Permission to allow only admins or the user themselves to access the API.
25
+
26
+ Non-staff users cannot pass a ``username`` that is not their own.
27
+ """
28
+
29
+ def has_permission(self, request: "Request", view: "APIView") -> bool: # noqa: ARG002
30
+ """Check if the user is admin or accessing their own data."""
31
+ if request.user.is_staff:
32
+ return True
33
+
34
+ if username := request.query_params.get("username"):
35
+ return request.user.username == username
36
+ return True
37
+
38
+
22
39
  class CanAccessLearningContext(BasePermission):
23
40
  """Permission to allow access to learning context if the user is enrolled."""
24
41
 
@@ -0,0 +1,48 @@
1
+ """API serializers for learning credentials."""
2
+
3
+ from typing import Any
4
+
5
+ from rest_framework import serializers
6
+
7
+ from learning_credentials.models import Credential
8
+
9
+
10
+ class CredentialSerializer(serializers.ModelSerializer):
11
+ """Serializer that returns credential metadata (for the public verification endpoint)."""
12
+
13
+ class Meta:
14
+ """Serializer metadata."""
15
+
16
+ model = Credential
17
+ fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')
18
+
19
+
20
+ class CredentialEligibilitySerializer(serializers.Serializer):
21
+ """Serializer for credential eligibility information with dynamic fields."""
22
+
23
+ credential_type_id = serializers.IntegerField()
24
+ name = serializers.CharField()
25
+ is_generation_enabled = serializers.BooleanField()
26
+ is_eligible = serializers.BooleanField()
27
+ existing_credential = serializers.UUIDField(required=False, allow_null=True)
28
+ existing_credential_url = serializers.URLField(required=False, allow_blank=True, allow_null=True)
29
+
30
+ current_grades = serializers.DictField(required=False)
31
+ required_grades = serializers.DictField(required=False)
32
+
33
+ current_completion = serializers.FloatField(required=False, allow_null=True)
34
+ required_completion = serializers.FloatField(required=False, allow_null=True)
35
+
36
+ steps = serializers.DictField(required=False)
37
+
38
+ def to_representation(self, instance: dict) -> dict[str, Any]:
39
+ """Remove null/empty fields from representation."""
40
+ data = super().to_representation(instance)
41
+ return {key: value for key, value in data.items() if value is not None and value not in ({}, [])}
42
+
43
+
44
+ class CredentialEligibilityResponseSerializer(serializers.Serializer):
45
+ """Serializer for the complete credential eligibility response."""
46
+
47
+ context_key = serializers.CharField()
48
+ credentials = CredentialEligibilitySerializer(many=True)
@@ -2,7 +2,7 @@
2
2
 
3
3
  from django.urls import path
4
4
 
5
- from .views import CredentialConfigurationCheckView, CredentialMetadataView
5
+ from .views import CredentialConfigurationCheckView, CredentialEligibilityView, CredentialMetadataView
6
6
 
7
7
  urlpatterns = [
8
8
  path(
@@ -11,4 +11,9 @@ urlpatterns = [
11
11
  name='credential_configuration_check',
12
12
  ),
13
13
  path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
14
+ path(
15
+ 'eligibility/<str:learning_context_key>/',
16
+ CredentialEligibilityView.as_view(),
17
+ name='credential-eligibility',
18
+ ),
14
19
  ]
@@ -0,0 +1,277 @@
1
+ """API views for Learning Credentials."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import edx_api_doc_tools as apidocs
6
+ from django.contrib.auth import get_user_model
7
+ from django.shortcuts import get_object_or_404
8
+ from edx_api_doc_tools import ParameterLocation
9
+ from rest_framework import status
10
+ from rest_framework.permissions import IsAuthenticated
11
+ from rest_framework.response import Response
12
+ from rest_framework.views import APIView
13
+
14
+ from learning_credentials.models import Credential, CredentialConfiguration
15
+
16
+ from .permissions import CanAccessLearningContext, IsAdminOrSelf
17
+ from .serializers import CredentialEligibilityResponseSerializer, CredentialSerializer
18
+
19
+ if TYPE_CHECKING:
20
+ from django.contrib.auth.models import User
21
+ from rest_framework.request import Request
22
+
23
+
24
+ class CredentialConfigurationCheckView(APIView):
25
+ """API view to check if any credentials are configured for a specific learning context."""
26
+
27
+ permission_classes = (IsAuthenticated, CanAccessLearningContext)
28
+
29
+ @apidocs.schema(
30
+ parameters=[
31
+ apidocs.string_parameter(
32
+ "learning_context_key",
33
+ ParameterLocation.PATH,
34
+ description=(
35
+ "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) "
36
+ "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)"
37
+ ),
38
+ ),
39
+ ],
40
+ responses={
41
+ 200: "Boolean indicating if credentials are configured.",
42
+ 400: "Invalid context key format.",
43
+ 403: "User is not authenticated or does not have permission to access the learning context.",
44
+ 404: "Learning context not found or user does not have access.",
45
+ },
46
+ )
47
+ def get(self, _request: "Request", learning_context_key: str) -> Response:
48
+ """
49
+ Check if any credentials are configured for the given learning context.
50
+
51
+ **Example Request**
52
+
53
+ ``GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/``
54
+
55
+ **Response Values**
56
+
57
+ - **200 OK**: Request successful, returns credential configuration status.
58
+ - **400 Bad Request**: Invalid learning context key format.
59
+ - **403 Forbidden**: User is not authenticated or does not have permission to access the learning context.
60
+ - **404 Not Found**: Learning context not found or user does not have access.
61
+
62
+ **Example Response**
63
+
64
+ .. code-block:: json
65
+
66
+ {
67
+ "has_credentials": true,
68
+ "credential_count": 2
69
+ }
70
+
71
+ **Response Fields**
72
+
73
+ - ``has_credentials``: Boolean indicating if any credentials are configured
74
+ - ``credential_count``: Number of credential configurations available
75
+
76
+ **Note**
77
+
78
+ This endpoint does not perform learning context existence validation, so it will not return 404 for staff users.
79
+ """
80
+ credential_count = CredentialConfiguration.objects.filter(learning_context_key=learning_context_key).count()
81
+
82
+ response_data = {
83
+ 'has_credentials': credential_count > 0,
84
+ 'credential_count': credential_count,
85
+ }
86
+
87
+ return Response(response_data, status=status.HTTP_200_OK)
88
+
89
+
90
+ class CredentialMetadataView(APIView):
91
+ """API view to retrieve credential metadata by UUID."""
92
+
93
+ @apidocs.schema(
94
+ parameters=[
95
+ apidocs.string_parameter(
96
+ "uuid",
97
+ ParameterLocation.PATH,
98
+ description="The UUID of the credential to retrieve.",
99
+ ),
100
+ ],
101
+ responses={
102
+ 200: "Successfully retrieved the credential metadata.",
103
+ 404: "Credential not found or not valid.",
104
+ },
105
+ )
106
+ def get(self, _request: "Request", uuid: str) -> Response:
107
+ """
108
+ Retrieve credential metadata by its UUID.
109
+
110
+ **Example Request**
111
+
112
+ ``GET /api/learning_credentials/v1/metadata/123e4567-e89b-12d3-a456-426614174000/``
113
+
114
+ **Response Values**
115
+
116
+ - **200 OK**: Successfully retrieved the credential metadata.
117
+ - **404 Not Found**: Credential not found or not valid.
118
+
119
+ **Example Response**
120
+
121
+ .. code-block:: json
122
+
123
+ {
124
+ "user_full_name": "John Doe",
125
+ "created": "2023-01-01",
126
+ "learning_context_name": "Demo Course",
127
+ "status": "available",
128
+ "invalidation_reason": ""
129
+ }
130
+
131
+
132
+ {
133
+ "user_full_name": "John Doe",
134
+ "created": "2023-01-01",
135
+ "learning_context_name": "Demo Course",
136
+ "status": "invalidated",
137
+ "invalidation_reason": "Reissued due to name change."
138
+ }
139
+ """
140
+ try:
141
+ credential = Credential.objects.get(verify_uuid=uuid)
142
+ except Credential.DoesNotExist:
143
+ return Response({'error': 'Credential not found.'}, status=status.HTTP_404_NOT_FOUND)
144
+
145
+ serializer = CredentialSerializer(credential)
146
+ return Response(serializer.data, status=status.HTTP_200_OK)
147
+
148
+
149
+ class CredentialEligibilityView(APIView):
150
+ """
151
+ API view for credential eligibility checking and generation.
152
+
153
+ **GET**: Returns detailed eligibility info for all configured credentials in a learning context.
154
+
155
+ Staff users can operate on behalf of other users via the ``username`` parameter.
156
+ """
157
+
158
+ permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext)
159
+
160
+ def _get_eligibility_data(
161
+ self, user: "User", config: CredentialConfiguration, credentials_by_config_id: dict[int, Credential]
162
+ ) -> dict:
163
+ """Calculate eligibility data for a credential configuration."""
164
+ progress_data = config.get_user_eligibility_details(user_id=user.id)
165
+ existing_credential = credentials_by_config_id.get(config.id)
166
+
167
+ return {
168
+ 'credential_type_id': config.credential_type.pk,
169
+ 'name': config.credential_type.name,
170
+ 'is_generation_enabled': config.periodic_task.enabled,
171
+ **progress_data,
172
+ 'existing_credential': existing_credential.uuid if existing_credential else None,
173
+ 'existing_credential_url': existing_credential.download_url if existing_credential else None,
174
+ }
175
+
176
+ @apidocs.schema(
177
+ parameters=[
178
+ apidocs.string_parameter(
179
+ "learning_context_key",
180
+ ParameterLocation.PATH,
181
+ description=(
182
+ "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) "
183
+ "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)"
184
+ ),
185
+ ),
186
+ apidocs.string_parameter(
187
+ "retrieval_func",
188
+ ParameterLocation.QUERY,
189
+ description=(
190
+ "Filter by credential type retrieval function "
191
+ "(e.g. learning_credentials.processors.retrieve_subsection_grades)."
192
+ ),
193
+ ),
194
+ apidocs.string_parameter(
195
+ "username",
196
+ ParameterLocation.QUERY,
197
+ description=(
198
+ "Operate on behalf of the specified user. "
199
+ "Staff users may specify any username; non-staff users are limited to their own."
200
+ ),
201
+ ),
202
+ ],
203
+ responses={
204
+ 200: CredentialEligibilityResponseSerializer,
205
+ 400: "Invalid context key format.",
206
+ 403: "User is not authenticated.",
207
+ 404: "Learning context not found or user does not have access.",
208
+ },
209
+ )
210
+ def get(self, request: "Request", learning_context_key: str) -> Response:
211
+ """
212
+ Get credential eligibility for a learning context.
213
+
214
+ Returns detailed eligibility information for all configured credentials, including:
215
+
216
+ - Current grades and requirements for grade-based credentials
217
+ - Completion percentages for completion-based credentials
218
+ - Step-by-step progress for learning paths
219
+ - Existing credential info if already generated
220
+
221
+ **Query Parameters**
222
+
223
+ - ``username`` (staff only): View eligibility for a specific user.
224
+ - ``retrieval_func``: Filter by credential type retrieval function
225
+ (e.g. ``learning_credentials.processors.retrieve_subsection_grades``).
226
+
227
+ **Example Request**
228
+
229
+ ``GET /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/``
230
+
231
+ **Example Response**
232
+
233
+ .. code-block:: json
234
+
235
+ {
236
+ "context_key": "course-v1:OpenedX+DemoX+DemoCourse",
237
+ "credentials": [
238
+ {
239
+ "credential_type_id": 1,
240
+ "name": "Certificate of Achievement",
241
+ "is_eligible": true,
242
+ "existing_credential": null,
243
+ "current_grades": {"final exam": 86, "total": 82},
244
+ "required_grades": {"final exam": 65, "total": 80}
245
+ }
246
+ ]
247
+ }
248
+ """
249
+ username = request.query_params.get('username')
250
+ user = get_object_or_404(get_user_model(), username=username) if username else request.user
251
+
252
+ configurations = CredentialConfiguration.objects.filter(
253
+ learning_context_key=learning_context_key
254
+ ).select_related('credential_type', 'periodic_task')
255
+
256
+ retrieval_func = request.query_params.get('retrieval_func')
257
+ if retrieval_func:
258
+ configurations = configurations.filter(credential_type__retrieval_func=retrieval_func)
259
+
260
+ # Pre-fetch all credentials for this user and learning context to avoid N+1 queries in the loop below.
261
+ credentials = Credential.objects.filter(user_id=user.id, configuration__in=configurations).exclude(
262
+ status__in=[Credential.Status.ERROR, Credential.Status.INVALIDATED]
263
+ )
264
+ credentials_by_config_id = {credential.configuration_id: credential for credential in credentials}
265
+
266
+ eligibility_data = [
267
+ self._get_eligibility_data(user, config, credentials_by_config_id) for config in configurations
268
+ ]
269
+
270
+ response_data = {
271
+ 'context_key': learning_context_key,
272
+ 'credentials': eligibility_data,
273
+ }
274
+
275
+ serializer = CredentialEligibilityResponseSerializer(data=response_data)
276
+ serializer.is_valid(raise_exception=True)
277
+ return Response(serializer.data)
@@ -31,7 +31,7 @@ from .models import CredentialAsset
31
31
 
32
32
  log = logging.getLogger(__name__)
33
33
 
34
- if TYPE_CHECKING: # pragma: no cover
34
+ if TYPE_CHECKING:
35
35
  from uuid import UUID
36
36
 
37
37
  from pypdf import PageObject
@@ -7,7 +7,7 @@ import logging
7
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, Self
10
+ from typing import TYPE_CHECKING, Any, Self
11
11
 
12
12
  import jsonfield
13
13
  from django.conf import settings
@@ -26,7 +26,7 @@ from opaque_keys.edx.django.models import LearningContextKeyField
26
26
  from learning_credentials.compat import get_learning_context_name
27
27
  from learning_credentials.exceptions import AssetNotFoundError, CredentialGenerationError
28
28
 
29
- if TYPE_CHECKING: # pragma: no cover
29
+ if TYPE_CHECKING:
30
30
  from django.core.files import File
31
31
  from django.db.models import QuerySet
32
32
 
@@ -183,11 +183,12 @@ class CredentialConfiguration(TimeStampedModel):
183
183
  filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials)
184
184
  return list(filtered_user_ids_set)
185
185
 
186
- def get_eligible_user_ids(self) -> list[int]:
186
+ def _call_retrieval_func(self, user_id: int | None = None) -> dict[int, dict[str, Any]]:
187
187
  """
188
- Get the list of eligible learners for the given course.
188
+ Call the retrieval function and return detailed results.
189
189
 
190
- :return: A list of user IDs.
190
+ :param user_id: Optional. If provided, only check eligibility for this user.
191
+ :return: A dict mapping user IDs to their detailed progress information.
191
192
  """
192
193
  func_path = self.credential_type.retrieval_func
193
194
  module_path, func_name = func_path.rsplit('.', 1)
@@ -195,7 +196,27 @@ class CredentialConfiguration(TimeStampedModel):
195
196
  func = getattr(module, func_name)
196
197
 
197
198
  custom_options = _deep_merge(self.credential_type.custom_options, self.custom_options)
198
- return func(self.learning_context_key, custom_options)
199
+ return func(self.learning_context_key, custom_options, user_id=user_id)
200
+
201
+ def get_eligible_user_ids(self, user_id: int | None = None) -> list[int]:
202
+ """
203
+ Get the list of eligible learners for the given learning context.
204
+
205
+ :param user_id: Optional. If provided, only check eligibility for this user.
206
+ :return: A list of eligible user IDs.
207
+ """
208
+ results = self._call_retrieval_func(user_id)
209
+ return [uid for uid, details in results.items() if details.get('is_eligible', False)]
210
+
211
+ def get_user_eligibility_details(self, user_id: int) -> dict[str, Any]:
212
+ """
213
+ Get detailed eligibility information for a specific user.
214
+
215
+ :param user_id: The user ID to check eligibility for.
216
+ :return: Dictionary containing eligibility details and progress information.
217
+ """
218
+ results = self._call_retrieval_func(user_id)
219
+ return results.get(user_id, {'is_eligible': False})
199
220
 
200
221
  def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
201
222
  """