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.
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/CHANGELOG.rst +34 -2
- {learning_credentials-0.5.0rc4/learning_credentials.egg-info → learning_credentials-0.5.1}/PKG-INFO +35 -3
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/admin.py +8 -10
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/permissions.py +17 -0
- learning_credentials-0.5.1/learning_credentials/api/v1/serializers.py +48 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/urls.py +6 -1
- learning_credentials-0.5.1/learning_credentials/api/v1/views.py +277 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/generators.py +1 -1
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/models.py +27 -6
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/processors.py +150 -57
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1/learning_credentials.egg-info}/PKG-INFO +35 -3
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/pyproject.toml +1 -1
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_admin.py +11 -30
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_models.py +12 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_processors.py +130 -69
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_views.py +151 -0
- learning_credentials-0.5.0rc4/learning_credentials/api/v1/serializers.py +0 -13
- learning_credentials-0.5.0rc4/learning_credentials/api/v1/views.py +0 -143
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/LICENSE.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/MANIFEST.in +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/README.rst +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/__init__.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/__init__.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/urls.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/__init__.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/apps.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/compat.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/conf/locale/config.yaml +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/exceptions.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0001_initial.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0007_migrate_to_text_elements_format.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0008_validation.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0009_credential_user_fk.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/0010_credential_configuration_fk.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/migrations/__init__.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/__init__.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/common.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/settings/production.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/tasks.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/base.html +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
- {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
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/urls.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/SOURCES.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/dependency_links.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/entry_points.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/requires.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials.egg-info/top_level.txt +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/setup.cfg +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_generators.py +0 -0
- {learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/tests/test_migrations.py +0 -0
- {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
|
-
*
|
|
26
|
-
*
|
|
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
|
******************
|
{learning_credentials-0.5.0rc4/learning_credentials.egg-info → learning_credentials-0.5.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: learning-credentials
|
|
3
|
-
Version: 0.5.
|
|
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
|
-
*
|
|
187
|
-
*
|
|
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:
|
|
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
|
|
267
|
-
"""
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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)
|
{learning_credentials-0.5.0rc4 → learning_credentials-0.5.1}/learning_credentials/api/v1/urls.py
RENAMED
|
@@ -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)
|
|
@@ -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:
|
|
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
|
|
186
|
+
def _call_retrieval_func(self, user_id: int | None = None) -> dict[int, dict[str, Any]]:
|
|
187
187
|
"""
|
|
188
|
-
|
|
188
|
+
Call the retrieval function and return detailed results.
|
|
189
189
|
|
|
190
|
-
:
|
|
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
|
"""
|