Skip to content

Commit 9bea20f

Browse files
authored
Merge pull request #109 from open-craft/agrendalath/bb-9902-rest-api
feat: implement REST API for configuration checks
2 parents a0ce2dc + a73335a commit 9bea20f

23 files changed

Lines changed: 2107 additions & 1081 deletions

.coveragerc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ data_file = .coverage
44
source=learning_credentials
55
omit =
66
compat.py
7+
manage.py
78
test_settings.py
89
*/migrations/*
910
*admin.py

CHANGELOG.rst

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ Unreleased
1616

1717
*
1818

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+
1927
0.2.4 - 2025-09-07
2028

2129
Added

MANIFEST.in

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
include CHANGELOG.rst
22
include LICENSE.txt
33
include README.rst
4-
include requirements/base.in
5-
include requirements/constraints.txt
64
recursive-include learning_credentials *.html *.png *.gif *.js *.css *.jpg *.jpeg *.svg *.txt
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Learning Credentials API package."""

learning_credentials/api/urls.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Learning Credentials API v1 package."""
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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()
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
]
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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)

learning_credentials/apps.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from typing import ClassVar
66

77
from django.apps import AppConfig
8+
from edx_django_utils.plugins.constants import PluginSettings, PluginURLs
89

910

1011
class LearningCredentialsConfig(AppConfig):
@@ -15,10 +16,16 @@ class LearningCredentialsConfig(AppConfig):
1516

1617
# https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html
1718
plugin_app: ClassVar[dict[str, dict[str, dict]]] = {
18-
'settings_config': {
19+
PluginURLs.CONFIG: {
1920
'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'},
2229
},
2330
},
2431
}

0 commit comments

Comments
 (0)