learning-credentials 0.2.4__tar.gz → 0.3.0__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 (53) hide show
  1. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/CHANGELOG.rst +8 -0
  2. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/MANIFEST.in +0 -2
  3. {learning_credentials-0.2.4/learning_credentials.egg-info → learning_credentials-0.3.0}/PKG-INFO +12 -2
  4. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/README.rst +1 -1
  5. learning_credentials-0.3.0/learning_credentials/api/__init__.py +1 -0
  6. learning_credentials-0.3.0/learning_credentials/api/urls.py +9 -0
  7. learning_credentials-0.3.0/learning_credentials/api/v1/__init__.py +1 -0
  8. learning_credentials-0.3.0/learning_credentials/api/v1/permissions.py +81 -0
  9. learning_credentials-0.3.0/learning_credentials/api/v1/urls.py +13 -0
  10. learning_credentials-0.3.0/learning_credentials/api/v1/views.py +83 -0
  11. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/apps.py +10 -3
  12. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/compat.py +4 -1
  13. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/models.py +1 -1
  14. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/settings/common.py +5 -2
  15. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/settings/production.py +5 -2
  16. learning_credentials-0.3.0/learning_credentials/urls.py +9 -0
  17. {learning_credentials-0.2.4 → learning_credentials-0.3.0/learning_credentials.egg-info}/PKG-INFO +12 -2
  18. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials.egg-info/SOURCES.txt +8 -2
  19. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials.egg-info/requires.txt +2 -0
  20. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/pyproject.toml +40 -8
  21. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/tests/test_generators.py +1 -1
  22. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/tests/test_processors.py +3 -1
  23. learning_credentials-0.3.0/tests/test_views.py +304 -0
  24. learning_credentials-0.2.4/learning_credentials/urls.py +0 -9
  25. learning_credentials-0.2.4/learning_credentials/views.py +0 -1
  26. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/LICENSE.txt +0 -0
  27. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/__init__.py +0 -0
  28. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/admin.py +0 -0
  29. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/conf/locale/config.yaml +0 -0
  30. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/exceptions.py +0 -0
  31. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/generators.py +0 -0
  32. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0001_initial.py +0 -0
  33. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0002_migrate_to_learning_credentials.py +0 -0
  34. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0003_rename_certificates_to_credentials.py +0 -0
  35. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0004_replace_course_keys_with_learning_context_keys.py +0 -0
  36. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0005_rename_processors_and_generators.py +0 -0
  37. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/0006_cleanup_openedx_certificates_tables.py +0 -0
  38. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/migrations/__init__.py +0 -0
  39. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/processors.py +0 -0
  40. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/settings/__init__.py +0 -0
  41. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/tasks.py +0 -0
  42. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/base.html +0 -0
  43. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.html +0 -0
  44. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/body.txt +0 -0
  45. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/from_name.txt +0 -0
  46. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/head.html +0 -0
  47. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials/templates/learning_credentials/edx_ace/certificate_generated/email/subject.txt +0 -0
  48. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials.egg-info/dependency_links.txt +0 -0
  49. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials.egg-info/entry_points.txt +0 -0
  50. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/learning_credentials.egg-info/top_level.txt +0 -0
  51. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/setup.cfg +0 -0
  52. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/tests/test_models.py +0 -0
  53. {learning_credentials-0.2.4 → learning_credentials-0.3.0}/tests/test_tasks.py +0 -0
@@ -16,6 +16,14 @@ Unreleased
16
16
 
17
17
  *
18
18
 
19
+ 0.3.0 - 2025-09-17
20
+ ******************
21
+
22
+ Added
23
+ =====
24
+
25
+ * REST API endpoint to check if credentials are configured for a learning context.
26
+
19
27
  0.2.4 - 2025-09-07
20
28
 
21
29
  Added
@@ -1,6 +1,4 @@
1
1
  include CHANGELOG.rst
2
2
  include LICENSE.txt
3
3
  include README.rst
4
- include requirements/base.in
5
- include requirements/constraints.txt
6
4
  recursive-include learning_credentials *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.txt
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.2.4
3
+ Version: 0.3.0
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
@@ -21,6 +21,8 @@ Description-Content-Type: text/x-rst
21
21
  License-File: LICENSE.txt
22
22
  Requires-Dist: django
23
23
  Requires-Dist: django-model-utils
24
+ Requires-Dist: edx-api-doc-tools
25
+ Requires-Dist: edx-django-utils
24
26
  Requires-Dist: edx-opaque-keys
25
27
  Requires-Dist: celery
26
28
  Requires-Dist: django-celery-beat
@@ -122,7 +124,7 @@ file in this repo.
122
124
  Reporting Security Issues
123
125
  *************************
124
126
 
125
- Please do not report security issues in public. Please email security@openedx.org.
127
+ Please do not report security issues in public. Please email help@opencraft.com.
126
128
 
127
129
  .. |pypi-badge| image:: https://img.shields.io/pypi/v/learning-credentials.svg
128
130
  :target: https://pypi.python.org/pypi/learning-credentials/
@@ -174,6 +176,14 @@ Unreleased
174
176
 
175
177
  *
176
178
 
179
+ 0.3.0 - 2025-09-17
180
+ ******************
181
+
182
+ Added
183
+ =====
184
+
185
+ * REST API endpoint to check if credentials are configured for a learning context.
186
+
177
187
  0.2.4 - 2025-09-07
178
188
 
179
189
  Added
@@ -86,7 +86,7 @@ file in this repo.
86
86
  Reporting Security Issues
87
87
  *************************
88
88
 
89
- Please do not report security issues in public. Please email security@openedx.org.
89
+ Please do not report security issues in public. Please email help@opencraft.com.
90
90
 
91
91
  .. |pypi-badge| image:: https://img.shields.io/pypi/v/learning-credentials.svg
92
92
  :target: https://pypi.python.org/pypi/learning-credentials/
@@ -0,0 +1 @@
1
+ """Learning Credentials API package."""
@@ -0,0 +1,9 @@
1
+ """API URLs."""
2
+
3
+ from django.urls import include, path
4
+
5
+ from .v1 import urls as v1_urls
6
+
7
+ urlpatterns = [
8
+ path("v1/", include((v1_urls, "learning_credentials_api_v1"), namespace="learning_credentials_api_v1")),
9
+ ]
@@ -0,0 +1 @@
1
+ """Learning Credentials API v1 package."""
@@ -0,0 +1,81 @@
1
+ """Django REST framework permissions."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from django.db.models import Q
6
+ from learning_paths.models import LearningPath
7
+ from opaque_keys import InvalidKeyError
8
+ from opaque_keys.edx.keys import LearningContextKey
9
+ from rest_framework.exceptions import NotFound, ParseError
10
+ from rest_framework.permissions import BasePermission
11
+
12
+ from learning_credentials.compat import get_course_enrollments
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
18
+ from rest_framework.request import Request
19
+ from rest_framework.views import APIView
20
+
21
+
22
+ class CanAccessLearningContext(BasePermission):
23
+ """Permission to allow access to learning context if the user is enrolled."""
24
+
25
+ def has_permission(self, request: "Request", view: "APIView") -> bool:
26
+ """Check if the user can access the learning context."""
27
+ try:
28
+ key = view.kwargs.get("learning_context_key") or request.query_params.get("learning_context_key")
29
+ learning_context_key = LearningContextKey.from_string(key)
30
+ except InvalidKeyError as e:
31
+ msg = "Invalid learning context key."
32
+ raise ParseError(msg) from e
33
+
34
+ if request.user.is_staff:
35
+ return True
36
+
37
+ if learning_context_key.is_course:
38
+ if self._can_access_course(learning_context_key, request.user):
39
+ return True
40
+
41
+ msg = "Course not found or user does not have access."
42
+ raise NotFound(msg)
43
+
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):
46
+ return True
47
+
48
+ msg = "Learning path not found or user does not have access."
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()
@@ -0,0 +1,13 @@
1
+ """API v1 URLs."""
2
+
3
+ from django.urls import path
4
+
5
+ from .views import CredentialConfigurationCheckView
6
+
7
+ urlpatterns = [
8
+ path(
9
+ 'configured/<str:learning_context_key>/',
10
+ CredentialConfigurationCheckView.as_view(),
11
+ name='credential_configuration_check',
12
+ ),
13
+ ]
@@ -0,0 +1,83 @@
1
+ """API views for Learning Credentials."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import edx_api_doc_tools as apidocs
6
+ from edx_api_doc_tools import ParameterLocation
7
+ from rest_framework import status
8
+ from rest_framework.permissions import IsAuthenticated
9
+ from rest_framework.response import Response
10
+ from rest_framework.views import APIView
11
+
12
+ from learning_credentials.models import CredentialConfiguration
13
+
14
+ from .permissions import CanAccessLearningContext
15
+
16
+ if TYPE_CHECKING:
17
+ from rest_framework.request import Request
18
+
19
+
20
+ class CredentialConfigurationCheckView(APIView):
21
+ """API view to check if any credentials are configured for a specific learning context."""
22
+
23
+ permission_classes = (IsAuthenticated, CanAccessLearningContext)
24
+
25
+ @apidocs.schema(
26
+ parameters=[
27
+ apidocs.string_parameter(
28
+ "learning_context_key",
29
+ ParameterLocation.PATH,
30
+ description=(
31
+ "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) "
32
+ "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)"
33
+ ),
34
+ ),
35
+ ],
36
+ responses={
37
+ 200: "Boolean indicating if credentials are configured.",
38
+ 400: "Invalid context key format.",
39
+ 403: "User is not authenticated or does not have permission to access the learning context.",
40
+ 404: "Learning context not found or user does not have access.",
41
+ },
42
+ )
43
+ def get(self, _request: "Request", learning_context_key: str) -> Response:
44
+ """
45
+ Check if any credentials are configured for the given learning context.
46
+
47
+ **Example Request**
48
+
49
+ ``GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/``
50
+
51
+ **Response Values**
52
+
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.
57
+
58
+ **Example Response**
59
+
60
+ .. code-block:: json
61
+
62
+ {
63
+ "has_credentials": true,
64
+ "credential_count": 2
65
+ }
66
+
67
+ **Response Fields**
68
+
69
+ - ``has_credentials``: Boolean indicating if any credentials are configured
70
+ - ``credential_count``: Number of credential configurations available
71
+
72
+ **Note**
73
+
74
+ This endpoint does not perform learning context existence validation, so it will not return 404 for staff users.
75
+ """
76
+ credential_count = CredentialConfiguration.objects.filter(learning_context_key=learning_context_key).count()
77
+
78
+ response_data = {
79
+ 'has_credentials': credential_count > 0,
80
+ 'credential_count': credential_count,
81
+ }
82
+
83
+ return Response(response_data, status=status.HTTP_200_OK)
@@ -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,10 +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
- 'settings_config': {
19
+ PluginURLs.CONFIG: {
19
20
  'lms.djangoapp': {
20
- 'common': {'relative_path': 'settings.common'},
21
- 'production': {'relative_path': 'settings.production'},
21
+ PluginURLs.NAMESPACE: name,
22
+ PluginURLs.APP_NAME: name,
23
+ }
24
+ },
25
+ PluginSettings.CONFIG: {
26
+ 'lms.djangoapp': {
27
+ 'common': {PluginSettings.RELATIVE_PATH: 'settings.common'},
28
+ 'production': {PluginSettings.RELATIVE_PATH: 'settings.production'},
22
29
  },
23
30
  },
24
31
  }
@@ -73,12 +73,15 @@ def get_learning_context_name(learning_context_key: LearningContextKey) -> str:
73
73
  return _get_learning_path_name(learning_context_key)
74
74
 
75
75
 
76
- def get_course_enrollments(course_id: CourseKey) -> list[User]:
76
+ def get_course_enrollments(course_id: CourseKey, user_id: int | None = None) -> list[User]:
77
77
  """Get the course enrollments from Open edX."""
78
78
  # noinspection PyUnresolvedReferences,PyPackageRequirements
79
79
  from common.djangoapps.student.models import CourseEnrollment
80
80
 
81
81
  enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user')
82
+ if user_id:
83
+ enrollments = enrollments.filter(user__id=user_id)
84
+
82
85
  return [enrollment.user for enrollment in enrollments]
83
86
 
84
87
 
@@ -112,7 +112,7 @@ class CredentialConfiguration(TimeStampedModel):
112
112
  task_path = f"{task.__wrapped__.__module__}.{task.__wrapped__.__name__}"
113
113
 
114
114
  if self._state.adding:
115
- schedule, created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
115
+ schedule, _created = IntervalSchedule.objects.get_or_create(every=10, period=IntervalSchedule.DAYS)
116
116
  self.periodic_task = PeriodicTask.objects.create(
117
117
  enabled=False,
118
118
  interval=schedule,
@@ -1,9 +1,12 @@
1
1
  """App-specific settings for all environments."""
2
2
 
3
- from django.conf import Settings
3
+ from typing import TYPE_CHECKING
4
4
 
5
+ if TYPE_CHECKING:
6
+ from django.conf import Settings
5
7
 
6
- def plugin_settings(settings: Settings):
8
+
9
+ def plugin_settings(settings: 'Settings'):
7
10
  """Add `django_celery_beat` to `INSTALLED_APPS`."""
8
11
  if 'django_celery_beat' not in settings.INSTALLED_APPS:
9
12
  settings.INSTALLED_APPS += ('django_celery_beat',)
@@ -1,9 +1,12 @@
1
1
  """App-specific settings for production environments."""
2
2
 
3
- from django.conf import Settings
3
+ from typing import TYPE_CHECKING
4
4
 
5
+ if TYPE_CHECKING:
6
+ from django.conf import Settings
5
7
 
6
- def plugin_settings(settings: Settings):
8
+
9
+ def plugin_settings(settings: 'Settings'):
7
10
  """
8
11
  Use the database scheduler for Celery Beat.
9
12
 
@@ -0,0 +1,9 @@
1
+ """URLs for learning_credentials."""
2
+
3
+ from django.urls import include, path
4
+
5
+ from .api import urls as api_urls
6
+
7
+ urlpatterns = [
8
+ path('api/learning_credentials/', include(api_urls)),
9
+ ]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: learning-credentials
3
- Version: 0.2.4
3
+ Version: 0.3.0
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
@@ -21,6 +21,8 @@ Description-Content-Type: text/x-rst
21
21
  License-File: LICENSE.txt
22
22
  Requires-Dist: django
23
23
  Requires-Dist: django-model-utils
24
+ Requires-Dist: edx-api-doc-tools
25
+ Requires-Dist: edx-django-utils
24
26
  Requires-Dist: edx-opaque-keys
25
27
  Requires-Dist: celery
26
28
  Requires-Dist: django-celery-beat
@@ -122,7 +124,7 @@ file in this repo.
122
124
  Reporting Security Issues
123
125
  *************************
124
126
 
125
- Please do not report security issues in public. Please email security@openedx.org.
127
+ Please do not report security issues in public. Please email help@opencraft.com.
126
128
 
127
129
  .. |pypi-badge| image:: https://img.shields.io/pypi/v/learning-credentials.svg
128
130
  :target: https://pypi.python.org/pypi/learning-credentials/
@@ -174,6 +176,14 @@ Unreleased
174
176
 
175
177
  *
176
178
 
179
+ 0.3.0 - 2025-09-17
180
+ ******************
181
+
182
+ Added
183
+ =====
184
+
185
+ * REST API endpoint to check if credentials are configured for a learning context.
186
+
177
187
  0.2.4 - 2025-09-07
178
188
 
179
189
  Added
@@ -13,13 +13,18 @@ learning_credentials/models.py
13
13
  learning_credentials/processors.py
14
14
  learning_credentials/tasks.py
15
15
  learning_credentials/urls.py
16
- learning_credentials/views.py
17
16
  learning_credentials.egg-info/PKG-INFO
18
17
  learning_credentials.egg-info/SOURCES.txt
19
18
  learning_credentials.egg-info/dependency_links.txt
20
19
  learning_credentials.egg-info/entry_points.txt
21
20
  learning_credentials.egg-info/requires.txt
22
21
  learning_credentials.egg-info/top_level.txt
22
+ learning_credentials/api/__init__.py
23
+ learning_credentials/api/urls.py
24
+ learning_credentials/api/v1/__init__.py
25
+ learning_credentials/api/v1/permissions.py
26
+ learning_credentials/api/v1/urls.py
27
+ learning_credentials/api/v1/views.py
23
28
  learning_credentials/conf/locale/config.yaml
24
29
  learning_credentials/migrations/0001_initial.py
25
30
  learning_credentials/migrations/0002_migrate_to_learning_credentials.py
@@ -40,4 +45,5 @@ learning_credentials/templates/learning_credentials/edx_ace/certificate_generate
40
45
  tests/test_generators.py
41
46
  tests/test_models.py
42
47
  tests/test_processors.py
43
- tests/test_tasks.py
48
+ tests/test_tasks.py
49
+ tests/test_views.py
@@ -1,5 +1,7 @@
1
1
  django
2
2
  django-model-utils
3
+ edx-api-doc-tools
4
+ edx-django-utils
3
5
  edx-opaque-keys
4
6
  celery
5
7
  django-celery-beat
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "learning-credentials"
3
- version = "0.2.4"
3
+ version = "0.3.0"
4
4
  description = "A pluggable service for preparing Open edX credentials."
5
5
  dynamic = ["readme"]
6
6
  requires-python = ">=3.11"
@@ -21,6 +21,8 @@ classifiers = [
21
21
  dependencies = [
22
22
  "django", # Web application framework
23
23
  "django-model-utils", # Provides TimeStampedModel abstract base class
24
+ "edx-api-doc-tools", # A toolkit for documenting REST APIs that are created with DRF
25
+ "edx-django-utils", # Open edX Django utilities (e.g., plugin infrastructure)
24
26
  "edx-opaque-keys", # Create and introspect Course and XBlock identities
25
27
  "celery", # Distributed task queue
26
28
  "django-celery-beat", # Periodic task scheduler
@@ -63,7 +65,7 @@ test = [
63
65
  "factory-boy",
64
66
  ]
65
67
  django42 = ["django>=4.2,<5.0"]
66
- django52 = ["django>=5.2,<5.3"]
68
+ django52 = ["django>=5.2,<6.0"]
67
69
  ci = ["tox", "tox-uv"]
68
70
  quality = ["ruff", "yamllint"]
69
71
  doc = ["Sphinx", "doc8", "sphinx-book-theme", "twine"]
@@ -74,15 +76,16 @@ dev = [
74
76
  { include-group = "doc" },
75
77
  "diff-cover",
76
78
  "edx-i18n-tools",
79
+ "ty", # Type checker.
80
+ "django-types", # Type stubs for Django.
77
81
  # External dev constraints (DO NOT REMOVE THIS LINE)
78
- "Django<5.0",
82
+ "Django<6.0",
79
83
  ]
80
84
 
81
85
  [tool.uv]
82
86
  constraint-dependencies = [
83
87
  # External constraints (DO NOT REMOVE THIS LINE)
84
88
  "elasticsearch<7.14.0",
85
- "pip<24.3",
86
89
  ]
87
90
  conflicts = [
88
91
  [
@@ -178,10 +181,12 @@ ignore = [
178
181
 
179
182
  [tool.ruff.lint.per-file-ignores]
180
183
  'tests/*' = [
181
- 'S101', # assert
182
- 'INP001', # implicit-namespace-package
183
- 'SLF001', # private-member-access
184
- 'RUF018', # assignment-in-assert
184
+ 'S101', # assert
185
+ 'INP001', # implicit-namespace-package
186
+ 'SLF001', # private-member-access
187
+ 'RUF018', # assignment-in-assert
188
+ 'ARG002', # unused-method-argument
189
+ 'PLR0913', # too-many-arguments
185
190
  ]
186
191
 
187
192
  [tool.ruff.lint.flake8-annotations]
@@ -193,6 +198,11 @@ convention = "google"
193
198
  [tool.ruff.lint.pylint]
194
199
  allow-magic-value-types = ['int', 'str']
195
200
 
201
+ [tool.ruff.lint.flake8-type-checking]
202
+ # Add quotes around type annotations, if doing so would allow
203
+ # an import to be moved into a type-checking block.
204
+ quote-annotations = true
205
+
196
206
  [tool.ruff.format]
197
207
  quote-style = "preserve"
198
208
 
@@ -207,3 +217,25 @@ filterwarnings = [
207
217
  DJANGO_SETTINGS_MODULE = "test_settings"
208
218
  addopts = "--cov learning_credentials --cov tests --cov-report term-missing --cov-report xml"
209
219
  norecursedirs = ".* docs requirements site-packages"
220
+
221
+ [tool.ty.rules]
222
+ unresolved-attribute = "warn"
223
+
224
+ [[tool.ty.overrides]]
225
+ include = ["learning_credentials/settings/*.py"]
226
+
227
+ [tool.ty.overrides.rules]
228
+ unresolved-attribute = "ignore"
229
+
230
+ [[tool.ty.overrides]]
231
+ include = ["learning_credentials/compat.py"]
232
+
233
+ [tool.ty.overrides.rules]
234
+ unresolved-import = "ignore"
235
+
236
+ [[tool.ty.overrides]]
237
+ include = ["tests/**", "test_utils/**"]
238
+
239
+ [tool.ty.overrides.rules]
240
+ possibly-unbound-attribute = "ignore"
241
+ unresolved-attribute = "ignore"
@@ -259,7 +259,7 @@ def test_save_credential(mock_contentfile: Mock, mock_token_hex: Mock, storage:
259
259
  return_value=Mock(getpdfdata=Mock(return_value=b'pdf_data')),
260
260
  )
261
261
  @patch('learning_credentials.generators._save_credential', return_value='credential_url')
262
- def test_generate_pdf_credential( # noqa: PLR0913
262
+ def test_generate_pdf_credential(
263
263
  mock_save_credential: Mock,
264
264
  mock_write_text_on_template: Mock,
265
265
  mock_pdf_writer: Mock,
@@ -23,6 +23,8 @@ from learning_credentials.processors import (
23
23
  from test_utils.factories import UserFactory
24
24
 
25
25
  if TYPE_CHECKING:
26
+ from collections.abc import Callable
27
+
26
28
  from django.contrib.auth.models import User
27
29
 
28
30
 
@@ -361,7 +363,7 @@ def learning_path_with_courses(users: list[User]) -> LearningPath:
361
363
  @pytest.mark.django_db
362
364
  def test_retrieve_data_for_learning_path(
363
365
  patch_target: str,
364
- function_to_test: callable,
366
+ function_to_test: Callable[[str, dict], list[int]],
365
367
  learning_path_with_courses: LearningPath,
366
368
  users: list[User],
367
369
  ):
@@ -0,0 +1,304 @@
1
+ """Tests for the Learning Credentials API views."""
2
+
3
+ from typing import TYPE_CHECKING, Union
4
+ from unittest.mock import Mock, patch
5
+
6
+ import pytest
7
+ from django.urls import reverse
8
+ from learning_paths.keys import LearningPathKey
9
+ from learning_paths.models import LearningPath, LearningPathEnrollment, LearningPathStep
10
+ from opaque_keys.edx.keys import CourseKey
11
+ from rest_framework import status
12
+ from rest_framework.test import APIClient
13
+
14
+ from learning_credentials.models import CredentialConfiguration, CredentialType
15
+ from test_utils.factories import UserFactory
16
+
17
+ if TYPE_CHECKING:
18
+ from django.contrib.auth.models import User
19
+ from opaque_keys.edx.keys import LearningContextKey
20
+ from requests import Response
21
+
22
+
23
+ @pytest.fixture
24
+ def user() -> UserFactory:
25
+ """Return a test user."""
26
+ return UserFactory()
27
+
28
+
29
+ @pytest.fixture
30
+ def staff_user() -> UserFactory:
31
+ """Return a staff user."""
32
+ return UserFactory(is_staff=True)
33
+
34
+
35
+ @pytest.fixture
36
+ def course_key() -> CourseKey:
37
+ """Return a course key."""
38
+ return CourseKey.from_string("course-v1:OpenedX+DemoX+DemoCourse")
39
+
40
+
41
+ @pytest.fixture
42
+ def learning_path_key() -> LearningPathKey:
43
+ """Return a learning path key."""
44
+ return LearningPathKey.from_string("path-v1:OpenedX+DemoX+DemoPath+Demo")
45
+
46
+
47
+ @pytest.fixture
48
+ def learning_path(learning_path_key: LearningPathKey) -> LearningPath:
49
+ """Create an invite-only learning path."""
50
+ return LearningPath.objects.create(key=learning_path_key)
51
+
52
+
53
+ @pytest.fixture
54
+ def public_learning_path(learning_path_key: LearningPathKey) -> LearningPath:
55
+ """Create a public (non-invite-only) learning path."""
56
+ return LearningPath.objects.create(key=learning_path_key, invite_only=False)
57
+
58
+
59
+ @pytest.fixture
60
+ def learning_path_enrollment(user: "User", learning_path: LearningPath) -> LearningPathEnrollment:
61
+ """Enroll user in the learning path."""
62
+ return LearningPathEnrollment.objects.create(learning_path=learning_path, user=user)
63
+
64
+
65
+ @pytest.fixture
66
+ def grade_credential_type() -> CredentialType:
67
+ """Create a grade-based credential type."""
68
+ return CredentialType.objects.create(
69
+ name="Certificate of Achievement",
70
+ retrieval_func="learning_credentials.processors.retrieve_subsection_grades",
71
+ generation_func="learning_credentials.generators.generate_pdf_credential",
72
+ custom_options={},
73
+ )
74
+
75
+
76
+ @pytest.fixture
77
+ def completion_credential_type() -> CredentialType:
78
+ """Create a completion-based credential type."""
79
+ return CredentialType.objects.create(
80
+ name="Certificate of Completion",
81
+ retrieval_func="learning_credentials.processors.retrieve_completions",
82
+ generation_func="learning_credentials.generators.generate_pdf_credential",
83
+ custom_options={},
84
+ )
85
+
86
+
87
+ @pytest.fixture
88
+ def grade_config(course_key: CourseKey, grade_credential_type: CredentialType) -> CredentialConfiguration:
89
+ """Create grade-based credential configuration."""
90
+ return CredentialConfiguration.objects.create(
91
+ learning_context_key=course_key,
92
+ credential_type=grade_credential_type,
93
+ custom_options={'required_grades': {'Final Exam': 65, 'Overall Grade': 80}},
94
+ )
95
+
96
+
97
+ @pytest.fixture
98
+ def completion_config(course_key: CourseKey, completion_credential_type: CredentialType) -> CredentialConfiguration:
99
+ """Create completion-based credential configuration."""
100
+ return CredentialConfiguration.objects.create(
101
+ learning_context_key=course_key,
102
+ credential_type=completion_credential_type,
103
+ custom_options={'required_completion': 100},
104
+ )
105
+
106
+
107
+ def _get_api_client(user: Union["User", None]) -> APIClient:
108
+ """Return API client for the given user."""
109
+ client = APIClient()
110
+ if user:
111
+ client.force_authenticate(user=user)
112
+ return client
113
+
114
+
115
+ @pytest.mark.django_db
116
+ class TestCredentialConfigurationCheckViewPermissions:
117
+ """Test permission requirements for credential configuration check endpoint."""
118
+
119
+ def _make_request(self, user: Union["User", None], learning_context_key: "LearningContextKey") -> "Response":
120
+ """Helper to make GET request to the endpoint."""
121
+ client = _get_api_client(user)
122
+ url = reverse(
123
+ 'learning_credentials_api_v1:credential_configuration_check',
124
+ kwargs={'learning_context_key': str(learning_context_key)},
125
+ )
126
+ return client.get(url)
127
+
128
+ def test_unauthenticated_user_gets_403(self, course_key: CourseKey):
129
+ """Test that unauthenticated user gets 403."""
130
+ response = self._make_request(None, course_key)
131
+ assert response.status_code == status.HTTP_403_FORBIDDEN
132
+
133
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments')
134
+ def test_enrolled_user_can_access_course_check(
135
+ self, mock_course_enrollments: Mock, user: "User", course_key: CourseKey
136
+ ):
137
+ """Test that enrolled user can access course configuration check."""
138
+ mock_course_enrollments.return_value = [user]
139
+ response = self._make_request(user, course_key)
140
+
141
+ assert response.status_code == status.HTTP_200_OK
142
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
143
+ mock_course_enrollments.assert_called_once_with(course_key, user.id)
144
+
145
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments', return_value=[])
146
+ def test_non_enrolled_user_denied_course_access(
147
+ self, mock_course_enrollments: Mock, user: "User", course_key: CourseKey
148
+ ):
149
+ """Test that non-enrolled user is denied course access."""
150
+ response = self._make_request(user, course_key)
151
+
152
+ assert response.status_code == status.HTTP_404_NOT_FOUND
153
+ assert 'Course not found or user does not have access' in str(response.data)
154
+
155
+ def test_enrolled_user_can_access_learning_path_check(
156
+ self, user: "User", learning_path_enrollment: LearningPathEnrollment
157
+ ):
158
+ """Test that enrolled user can access learning path configuration check."""
159
+ response = self._make_request(user, learning_path_enrollment.learning_path.key)
160
+
161
+ assert response.status_code == status.HTTP_200_OK
162
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
163
+
164
+ def test_non_enrolled_user_denied_learning_path_access(self, user: "User", learning_path: LearningPath):
165
+ """Test that non-enrolled user is denied learning path access."""
166
+ response = self._make_request(user, learning_path.key)
167
+
168
+ assert response.status_code == status.HTTP_404_NOT_FOUND
169
+ assert 'Learning path not found or user does not have access' in str(response.data)
170
+
171
+ def test_invalid_learning_context_key_returns_400(self, user: "User"):
172
+ """Test that invalid learning context key returns 400."""
173
+ response = self._make_request(user, "invalid-key")
174
+
175
+ assert response.status_code == status.HTTP_400_BAD_REQUEST
176
+ assert 'Invalid learning context key' in str(response.data)
177
+
178
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments')
179
+ def test_staff_can_view_any_context_check(
180
+ self, mock_course_enrollments: Mock, staff_user: "User", course_key: CourseKey
181
+ ):
182
+ """Test that staff can view configuration check for any context without enrollment check."""
183
+ response = self._make_request(staff_user, course_key)
184
+
185
+ assert response.status_code == status.HTTP_200_OK
186
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
187
+ # Staff users bypass enrollment checks.
188
+ mock_course_enrollments.assert_not_called()
189
+
190
+ def test_user_can_access_public_learning_path(self, user: "User", public_learning_path: LearningPath):
191
+ """Test that any user can access a public (non-invite-only) learning path."""
192
+ response = self._make_request(user, public_learning_path.key)
193
+
194
+ assert response.status_code == status.HTTP_200_OK
195
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
196
+
197
+ def test_user_cannot_access_invite_only_learning_path_without_enrollment(
198
+ self, user: "User", learning_path: LearningPath
199
+ ):
200
+ """Test that user cannot access invite-only learning path without enrollment."""
201
+ response = self._make_request(user, learning_path.key)
202
+
203
+ assert response.status_code == status.HTTP_404_NOT_FOUND
204
+ assert 'Learning path not found or user does not have access' in str(response.data)
205
+
206
+ def test_enrolled_user_can_access_invite_only_learning_path(self, learning_path_enrollment: LearningPathEnrollment):
207
+ """Test that enrolled user can access invite-only learning path."""
208
+ response = self._make_request(learning_path_enrollment.user, learning_path_enrollment.learning_path.key)
209
+
210
+ assert response.status_code == status.HTTP_200_OK
211
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
212
+
213
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments', return_value=[])
214
+ def test_user_can_access_course_via_public_learning_path(
215
+ self, mock_course_enrollments: Mock, user: "User", course_key: CourseKey, public_learning_path: LearningPath
216
+ ):
217
+ """Test that user can access course through membership in a public learning path."""
218
+ LearningPathStep.objects.create(course_key=course_key, learning_path=public_learning_path)
219
+ response = self._make_request(user, course_key)
220
+
221
+ assert response.status_code == status.HTTP_200_OK
222
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
223
+
224
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments', return_value=[])
225
+ def test_user_can_access_course_via_enrolled_learning_path(
226
+ self, mock_course_enrollments: Mock, course_key: CourseKey, learning_path_enrollment: LearningPathEnrollment
227
+ ):
228
+ """Test that user can access course through enrollment in learning path containing that course."""
229
+ LearningPathStep.objects.create(course_key=course_key, learning_path=learning_path_enrollment.learning_path)
230
+ response = self._make_request(learning_path_enrollment.user, course_key)
231
+
232
+ assert response.status_code == status.HTTP_200_OK
233
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
234
+
235
+
236
+ @pytest.mark.django_db
237
+ class TestCredentialConfigurationCheckView:
238
+ """Test the CredentialConfigurationCheckView functionality."""
239
+
240
+ def _make_request(self, user: Union["User", None], learning_context_key: "LearningContextKey") -> "Response":
241
+ """Helper to make GET request to the endpoint."""
242
+ client = _get_api_client(user)
243
+ url = reverse(
244
+ 'learning_credentials_api_v1:credential_configuration_check',
245
+ kwargs={'learning_context_key': str(learning_context_key)},
246
+ )
247
+ return client.get(url)
248
+
249
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments')
250
+ def test_no_credentials_configured(self, mock_course_enrollments: Mock, user: "User", course_key: CourseKey):
251
+ """Test response when no credentials are configured for a learning context."""
252
+ mock_course_enrollments.return_value = [user]
253
+ response = self._make_request(user, course_key)
254
+
255
+ assert response.status_code == status.HTTP_200_OK
256
+ assert response.data == {'has_credentials': False, 'credential_count': 0}
257
+
258
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments')
259
+ def test_single_credential_configured(
260
+ self, mock_course_enrollments: Mock, user: "User", course_key: CourseKey, grade_config: CredentialConfiguration
261
+ ):
262
+ """Test response when one credential is configured for a learning context."""
263
+ mock_course_enrollments.return_value = [user]
264
+ response = self._make_request(user, course_key)
265
+
266
+ assert response.status_code == status.HTTP_200_OK
267
+ assert response.data == {'has_credentials': True, 'credential_count': 1}
268
+
269
+ @patch('learning_credentials.api.v1.permissions.get_course_enrollments')
270
+ def test_multiple_credentials_configured(
271
+ self,
272
+ mock_course_enrollments: Mock,
273
+ user: "User",
274
+ course_key: CourseKey,
275
+ grade_config: CredentialConfiguration,
276
+ completion_config: CredentialConfiguration,
277
+ ):
278
+ """Test response when multiple credentials are configured for a learning context."""
279
+ mock_course_enrollments.return_value = [user]
280
+ response = self._make_request(user, course_key)
281
+
282
+ assert response.status_code == status.HTTP_200_OK
283
+ assert response.data == {'has_credentials': True, 'credential_count': 2}
284
+
285
+ def test_learning_path_credentials_configured(
286
+ self, completion_credential_type: CredentialType, learning_path_enrollment: LearningPathEnrollment
287
+ ):
288
+ """Test response for learning path context with configured credentials."""
289
+ CredentialConfiguration.objects.create(
290
+ learning_context_key=learning_path_enrollment.learning_path.key, credential_type=completion_credential_type
291
+ )
292
+ response = self._make_request(learning_path_enrollment.user, learning_path_enrollment.learning_path.key)
293
+
294
+ assert response.status_code == status.HTTP_200_OK
295
+ assert response.data == {'has_credentials': True, 'credential_count': 1}
296
+
297
+ def test_staff_can_check_any_context(
298
+ self, staff_user: "User", course_key: CourseKey, grade_config: CredentialConfiguration
299
+ ):
300
+ """Test that staff can check configuration for any context without enrollment."""
301
+ response = self._make_request(staff_user, course_key)
302
+
303
+ assert response.status_code == status.HTTP_200_OK
304
+ assert response.data == {'has_credentials': True, 'credential_count': 1}
@@ -1,9 +0,0 @@
1
- """URLs for learning_credentials."""
2
-
3
- # from django.urls import re_path # noqa: ERA001, RUF100
4
- # from django.views.generic import TemplateView # noqa: ERA001, RUF100
5
-
6
- urlpatterns = [ # pragma: no cover
7
- # TODO: Fill in URL patterns and views here.
8
- # re_path(r'', TemplateView.as_view(template_name="learning_credentials/base.html")), # noqa: ERA001, RUF100
9
- ]
@@ -1 +0,0 @@
1
- """TODO."""