learning-credentials 0.3.0rc3__py3-none-any.whl → 0.3.0rc10__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.
- learning_credentials/api/v1/permissions.py +42 -24
- learning_credentials/api/v1/urls.py +4 -8
- learning_credentials/api/v1/views.py +28 -302
- learning_credentials/apps.py +7 -10
- learning_credentials/compat.py +1 -7
- learning_credentials/generators.py +0 -1
- learning_credentials/models.py +10 -44
- learning_credentials/processors.py +40 -148
- learning_credentials/settings/common.py +5 -2
- learning_credentials/settings/production.py +5 -2
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/METADATA +19 -3
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/RECORD +16 -22
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/entry_points.txt +0 -3
- learning_credentials/api/v1/serializers.py +0 -74
- learning_credentials/core_api.py +0 -77
- learning_credentials/public/css/credentials_xblock.css +0 -7
- learning_credentials/public/html/credentials_xblock.html +0 -48
- learning_credentials/public/js/credentials_xblock.js +0 -23
- learning_credentials/xblocks.py +0 -85
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/WHEEL +0 -0
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/licenses/LICENSE.txt +0 -0
- {learning_credentials-0.3.0rc3.dist-info → learning_credentials-0.3.0rc10.dist-info}/top_level.txt +0 -0
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from typing import TYPE_CHECKING
|
|
4
4
|
|
|
5
|
-
from
|
|
5
|
+
from django.db.models import Q
|
|
6
|
+
from learning_paths.models import LearningPath
|
|
6
7
|
from opaque_keys import InvalidKeyError
|
|
7
8
|
from opaque_keys.edx.keys import LearningContextKey
|
|
8
9
|
from rest_framework.exceptions import NotFound, ParseError
|
|
@@ -11,35 +12,18 @@ from rest_framework.permissions import BasePermission
|
|
|
11
12
|
from learning_credentials.compat import get_course_enrollments
|
|
12
13
|
|
|
13
14
|
if TYPE_CHECKING:
|
|
15
|
+
from django.contrib.auth.models import User
|
|
16
|
+
from learning_paths.keys import LearningPathKey
|
|
17
|
+
from opaque_keys.edx.keys import CourseKey
|
|
14
18
|
from rest_framework.request import Request
|
|
15
19
|
from rest_framework.views import APIView
|
|
16
20
|
|
|
17
21
|
|
|
18
|
-
class IsAdminOrSelf(BasePermission):
|
|
19
|
-
"""
|
|
20
|
-
Permission to allow only admins or the user themselves to access the API.
|
|
21
|
-
|
|
22
|
-
Non-staff users cannot pass "username" that is not their own.
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
def has_permission(self, request: "Request", view: "APIView") -> bool: # noqa: ARG002
|
|
26
|
-
"""Check if the user is admin or accessing their own data."""
|
|
27
|
-
if request.user.is_staff:
|
|
28
|
-
return True
|
|
29
|
-
|
|
30
|
-
username = request.query_params.get("username") if request.method == "GET" else request.data.get("username")
|
|
31
|
-
|
|
32
|
-
# For learners, the username passed should match the logged in user.
|
|
33
|
-
if username:
|
|
34
|
-
return request.user.username == username
|
|
35
|
-
return True
|
|
36
|
-
|
|
37
|
-
|
|
38
22
|
class CanAccessLearningContext(BasePermission):
|
|
39
23
|
"""Permission to allow access to learning context if the user is enrolled."""
|
|
40
24
|
|
|
41
25
|
def has_permission(self, request: "Request", view: "APIView") -> bool:
|
|
42
|
-
"""Check if the user
|
|
26
|
+
"""Check if the user can access the learning context."""
|
|
43
27
|
try:
|
|
44
28
|
key = view.kwargs.get("learning_context_key") or request.query_params.get("learning_context_key")
|
|
45
29
|
learning_context_key = LearningContextKey.from_string(key)
|
|
@@ -51,13 +35,47 @@ class CanAccessLearningContext(BasePermission):
|
|
|
51
35
|
return True
|
|
52
36
|
|
|
53
37
|
if learning_context_key.is_course:
|
|
54
|
-
if
|
|
38
|
+
if self._can_access_course(learning_context_key, request.user):
|
|
55
39
|
return True
|
|
40
|
+
|
|
56
41
|
msg = "Course not found or user does not have access."
|
|
57
42
|
raise NotFound(msg)
|
|
58
43
|
|
|
59
|
-
|
|
44
|
+
# For learning paths, check enrollment or if it's not invite-only.
|
|
45
|
+
if self._can_access_learning_path(learning_context_key, request.user):
|
|
60
46
|
return True
|
|
61
47
|
|
|
62
48
|
msg = "Learning path not found or user does not have access."
|
|
63
49
|
raise NotFound(msg)
|
|
50
|
+
|
|
51
|
+
def _can_access_course(self, course_key: "CourseKey", user: "User") -> bool:
|
|
52
|
+
"""Check if user can access a course."""
|
|
53
|
+
# Check if user is enrolled in the course.
|
|
54
|
+
if get_course_enrollments(course_key, user.id): # ty: ignore[unresolved-attribute]
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
# Check if the course is a part of a learning path the user can access.
|
|
58
|
+
return self._can_access_course_via_learning_path(course_key, user)
|
|
59
|
+
|
|
60
|
+
def _get_accessible_learning_paths_filter(self, user: "User") -> Q:
|
|
61
|
+
"""Get Q filter for learning paths that the user can access."""
|
|
62
|
+
return Q(invite_only=False) | Q(learningpathenrollment__user=user, learningpathenrollment__is_active=True)
|
|
63
|
+
|
|
64
|
+
def _can_access_course_via_learning_path(self, course_key: "CourseKey", user: "User") -> bool:
|
|
65
|
+
"""Check if user can access a course through learning path membership."""
|
|
66
|
+
accessible_paths = (
|
|
67
|
+
LearningPath.objects.filter(steps__course_key=course_key)
|
|
68
|
+
.filter(self._get_accessible_learning_paths_filter(user))
|
|
69
|
+
.distinct()
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
return accessible_paths.exists()
|
|
73
|
+
|
|
74
|
+
def _can_access_learning_path(self, learning_path_key: "LearningPathKey", user: "User") -> bool:
|
|
75
|
+
"""Check if user can access a learning path."""
|
|
76
|
+
# Single query to check if learning path exists and user can access it
|
|
77
|
+
accessible_path = LearningPath.objects.filter(key=learning_path_key).filter(
|
|
78
|
+
self._get_accessible_learning_paths_filter(user)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
return accessible_path.exists()
|
|
@@ -2,16 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
from django.urls import path
|
|
4
4
|
|
|
5
|
-
from .views import
|
|
5
|
+
from .views import CredentialConfigurationCheckView
|
|
6
6
|
|
|
7
7
|
urlpatterns = [
|
|
8
|
-
# Credential eligibility endpoints
|
|
9
|
-
path('eligibility/<str:learning_context_key>/', CredentialEligibilityView.as_view(), name='credential-eligibility'),
|
|
10
8
|
path(
|
|
11
|
-
'
|
|
12
|
-
|
|
13
|
-
name='credential-
|
|
9
|
+
'configured/<str:learning_context_key>/',
|
|
10
|
+
CredentialConfigurationCheckView.as_view(),
|
|
11
|
+
name='credential-configuration-check',
|
|
14
12
|
),
|
|
15
|
-
# Credential listing endpoints
|
|
16
|
-
path('credentials/', CredentialListView.as_view(), name='credential-list'),
|
|
17
13
|
]
|
|
@@ -1,68 +1,26 @@
|
|
|
1
1
|
"""API views for Learning Credentials."""
|
|
2
2
|
|
|
3
|
-
import logging
|
|
4
3
|
from typing import TYPE_CHECKING
|
|
5
4
|
|
|
6
5
|
import edx_api_doc_tools as apidocs
|
|
7
|
-
from django.contrib.auth.models import User
|
|
8
|
-
from django.shortcuts import get_object_or_404
|
|
9
6
|
from edx_api_doc_tools import ParameterLocation
|
|
10
7
|
from rest_framework import status
|
|
11
8
|
from rest_framework.permissions import IsAuthenticated
|
|
12
9
|
from rest_framework.response import Response
|
|
13
10
|
from rest_framework.views import APIView
|
|
14
11
|
|
|
15
|
-
from learning_credentials.models import
|
|
16
|
-
from learning_credentials.tasks import generate_credential_for_user_task
|
|
12
|
+
from learning_credentials.models import CredentialConfiguration
|
|
17
13
|
|
|
18
|
-
from .permissions import CanAccessLearningContext
|
|
19
|
-
from .serializers import (
|
|
20
|
-
CredentialEligibilityResponseSerializer,
|
|
21
|
-
CredentialListResponseSerializer,
|
|
22
|
-
CredentialModelSerializer,
|
|
23
|
-
)
|
|
14
|
+
from .permissions import CanAccessLearningContext
|
|
24
15
|
|
|
25
16
|
if TYPE_CHECKING:
|
|
26
17
|
from rest_framework.request import Request
|
|
27
18
|
|
|
28
|
-
logger = logging.getLogger(__name__)
|
|
29
19
|
|
|
20
|
+
class CredentialConfigurationCheckView(APIView):
|
|
21
|
+
"""API view to check if any credentials are configured for a specific learning context."""
|
|
30
22
|
|
|
31
|
-
|
|
32
|
-
"""
|
|
33
|
-
API view for credential eligibility and generation.
|
|
34
|
-
|
|
35
|
-
This endpoint manages credential eligibility checking and generation for users in specific learning contexts.
|
|
36
|
-
|
|
37
|
-
Supported Learning Contexts:
|
|
38
|
-
- Course keys: `course-v1:org+course+run`
|
|
39
|
-
- Learning path keys: `path-v1:org+path+run+group`
|
|
40
|
-
"""
|
|
41
|
-
|
|
42
|
-
permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext)
|
|
43
|
-
|
|
44
|
-
def _get_eligibility_data(self, user: User, config: CredentialConfiguration) -> dict:
|
|
45
|
-
"""Calculate eligibility data for a credential configuration."""
|
|
46
|
-
progress_data = config.get_user_eligibility_details(user_id=user.id) # ty: ignore[unresolved-attribute]
|
|
47
|
-
|
|
48
|
-
existing_credential = (
|
|
49
|
-
Credential.objects.filter(
|
|
50
|
-
user_id=user.id, # ty: ignore[unresolved-attribute]
|
|
51
|
-
learning_context_key=config.learning_context_key,
|
|
52
|
-
credential_type=config.credential_type.name,
|
|
53
|
-
)
|
|
54
|
-
.exclude(status=Credential.Status.ERROR)
|
|
55
|
-
.first()
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
return {
|
|
59
|
-
'credential_type_id': config.credential_type.pk,
|
|
60
|
-
'name': config.credential_type.name,
|
|
61
|
-
'is_eligible': progress_data.get('is_eligible', False),
|
|
62
|
-
'existing_credential': existing_credential.uuid if existing_credential else None,
|
|
63
|
-
'existing_credential_url': existing_credential.download_url if existing_credential else None,
|
|
64
|
-
**progress_data,
|
|
65
|
-
}
|
|
23
|
+
permission_classes = (IsAuthenticated, CanAccessLearningContext)
|
|
66
24
|
|
|
67
25
|
@apidocs.schema(
|
|
68
26
|
parameters=[
|
|
@@ -76,282 +34,50 @@ class CredentialEligibilityView(APIView):
|
|
|
76
34
|
),
|
|
77
35
|
],
|
|
78
36
|
responses={
|
|
79
|
-
200:
|
|
37
|
+
200: "Boolean indicating if credentials are configured.",
|
|
80
38
|
400: "Invalid context key format.",
|
|
81
|
-
403: "User is not authenticated.",
|
|
39
|
+
403: "User is not authenticated or does not have permission to access the learning context.",
|
|
82
40
|
404: "Learning context not found or user does not have access.",
|
|
83
41
|
},
|
|
84
42
|
)
|
|
85
|
-
def get(self,
|
|
43
|
+
def get(self, _request: "Request", learning_context_key: str) -> Response:
|
|
86
44
|
"""
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Retrieve detailed eligibility information for all available credentials in a learning context.
|
|
90
|
-
This endpoint returns comprehensive progress data including:
|
|
91
|
-
- Current grades and requirements for grade-based credentials
|
|
92
|
-
- Completion percentages for completion-based credentials
|
|
93
|
-
- Step-by-step progress for learning paths
|
|
94
|
-
- Eligibility status for each credential type
|
|
45
|
+
Check if any credentials are configured for the given learning context.
|
|
95
46
|
|
|
96
47
|
**Example Request**
|
|
97
48
|
|
|
98
|
-
|
|
49
|
+
``GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/``
|
|
99
50
|
|
|
100
51
|
**Response Values**
|
|
101
52
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
-
|
|
106
|
-
- Completion-based credentials include `current_completion` and `required_completion`
|
|
107
|
-
- Learning paths include detailed `steps` breakdown
|
|
53
|
+
- **200 OK**: Request successful, returns credential configuration status.
|
|
54
|
+
- **400 Bad Request**: Invalid learning context key format.
|
|
55
|
+
- **403 Forbidden**: User is not authenticated or does not have permission to access the learning context.
|
|
56
|
+
- **404 Not Found**: Learning context not found or user does not have access.
|
|
108
57
|
|
|
109
|
-
**Example Response
|
|
110
|
-
|
|
111
|
-
```json
|
|
112
|
-
{
|
|
113
|
-
"context_key": "course-v1:OpenedX+DemoX+DemoCourse",
|
|
114
|
-
"credentials": [
|
|
115
|
-
{
|
|
116
|
-
"credential_type_id": 1,
|
|
117
|
-
"name": "Certificate of Achievement",
|
|
118
|
-
"description": "",
|
|
119
|
-
"is_eligible": true,
|
|
120
|
-
"existing_credential": null,
|
|
121
|
-
"current_grades": {
|
|
122
|
-
"Final Exam": 86,
|
|
123
|
-
"Overall Grade": 82
|
|
124
|
-
},
|
|
125
|
-
"required_grades": {
|
|
126
|
-
"Final Exam": 65,
|
|
127
|
-
"Overall Grade": 80
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
]
|
|
131
|
-
}
|
|
132
|
-
```
|
|
58
|
+
**Example Response**
|
|
133
59
|
|
|
134
|
-
|
|
60
|
+
.. code-block:: json
|
|
135
61
|
|
|
136
|
-
```json
|
|
137
|
-
{
|
|
138
|
-
"context_key": "course-v1:OpenedX+DemoX+DemoCourse",
|
|
139
|
-
"credentials": [
|
|
140
62
|
{
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"description": "",
|
|
144
|
-
"is_eligible": false,
|
|
145
|
-
"existing_credential": null,
|
|
146
|
-
"current_completion": 74.0,
|
|
147
|
-
"required_completion": 100.0
|
|
63
|
+
"has_credentials": true,
|
|
64
|
+
"credential_count": 2
|
|
148
65
|
}
|
|
149
|
-
]
|
|
150
|
-
}
|
|
151
|
-
```
|
|
152
|
-
"""
|
|
153
|
-
configurations = CredentialConfiguration.objects.filter(
|
|
154
|
-
learning_context_key=learning_context_key
|
|
155
|
-
).select_related('credential_type')
|
|
156
|
-
|
|
157
|
-
eligibility_data = [self._get_eligibility_data(request.user, config) for config in configurations]
|
|
158
|
-
|
|
159
|
-
response_data = {
|
|
160
|
-
'context_key': learning_context_key,
|
|
161
|
-
'credentials': eligibility_data,
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
serializer = CredentialEligibilityResponseSerializer(data=response_data)
|
|
165
|
-
serializer.is_valid(raise_exception=True)
|
|
166
|
-
return Response(serializer.data)
|
|
167
|
-
|
|
168
|
-
@apidocs.schema(
|
|
169
|
-
parameters=[
|
|
170
|
-
apidocs.string_parameter(
|
|
171
|
-
"learning_context_key",
|
|
172
|
-
ParameterLocation.PATH,
|
|
173
|
-
description="Learning context identifier (e.g. course-v1:OpenedX+DemoX+DemoCourse)",
|
|
174
|
-
),
|
|
175
|
-
apidocs.parameter(
|
|
176
|
-
"credential_type_id",
|
|
177
|
-
ParameterLocation.PATH,
|
|
178
|
-
int,
|
|
179
|
-
description="ID of the credential type to generate",
|
|
180
|
-
),
|
|
181
|
-
],
|
|
182
|
-
responses={
|
|
183
|
-
201: "Credential generation started.",
|
|
184
|
-
400: "User is not eligible for this credential or validation error.",
|
|
185
|
-
403: "User is not authenticated.",
|
|
186
|
-
404: "Learning context or credential type not found, or user does not have access.",
|
|
187
|
-
409: "User already has a valid credential of this type.",
|
|
188
|
-
500: "Internal server error during credential generation.",
|
|
189
|
-
},
|
|
190
|
-
)
|
|
191
|
-
def post(self, request: "Request", learning_context_key: str, credential_type_id: int) -> Response:
|
|
192
|
-
"""
|
|
193
|
-
Trigger credential generation for an eligible user.
|
|
194
|
-
|
|
195
|
-
This endpoint initiates the credential generation process for a specific credential type.
|
|
196
|
-
The user must be eligible for the credential based on the configured requirements.
|
|
197
|
-
|
|
198
|
-
**Prerequisites:**
|
|
199
|
-
- User must be authenticated
|
|
200
|
-
- User must be enrolled in the course or have access to the learning path
|
|
201
|
-
- User must meet the eligibility criteria for the specific credential type
|
|
202
|
-
- User must not already have an existing valid credential of this type
|
|
203
|
-
|
|
204
|
-
**Process:**
|
|
205
|
-
1. Validates user eligibility using the configured processor function
|
|
206
|
-
2. Checks for existing credentials to prevent duplicates
|
|
207
|
-
3. Initiates asynchronous credential generation
|
|
208
|
-
4. Returns credential status and tracking information
|
|
209
|
-
|
|
210
|
-
**Notification:**
|
|
211
|
-
Users will receive an email notification when credential generation completes.
|
|
212
|
-
|
|
213
|
-
**Example Request**
|
|
214
|
-
|
|
215
|
-
POST /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/1/
|
|
216
|
-
|
|
217
|
-
**Response Values**
|
|
218
66
|
|
|
219
|
-
|
|
67
|
+
**Response Fields**
|
|
220
68
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
```json
|
|
224
|
-
{
|
|
225
|
-
"status": "generating",
|
|
226
|
-
"credential_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
227
|
-
"message": "Credential generation started. You will receive an email when ready."
|
|
228
|
-
}
|
|
229
|
-
```
|
|
230
|
-
"""
|
|
231
|
-
config = get_object_or_404(
|
|
232
|
-
CredentialConfiguration.objects.select_related('credential_type'),
|
|
233
|
-
learning_context_key=learning_context_key,
|
|
234
|
-
credential_type_id=credential_type_id,
|
|
235
|
-
)
|
|
236
|
-
|
|
237
|
-
existing_credential = (
|
|
238
|
-
Credential.objects.filter(
|
|
239
|
-
user_id=request.user.id,
|
|
240
|
-
learning_context_key=learning_context_key,
|
|
241
|
-
credential_type=config.credential_type.name,
|
|
242
|
-
)
|
|
243
|
-
.exclude(status=Credential.Status.ERROR)
|
|
244
|
-
.first()
|
|
245
|
-
)
|
|
246
|
-
|
|
247
|
-
if existing_credential:
|
|
248
|
-
return Response({"detail": "User already has a credential of this type."}, status=status.HTTP_409_CONFLICT)
|
|
249
|
-
|
|
250
|
-
if not config.get_eligible_user_ids(user_id=request.user.id):
|
|
251
|
-
return Response({"detail": "User is not eligible for this credential."}, status=status.HTTP_400_BAD_REQUEST)
|
|
252
|
-
|
|
253
|
-
generate_credential_for_user_task.delay(config.id, request.user.id)
|
|
254
|
-
return Response({"detail": "Credential generation started."}, status=status.HTTP_201_CREATED)
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
class CredentialListView(APIView):
|
|
258
|
-
"""
|
|
259
|
-
API view to list user credentials with staff override capability.
|
|
260
|
-
|
|
261
|
-
This endpoint provides access to user credential records with optional filtering
|
|
262
|
-
by learning context and staff oversight capabilities.
|
|
69
|
+
- ``has_credentials``: Boolean indicating if any credentials are configured
|
|
70
|
+
- ``credential_count``: Number of credential configurations available
|
|
263
71
|
|
|
264
|
-
|
|
72
|
+
**Note**
|
|
265
73
|
|
|
266
|
-
|
|
267
|
-
- Staff users can view credentials for any user by providing `username` parameter
|
|
268
|
-
- Non-staff users can only view their own credentials
|
|
269
|
-
"""
|
|
270
|
-
|
|
271
|
-
def get_permissions(self) -> list:
|
|
272
|
-
"""Instantiate and return the list of permissions required for this view."""
|
|
273
|
-
permission_classes = [IsAuthenticated, IsAdminOrSelf]
|
|
274
|
-
|
|
275
|
-
if self.request.query_params.get('learning_context_key'):
|
|
276
|
-
permission_classes.append(CanAccessLearningContext)
|
|
277
|
-
|
|
278
|
-
return [permission() for permission in permission_classes]
|
|
279
|
-
|
|
280
|
-
@apidocs.schema(
|
|
281
|
-
parameters=[
|
|
282
|
-
apidocs.string_parameter(
|
|
283
|
-
"learning_context_key",
|
|
284
|
-
ParameterLocation.QUERY,
|
|
285
|
-
description="Optional learning context to filter credentials (e.g. course-v1:OpenedX+DemoX+DemoCourse)",
|
|
286
|
-
),
|
|
287
|
-
apidocs.string_parameter(
|
|
288
|
-
"username",
|
|
289
|
-
ParameterLocation.QUERY,
|
|
290
|
-
description="Username to view credentials for (staff only)",
|
|
291
|
-
),
|
|
292
|
-
],
|
|
293
|
-
responses={
|
|
294
|
-
200: CredentialListResponseSerializer,
|
|
295
|
-
403: "User is not authenticated or lacks permission to view specified user's credentials.",
|
|
296
|
-
404: "Specified user not found or learning context not found/accessible.",
|
|
297
|
-
},
|
|
298
|
-
)
|
|
299
|
-
def get(self, request: "Request") -> Response:
|
|
74
|
+
This endpoint does not perform learning context existence validation, so it will not return 404 for staff users.
|
|
300
75
|
"""
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
This endpoint returns credential records with filtering options:
|
|
304
|
-
- Filter by learning context (course or learning path)
|
|
305
|
-
- Staff users can view credentials for any user
|
|
306
|
-
- Regular users can only view their own credentials
|
|
307
|
-
|
|
308
|
-
**Query Parameters:**
|
|
309
|
-
- `username` (staff only): View credentials for a specific user
|
|
310
|
-
|
|
311
|
-
**Path Parameters:**
|
|
312
|
-
- `learning_context_key` (optional): Filter credentials by learning context
|
|
313
|
-
|
|
314
|
-
**Response includes:**
|
|
315
|
-
- Credential ID and type
|
|
316
|
-
- Learning context information
|
|
317
|
-
- Creation date and status
|
|
318
|
-
- Download URL for completed credentials
|
|
319
|
-
|
|
320
|
-
**Example Request**
|
|
321
|
-
|
|
322
|
-
GET /api/learning_credentials/v1/credentials/
|
|
323
|
-
GET /api/learning_credentials/v1/credentials/course-v1:OpenedX+DemoX+DemoCourse/
|
|
324
|
-
GET /api/learning_credentials/v1/credentials/?username=student123 # staff only
|
|
76
|
+
credential_count = CredentialConfiguration.objects.filter(learning_context_key=learning_context_key).count()
|
|
325
77
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
**Example Response**
|
|
331
|
-
|
|
332
|
-
```json
|
|
333
|
-
{
|
|
334
|
-
"credentials": [
|
|
335
|
-
{
|
|
336
|
-
"credential_id": "123e4567-e89b-12d3-a456-426614174000",
|
|
337
|
-
"credential_type": "Certificate of Achievement",
|
|
338
|
-
"context_key": "course-v1:OpenedX+DemoX+DemoCourse",
|
|
339
|
-
"status": "available",
|
|
340
|
-
"created_date": "2024-08-20T10:30:00Z",
|
|
341
|
-
"download_url": "https://example.com/credentials/123e4567.pdf"
|
|
342
|
-
}
|
|
343
|
-
]
|
|
78
|
+
response_data = {
|
|
79
|
+
'has_credentials': credential_count > 0,
|
|
80
|
+
'credential_count': credential_count,
|
|
344
81
|
}
|
|
345
|
-
```
|
|
346
|
-
"""
|
|
347
|
-
learning_context_key = request.query_params.get('learning_context_key')
|
|
348
|
-
username = request.query_params.get('username')
|
|
349
|
-
user = get_object_or_404(User, username=username) if username else request.user
|
|
350
|
-
|
|
351
|
-
credentials_queryset = Credential.objects.filter(user_id=user.pk)
|
|
352
|
-
|
|
353
|
-
if learning_context_key:
|
|
354
|
-
credentials_queryset = credentials_queryset.filter(learning_context_key=learning_context_key)
|
|
355
82
|
|
|
356
|
-
|
|
357
|
-
return Response({'credentials': credentials_data})
|
|
83
|
+
return Response(response_data, status=status.HTTP_200_OK)
|
learning_credentials/apps.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import ClassVar
|
|
6
6
|
|
|
7
7
|
from django.apps import AppConfig
|
|
8
|
+
from edx_django_utils.plugins.constants import PluginSettings, PluginURLs
|
|
8
9
|
|
|
9
10
|
|
|
10
11
|
class LearningCredentialsConfig(AppConfig):
|
|
@@ -15,20 +16,16 @@ class LearningCredentialsConfig(AppConfig):
|
|
|
15
16
|
|
|
16
17
|
# https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html
|
|
17
18
|
plugin_app: ClassVar[dict[str, dict[str, dict]]] = {
|
|
18
|
-
|
|
19
|
+
PluginURLs.CONFIG: {
|
|
19
20
|
'lms.djangoapp': {
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
PluginURLs.NAMESPACE: 'learning_credentials',
|
|
22
|
+
PluginURLs.APP_NAME: 'learning_credentials',
|
|
22
23
|
}
|
|
23
24
|
},
|
|
24
|
-
|
|
25
|
+
PluginSettings.CONFIG: {
|
|
25
26
|
'lms.djangoapp': {
|
|
26
|
-
'common': {
|
|
27
|
-
'production': {
|
|
27
|
+
'common': {PluginSettings.RELATIVE_PATH: 'settings.common'},
|
|
28
|
+
'production': {PluginSettings.RELATIVE_PATH: 'settings.production'},
|
|
28
29
|
},
|
|
29
|
-
# 'cms.djangoapp': {
|
|
30
|
-
# 'common': {'relative_path': 'settings.common'},
|
|
31
|
-
# 'production': {'relative_path': 'settings.production'},
|
|
32
|
-
# },
|
|
33
30
|
},
|
|
34
31
|
}
|
learning_credentials/compat.py
CHANGED
|
@@ -74,13 +74,7 @@ def get_learning_context_name(learning_context_key: LearningContextKey) -> str:
|
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
def get_course_enrollments(course_id: CourseKey, user_id: int | None = None) -> list[User]:
|
|
77
|
-
"""
|
|
78
|
-
Get the course enrollments from Open edX.
|
|
79
|
-
|
|
80
|
-
:param course_id: The course ID.
|
|
81
|
-
:param user_id: Optional. If provided, will filter the enrollments by user.
|
|
82
|
-
:return: A list of users enrolled in the course.
|
|
83
|
-
"""
|
|
77
|
+
"""Get the course enrollments from Open edX."""
|
|
84
78
|
# noinspection PyUnresolvedReferences,PyPackageRequirements
|
|
85
79
|
from common.djangoapps.student.models import CourseEnrollment
|
|
86
80
|
|
|
@@ -89,7 +89,6 @@ def _write_text_on_template(template: any, font: str, username: str, context_nam
|
|
|
89
89
|
pdf_canvas = canvas.Canvas(io.BytesIO(), pagesize=(template_width, template_height))
|
|
90
90
|
|
|
91
91
|
# Write the learner name.
|
|
92
|
-
# TODO: Add tests.
|
|
93
92
|
pdf_canvas.setFont(font, options.get('name_size', 32))
|
|
94
93
|
name_color = options.get('name_color', '#000')
|
|
95
94
|
pdf_canvas.setFillColorRGB(*hex_to_rgb(name_color))
|
learning_credentials/models.py
CHANGED
|
@@ -7,7 +7,7 @@ import logging
|
|
|
7
7
|
import uuid
|
|
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
|
|
11
11
|
|
|
12
12
|
import jsonfield
|
|
13
13
|
from django.conf import settings
|
|
@@ -165,12 +165,11 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
165
165
|
filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials)
|
|
166
166
|
return list(filtered_user_ids_set)
|
|
167
167
|
|
|
168
|
-
def
|
|
168
|
+
def get_eligible_user_ids(self) -> list[int]:
|
|
169
169
|
"""
|
|
170
|
-
|
|
170
|
+
Get the list of eligible learners for the given course.
|
|
171
171
|
|
|
172
|
-
:
|
|
173
|
-
:return: Raw result from the retrieval function - list of user IDs or user details dict.
|
|
172
|
+
:return: A list of user IDs.
|
|
174
173
|
"""
|
|
175
174
|
func_path = self.credential_type.retrieval_func
|
|
176
175
|
module_path, func_name = func_path.rsplit('.', 1)
|
|
@@ -178,40 +177,7 @@ class CredentialConfiguration(TimeStampedModel):
|
|
|
178
177
|
func = getattr(module, func_name)
|
|
179
178
|
|
|
180
179
|
custom_options = {**self.credential_type.custom_options, **self.custom_options}
|
|
181
|
-
return func(self.learning_context_key, custom_options
|
|
182
|
-
|
|
183
|
-
def get_eligible_user_ids(self, user_id: int | None = None) -> list[int]:
|
|
184
|
-
"""
|
|
185
|
-
Get the list of eligible learners for the given course.
|
|
186
|
-
|
|
187
|
-
:param user_id: Optional. If provided, will check eligibility for the specific user.
|
|
188
|
-
:return: A list of user IDs.
|
|
189
|
-
"""
|
|
190
|
-
result = self._call_retrieval_func(user_id)
|
|
191
|
-
|
|
192
|
-
if user_id is not None:
|
|
193
|
-
# Single user case: return list with user ID if eligible
|
|
194
|
-
if isinstance(result, dict) and result.get('is_eligible', False):
|
|
195
|
-
return [user_id]
|
|
196
|
-
return []
|
|
197
|
-
|
|
198
|
-
# Multiple users case: result should already be a list of user IDs
|
|
199
|
-
return result if isinstance(result, list) else []
|
|
200
|
-
|
|
201
|
-
def get_user_eligibility_details(self, user_id: int) -> dict[str, Any]:
|
|
202
|
-
"""
|
|
203
|
-
Get detailed eligibility information for a specific user.
|
|
204
|
-
|
|
205
|
-
:param user_id: The user ID to check eligibility for.
|
|
206
|
-
:return: Dictionary containing eligibility details and progress information.
|
|
207
|
-
"""
|
|
208
|
-
result = self._call_retrieval_func(user_id)
|
|
209
|
-
|
|
210
|
-
if isinstance(result, dict):
|
|
211
|
-
return result
|
|
212
|
-
|
|
213
|
-
# Fallback for processors that don't support detailed results
|
|
214
|
-
return {'is_eligible': False}
|
|
180
|
+
return func(self.learning_context_key, custom_options)
|
|
215
181
|
|
|
216
182
|
def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0):
|
|
217
183
|
"""
|
|
@@ -328,15 +294,15 @@ class Credential(TimeStampedModel):
|
|
|
328
294
|
learning_context_name = get_learning_context_name(self.learning_context_key)
|
|
329
295
|
user = get_user_model().objects.get(id=self.user_id)
|
|
330
296
|
msg = Message(
|
|
331
|
-
name="certificate_generated",
|
|
332
|
-
app_label="learning_credentials",
|
|
333
|
-
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
334
|
-
language='en',
|
|
297
|
+
name="certificate_generated",
|
|
298
|
+
app_label="learning_credentials",
|
|
299
|
+
recipient=Recipient(lms_user_id=user.id, email_address=user.email),
|
|
300
|
+
language='en',
|
|
335
301
|
context={
|
|
336
302
|
'certificate_link': self.download_url,
|
|
337
303
|
'course_name': learning_context_name,
|
|
338
304
|
'platform_name': settings.PLATFORM_NAME,
|
|
339
|
-
},
|
|
305
|
+
},
|
|
340
306
|
)
|
|
341
307
|
ace.send(msg)
|
|
342
308
|
|