learning-credentials 0.3.0rc4__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.
@@ -2,7 +2,8 @@
2
2
 
3
3
  from typing import TYPE_CHECKING
4
4
 
5
- from learning_paths.models import LearningPathEnrollment
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 is enrolled in the learning context."""
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 bool(get_course_enrollments(learning_context_key, request.user.id)):
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
- if LearningPathEnrollment.objects.filter(learning_path__key=learning_context_key, user=request.user).exists():
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,22 +2,12 @@
2
2
 
3
3
  from django.urls import path
4
4
 
5
- from .views import CredentialConfigurationCheckView, CredentialEligibilityView, CredentialListView
5
+ from .views import CredentialConfigurationCheckView
6
6
 
7
7
  urlpatterns = [
8
- # Credential configuration check endpoint
9
8
  path(
10
9
  'configured/<str:learning_context_key>/',
11
10
  CredentialConfigurationCheckView.as_view(),
12
11
  name='credential-configuration-check',
13
12
  ),
14
- # Credential eligibility endpoints
15
- path('eligibility/<str:learning_context_key>/', CredentialEligibilityView.as_view(), name='credential-eligibility'),
16
- path(
17
- 'eligibility/<str:learning_context_key>/<int:credential_type_id>/',
18
- CredentialEligibilityView.as_view(),
19
- name='credential-generation',
20
- ),
21
- # Credential listing endpoints
22
- path('credentials/', CredentialListView.as_view(), name='credential-list'),
23
13
  ]
@@ -1,37 +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 Credential, CredentialConfiguration
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, IsAdminOrSelf
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
-
30
19
 
31
20
  class CredentialConfigurationCheckView(APIView):
32
21
  """API view to check if any credentials are configured for a specific learning context."""
33
22
 
34
- permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext)
23
+ permission_classes = (IsAuthenticated, CanAccessLearningContext)
35
24
 
36
25
  @apidocs.schema(
37
26
  parameters=[
@@ -47,6 +36,7 @@ class CredentialConfigurationCheckView(APIView):
47
36
  responses={
48
37
  200: "Boolean indicating if credentials are configured.",
49
38
  400: "Invalid context key format.",
39
+ 403: "User is not authenticated or does not have permission to access the learning context.",
50
40
  404: "Learning context not found or user does not have access.",
51
41
  },
52
42
  )
@@ -56,26 +46,31 @@ class CredentialConfigurationCheckView(APIView):
56
46
 
57
47
  **Example Request**
58
48
 
59
- GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/
49
+ ``GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/``
60
50
 
61
51
  **Response Values**
62
52
 
63
- If the request is successful, an HTTP 200 "OK" response is returned.
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.
64
57
 
65
58
  **Example Response**
66
59
 
67
- ```json
68
- {
69
- "has_credentials": true,
70
- "credential_count": 2
71
- }
72
- ```
60
+ .. code-block:: json
61
+
62
+ {
63
+ "has_credentials": true,
64
+ "credential_count": 2
65
+ }
73
66
 
74
67
  **Response Fields**
75
- - `has_credentials`: Boolean indicating if any credentials are configured
76
- - `credential_count`: Number of credential configurations available
68
+
69
+ - ``has_credentials``: Boolean indicating if any credentials are configured
70
+ - ``credential_count``: Number of credential configurations available
77
71
 
78
72
  **Note**
73
+
79
74
  This endpoint does not perform learning context existence validation, so it will not return 404 for staff users.
80
75
  """
81
76
  credential_count = CredentialConfiguration.objects.filter(learning_context_key=learning_context_key).count()
@@ -86,346 +81,3 @@ class CredentialConfigurationCheckView(APIView):
86
81
  }
87
82
 
88
83
  return Response(response_data, status=status.HTTP_200_OK)
89
-
90
-
91
- class CredentialEligibilityView(APIView):
92
- """
93
- API view for credential eligibility and generation.
94
-
95
- This endpoint manages credential eligibility checking and generation for users in specific learning contexts.
96
-
97
- Supported Learning Contexts:
98
- - Course keys: `course-v1:org+course+run`
99
- - Learning path keys: `path-v1:org+path+run+group`
100
-
101
- **Staff Features**:
102
- - Staff users can view eligibility for any user by providing `username` parameter
103
- - Non-staff users can only view their own eligibility
104
- """
105
-
106
- permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext)
107
-
108
- def _get_eligibility_data(self, user: User, config: CredentialConfiguration) -> dict:
109
- """Calculate eligibility data for a credential configuration."""
110
- progress_data = config.get_user_eligibility_details(user_id=user.id) # ty: ignore[unresolved-attribute]
111
-
112
- existing_credential = (
113
- Credential.objects.filter(
114
- user_id=user.id, # ty: ignore[unresolved-attribute]
115
- learning_context_key=config.learning_context_key,
116
- credential_type=config.credential_type.name,
117
- )
118
- .exclude(status=Credential.Status.ERROR)
119
- .first()
120
- )
121
-
122
- return {
123
- 'credential_type_id': config.credential_type.pk,
124
- 'name': config.credential_type.name,
125
- 'is_eligible': progress_data.get('is_eligible', False),
126
- 'existing_credential': existing_credential.uuid if existing_credential else None,
127
- 'existing_credential_url': existing_credential.download_url if existing_credential else None,
128
- **progress_data,
129
- }
130
-
131
- @apidocs.schema(
132
- parameters=[
133
- apidocs.string_parameter(
134
- "learning_context_key",
135
- ParameterLocation.PATH,
136
- description=(
137
- "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) "
138
- "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)"
139
- ),
140
- ),
141
- ],
142
- responses={
143
- 200: CredentialEligibilityResponseSerializer,
144
- 400: "Invalid context key format.",
145
- 403: "User is not authenticated.",
146
- 404: "Learning context not found or user does not have access.",
147
- },
148
- )
149
- def get(self, request: "Request", learning_context_key: str) -> Response:
150
- """
151
- Get credential eligibility for a learning context.
152
-
153
- Retrieve detailed eligibility information for all available credentials in a learning context.
154
- This endpoint returns comprehensive progress data including:
155
- - Current grades and requirements for grade-based credentials
156
- - Completion percentages for completion-based credentials
157
- - Step-by-step progress for learning paths
158
- - Eligibility status for each credential type
159
-
160
- **Query Parameters:**
161
- - `username` (staff only): View eligibility for a specific user
162
-
163
- **Example Request**
164
-
165
- GET /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/
166
-
167
- **Response Values**
168
-
169
- If the request is successful, an HTTP 200 "OK" response is returned.
170
-
171
- The response structure adapts based on credential types:
172
- - Grade-based credentials include `current_grades` and `required_grades`
173
- - Completion-based credentials include `current_completion` and `required_completion`
174
- - Learning paths include detailed `steps` breakdown
175
-
176
- **Example Response for Grade-based Credential**
177
-
178
- ```json
179
- {
180
- "context_key": "course-v1:OpenedX+DemoX+DemoCourse",
181
- "credentials": [
182
- {
183
- "credential_type_id": 1,
184
- "name": "Certificate of Achievement",
185
- "description": "",
186
- "is_eligible": true,
187
- "existing_credential": null,
188
- "current_grades": {
189
- "Final Exam": 86,
190
- "Overall Grade": 82
191
- },
192
- "required_grades": {
193
- "Final Exam": 65,
194
- "Overall Grade": 80
195
- }
196
- }
197
- ]
198
- }
199
- ```
200
-
201
- **Example Response for Completion-based Credential**
202
-
203
- ```json
204
- {
205
- "context_key": "course-v1:OpenedX+DemoX+DemoCourse",
206
- "credentials": [
207
- {
208
- "credential_type_id": 2,
209
- "name": "Certificate of Completion",
210
- "description": "",
211
- "is_eligible": false,
212
- "existing_credential": null,
213
- "current_completion": 74.0,
214
- "required_completion": 100.0
215
- }
216
- ]
217
- }
218
- ```
219
- """
220
- username = request.query_params.get('username')
221
- user = get_object_or_404(User, username=username) if username else request.user
222
-
223
- configurations = CredentialConfiguration.objects.filter(
224
- learning_context_key=learning_context_key
225
- ).select_related('credential_type')
226
-
227
- eligibility_data = [self._get_eligibility_data(user, config) for config in configurations]
228
-
229
- response_data = {
230
- 'context_key': learning_context_key,
231
- 'credentials': eligibility_data,
232
- }
233
-
234
- serializer = CredentialEligibilityResponseSerializer(data=response_data)
235
- serializer.is_valid(raise_exception=True)
236
- return Response(serializer.data)
237
-
238
- @apidocs.schema(
239
- parameters=[
240
- apidocs.string_parameter(
241
- "learning_context_key",
242
- ParameterLocation.PATH,
243
- description="Learning context identifier (e.g. course-v1:OpenedX+DemoX+DemoCourse)",
244
- ),
245
- apidocs.parameter(
246
- "credential_type_id",
247
- ParameterLocation.PATH,
248
- int,
249
- description="ID of the credential type to generate",
250
- ),
251
- ],
252
- responses={
253
- 201: "Credential generation started.",
254
- 400: "User is not eligible for this credential or validation error.",
255
- 403: "User is not authenticated.",
256
- 404: "Learning context or credential type not found, or user does not have access.",
257
- 409: "User already has a valid credential of this type.",
258
- 500: "Internal server error during credential generation.",
259
- },
260
- )
261
- def post(self, request: "Request", learning_context_key: str, credential_type_id: int) -> Response:
262
- """
263
- Trigger credential generation for an eligible user.
264
-
265
- This endpoint initiates the credential generation process for a specific credential type.
266
- The user must be eligible for the credential based on the configured requirements.
267
-
268
- **Prerequisites:**
269
- - User must be authenticated
270
- - User must be enrolled in the course or have access to the learning path
271
- - User must meet the eligibility criteria for the specific credential type
272
- - User must not already have an existing valid credential of this type
273
-
274
- **Process:**
275
- 1. Validates user eligibility using the configured processor function
276
- 2. Checks for existing credentials to prevent duplicates
277
- 3. Initiates asynchronous credential generation
278
- 4. Returns credential status and tracking information
279
-
280
- **Notification:**
281
- Users will receive an email notification when credential generation completes.
282
-
283
- **Query Parameters:**
284
- - `username` (staff only): Trigger credential generation for a specific user
285
-
286
- **Example Request**
287
-
288
- POST /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/1/
289
-
290
- **Response Values**
291
-
292
- If the request is successful, an HTTP 201 "Created" response is returned.
293
-
294
- **Example Response**
295
-
296
- ```json
297
- {
298
- "status": "generating",
299
- "credential_id": "123e4567-e89b-12d3-a456-426614174000",
300
- "message": "Credential generation started. You will receive an email when ready."
301
- }
302
- ```
303
- """
304
- username = request.query_params.get('username')
305
- user = get_object_or_404(User, username=username) if username else request.user
306
-
307
- config = get_object_or_404(
308
- CredentialConfiguration.objects.select_related('credential_type'),
309
- learning_context_key=learning_context_key,
310
- credential_type_id=credential_type_id,
311
- )
312
-
313
- existing_credential = (
314
- Credential.objects.filter(
315
- user_id=user.id,
316
- learning_context_key=learning_context_key,
317
- credential_type=config.credential_type.name,
318
- )
319
- .exclude(status=Credential.Status.ERROR)
320
- .first()
321
- )
322
-
323
- if existing_credential:
324
- return Response({"detail": "User already has a credential of this type."}, status=status.HTTP_409_CONFLICT)
325
-
326
- if not config.get_eligible_user_ids(user_id=user.id):
327
- return Response({"detail": "User is not eligible for this credential."}, status=status.HTTP_400_BAD_REQUEST)
328
-
329
- generate_credential_for_user_task.delay(config.id, user.id)
330
- return Response({"detail": "Credential generation started."}, status=status.HTTP_201_CREATED)
331
-
332
-
333
- class CredentialListView(APIView):
334
- """
335
- API view to list user credentials with staff override capability.
336
-
337
- This endpoint provides access to user credential records with optional filtering
338
- by learning context and staff oversight capabilities.
339
-
340
- **Authentication Required**: Yes
341
-
342
- **Staff Features**:
343
- - Staff users can view credentials for any user by providing `username` parameter
344
- - Non-staff users can only view their own credentials
345
- """
346
-
347
- def get_permissions(self) -> list:
348
- """Instantiate and return the list of permissions required for this view."""
349
- permission_classes = [IsAuthenticated, IsAdminOrSelf]
350
-
351
- if self.request.query_params.get('learning_context_key'):
352
- permission_classes.append(CanAccessLearningContext)
353
-
354
- return [permission() for permission in permission_classes]
355
-
356
- @apidocs.schema(
357
- parameters=[
358
- apidocs.string_parameter(
359
- "learning_context_key",
360
- ParameterLocation.QUERY,
361
- description="Optional learning context to filter credentials (e.g. course-v1:OpenedX+DemoX+DemoCourse)",
362
- ),
363
- apidocs.string_parameter(
364
- "username",
365
- ParameterLocation.QUERY,
366
- description="Username to view credentials for (staff only)",
367
- ),
368
- ],
369
- responses={
370
- 200: CredentialListResponseSerializer,
371
- 403: "User is not authenticated or lacks permission to view specified user's credentials.",
372
- 404: "Specified user not found or learning context not found/accessible.",
373
- },
374
- )
375
- def get(self, request: "Request") -> Response:
376
- """
377
- Retrieve a list of credentials for the authenticated user or a specified user.
378
-
379
- This endpoint returns credential records with filtering options:
380
- - Filter by learning context (course or learning path)
381
- - Staff users can view credentials for any user
382
- - Regular users can only view their own credentials
383
-
384
- **Query Parameters:**
385
- - `username` (staff only): View credentials for a specific user
386
- - `learning_context_key` (optional): Filter credentials by learning context
387
-
388
- **Response includes:**
389
- - Credential ID and type
390
- - Learning context information
391
- - Creation date and status
392
- - Download URL for completed credentials
393
-
394
- **Example Request**
395
-
396
- GET /api/learning_credentials/v1/credentials/
397
- GET /api/learning_credentials/v1/credentials/course-v1:OpenedX+DemoX+DemoCourse/
398
- GET /api/learning_credentials/v1/credentials/?username=student123 # staff only
399
-
400
- **Response Values**
401
-
402
- If the request is successful, an HTTP 200 "OK" response is returned.
403
-
404
- **Example Response**
405
-
406
- ```json
407
- {
408
- "credentials": [
409
- {
410
- "credential_id": "123e4567-e89b-12d3-a456-426614174000",
411
- "credential_type": "Certificate of Achievement",
412
- "context_key": "course-v1:OpenedX+DemoX+DemoCourse",
413
- "status": "available",
414
- "created_date": "2024-08-20T10:30:00Z",
415
- "download_url": "https://example.com/credentials/123e4567.pdf"
416
- }
417
- ]
418
- }
419
- ```
420
- """
421
- learning_context_key = request.query_params.get('learning_context_key')
422
- username = request.query_params.get('username')
423
- user = get_object_or_404(User, username=username) if username else request.user
424
-
425
- credentials_queryset = Credential.objects.filter(user_id=user.pk)
426
-
427
- if learning_context_key:
428
- credentials_queryset = credentials_queryset.filter(learning_context_key=learning_context_key)
429
-
430
- credentials_data = CredentialModelSerializer(credentials_queryset, many=True).data
431
- return Response({'credentials': credentials_data})
@@ -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
- 'url_config': {
19
+ PluginURLs.CONFIG: {
19
20
  'lms.djangoapp': {
20
- 'namespace': 'learning_credentials',
21
- 'app_name': 'learning_credentials',
21
+ PluginURLs.NAMESPACE: 'learning_credentials',
22
+ PluginURLs.APP_NAME: 'learning_credentials',
22
23
  }
23
24
  },
24
- 'settings_config': {
25
+ PluginSettings.CONFIG: {
25
26
  'lms.djangoapp': {
26
- 'common': {'relative_path': 'settings.common'},
27
- 'production': {'relative_path': 'settings.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
  }
@@ -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))