diff --git a/.coveragerc b/.coveragerc index c7de2e9..df22f02 100644 --- a/.coveragerc +++ b/.coveragerc @@ -11,3 +11,5 @@ omit = */templates/* */tests/* */settings/* + core_api.py + xblocks.py diff --git a/learning_credentials/api/__init__.py b/learning_credentials/api/__init__.py new file mode 100644 index 0000000..239093d --- /dev/null +++ b/learning_credentials/api/__init__.py @@ -0,0 +1 @@ +"""Learning Credentials API package.""" diff --git a/learning_credentials/api/urls.py b/learning_credentials/api/urls.py new file mode 100644 index 0000000..5375b20 --- /dev/null +++ b/learning_credentials/api/urls.py @@ -0,0 +1,12 @@ +"""API URLs.""" + +from django.urls import include, path + +urlpatterns = [ + path( + "v1/", + include( + ("learning_credentials.api.v1.urls", "learning_credentials_api_v1"), namespace="learning_credentials_api_v1" + ), + ), +] diff --git a/learning_credentials/api/v1/__init__.py b/learning_credentials/api/v1/__init__.py new file mode 100644 index 0000000..708aeec --- /dev/null +++ b/learning_credentials/api/v1/__init__.py @@ -0,0 +1 @@ +"""Learning Credentials API v1 package.""" diff --git a/learning_credentials/api/v1/permissions.py b/learning_credentials/api/v1/permissions.py new file mode 100644 index 0000000..98e3966 --- /dev/null +++ b/learning_credentials/api/v1/permissions.py @@ -0,0 +1,63 @@ +"""Django REST framework permissions.""" + +from typing import TYPE_CHECKING + +from learning_paths.models import LearningPathEnrollment +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import LearningContextKey +from rest_framework.exceptions import NotFound, ParseError +from rest_framework.permissions import BasePermission + +from learning_credentials.compat import get_course_enrollments + +if TYPE_CHECKING: + from rest_framework.request import Request + from rest_framework.views import APIView + + +class IsAdminOrSelf(BasePermission): + """ + Permission to allow only admins or the user themselves to access the API. + + Non-staff users cannot pass "username" that is not their own. + """ + + def has_permission(self, request: "Request", view: "APIView") -> bool: # noqa: ARG002 + """Check if the user is admin or accessing their own data.""" + if request.user.is_staff: + return True + + username = request.query_params.get("username") if request.method == "GET" else request.data.get("username") + + # For learners, the username passed should match the logged in user. + if username: + return request.user.username == username + return True + + +class CanAccessLearningContext(BasePermission): + """Permission to allow access to learning context if the user is enrolled.""" + + def has_permission(self, request: "Request", view: "APIView") -> bool: + """Check if the user is enrolled in the learning context.""" + try: + key = view.kwargs.get("learning_context_key") or request.query_params.get("learning_context_key") + learning_context_key = LearningContextKey.from_string(key) + except InvalidKeyError as e: + msg = "Invalid learning context key." + raise ParseError(msg) from e + + if request.user.is_staff: + return True + + if learning_context_key.is_course: + if bool(get_course_enrollments(learning_context_key, request.user.id)): + return True + msg = "Course not found or user does not have access." + raise NotFound(msg) + + if LearningPathEnrollment.objects.filter(learning_path__key=learning_context_key, user=request.user).exists(): + return True + + msg = "Learning path not found or user does not have access." + raise NotFound(msg) diff --git a/learning_credentials/api/v1/serializers.py b/learning_credentials/api/v1/serializers.py new file mode 100644 index 0000000..40e9542 --- /dev/null +++ b/learning_credentials/api/v1/serializers.py @@ -0,0 +1,74 @@ +"""Serializers for the Learning Credentials API.""" + +from typing import Any, ClassVar + +from rest_framework import serializers + +from learning_credentials.models import Credential + + +class CredentialModelSerializer(serializers.ModelSerializer): + """Model serializer for Credential instances.""" + + credential_id = serializers.UUIDField(source='uuid', read_only=True) + credential_type = serializers.CharField(read_only=True) + context_key = serializers.CharField(source='learning_context_key', read_only=True) + created_date = serializers.DateTimeField(source='created', read_only=True) + download_url = serializers.URLField(read_only=True) + + class Meta: + """Meta configuration for CredentialModelSerializer.""" + + model = Credential + fields: ClassVar[list[str]] = [ + 'credential_id', + 'credential_type', + 'context_key', + 'status', + 'created_date', + 'download_url', + ] + read_only_fields: ClassVar[list[str]] = [ + 'credential_id', + 'credential_type', + 'context_key', + 'status', + 'created_date', + 'download_url', + ] + + +class CredentialEligibilitySerializer(serializers.Serializer): + """Serializer for credential eligibility information with dynamic fields.""" + + credential_type_id = serializers.IntegerField() + name = serializers.CharField() + is_eligible = serializers.BooleanField() + existing_credential = serializers.UUIDField(required=False, allow_null=True) + existing_credential_url = serializers.URLField(required=False, allow_blank=True, allow_null=True) + + current_grades = serializers.DictField(required=False) + required_grades = serializers.DictField(required=False) + + current_completion = serializers.FloatField(required=False, allow_null=True) + required_completion = serializers.FloatField(required=False, allow_null=True) + + steps = serializers.DictField(required=False) + + def to_representation(self, instance: dict) -> dict[str, Any]: + """Remove null/empty fields from representation.""" + data = super().to_representation(instance) + return {key: value for key, value in data.items() if value is not None and value not in ({}, [])} + + +class CredentialEligibilityResponseSerializer(serializers.Serializer): + """Serializer for the complete credential eligibility response.""" + + context_key = serializers.CharField() + credentials = CredentialEligibilitySerializer(many=True) + + +class CredentialListResponseSerializer(serializers.Serializer): + """Serializer for credential list response.""" + + credentials = CredentialModelSerializer(many=True) diff --git a/learning_credentials/api/v1/urls.py b/learning_credentials/api/v1/urls.py new file mode 100644 index 0000000..c86b8c6 --- /dev/null +++ b/learning_credentials/api/v1/urls.py @@ -0,0 +1,23 @@ +"""API v1 URLs.""" + +from django.urls import path + +from .views import CredentialConfigurationCheckView, CredentialEligibilityView, CredentialListView + +urlpatterns = [ + # Credential configuration check endpoint + path( + 'configured//', + CredentialConfigurationCheckView.as_view(), + name='credential-configuration-check', + ), + # Credential eligibility endpoints + path('eligibility//', CredentialEligibilityView.as_view(), name='credential-eligibility'), + path( + 'eligibility///', + CredentialEligibilityView.as_view(), + name='credential-generation', + ), + # Credential listing endpoints + path('credentials/', CredentialListView.as_view(), name='credential-list'), +] diff --git a/learning_credentials/api/v1/views.py b/learning_credentials/api/v1/views.py new file mode 100644 index 0000000..7aca22d --- /dev/null +++ b/learning_credentials/api/v1/views.py @@ -0,0 +1,431 @@ +"""API views for Learning Credentials.""" + +import logging +from typing import TYPE_CHECKING + +import edx_api_doc_tools as apidocs +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 +from edx_api_doc_tools import ParameterLocation +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from learning_credentials.models import Credential, CredentialConfiguration +from learning_credentials.tasks import generate_credential_for_user_task + +from .permissions import CanAccessLearningContext, IsAdminOrSelf +from .serializers import ( + CredentialEligibilityResponseSerializer, + CredentialListResponseSerializer, + CredentialModelSerializer, +) + +if TYPE_CHECKING: + from rest_framework.request import Request + +logger = logging.getLogger(__name__) + + +class CredentialConfigurationCheckView(APIView): + """API view to check if any credentials are configured for a specific learning context.""" + + permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "learning_context_key", + ParameterLocation.PATH, + description=( + "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) " + "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)" + ), + ), + ], + responses={ + 200: "Boolean indicating if credentials are configured.", + 400: "Invalid context key format.", + 404: "Learning context not found or user does not have access.", + }, + ) + def get(self, _request: "Request", learning_context_key: str) -> Response: + """ + Check if any credentials are configured for the given learning context. + + **Example Request** + + GET /api/learning_credentials/v1/configured/course-v1:OpenedX+DemoX+DemoCourse/ + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + **Example Response** + + ```json + { + "has_credentials": true, + "credential_count": 2 + } + ``` + + **Response Fields** + - `has_credentials`: Boolean indicating if any credentials are configured + - `credential_count`: Number of credential configurations available + + **Note** + This endpoint does not perform learning context existence validation, so it will not return 404 for staff users. + """ + credential_count = CredentialConfiguration.objects.filter(learning_context_key=learning_context_key).count() + + response_data = { + 'has_credentials': credential_count > 0, + 'credential_count': credential_count, + } + + return Response(response_data, status=status.HTTP_200_OK) + + +class CredentialEligibilityView(APIView): + """ + API view for credential eligibility and generation. + + This endpoint manages credential eligibility checking and generation for users in specific learning contexts. + + Supported Learning Contexts: + - Course keys: `course-v1:org+course+run` + - Learning path keys: `path-v1:org+path+run+group` + + **Staff Features**: + - Staff users can view eligibility for any user by providing `username` parameter + - Non-staff users can only view their own eligibility + """ + + permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext) + + def _get_eligibility_data(self, user: User, config: CredentialConfiguration) -> dict: + """Calculate eligibility data for a credential configuration.""" + progress_data = config.get_user_eligibility_details(user_id=user.id) # ty: ignore[unresolved-attribute] + + existing_credential = ( + Credential.objects.filter( + user_id=user.id, # ty: ignore[unresolved-attribute] + learning_context_key=config.learning_context_key, + credential_type=config.credential_type.name, + ) + .exclude(status=Credential.Status.ERROR) + .first() + ) + + return { + 'credential_type_id': config.credential_type.pk, + 'name': config.credential_type.name, + 'is_eligible': progress_data.get('is_eligible', False), + 'existing_credential': existing_credential.uuid if existing_credential else None, + 'existing_credential_url': existing_credential.download_url if existing_credential else None, + **progress_data, + } + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "learning_context_key", + ParameterLocation.PATH, + description=( + "Learning context identifier. Can be a course key (course-v1:OpenedX+DemoX+DemoCourse) " + "or learning path key (path-v1:OpenedX+DemoX+DemoPath+Demo)" + ), + ), + ], + responses={ + 200: CredentialEligibilityResponseSerializer, + 400: "Invalid context key format.", + 403: "User is not authenticated.", + 404: "Learning context not found or user does not have access.", + }, + ) + def get(self, request: "Request", learning_context_key: str) -> Response: + """ + Get credential eligibility for a learning context. + + Retrieve detailed eligibility information for all available credentials in a learning context. + This endpoint returns comprehensive progress data including: + - Current grades and requirements for grade-based credentials + - Completion percentages for completion-based credentials + - Step-by-step progress for learning paths + - Eligibility status for each credential type + + **Query Parameters:** + - `username` (staff only): View eligibility for a specific user + + **Example Request** + + GET /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/ + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + The response structure adapts based on credential types: + - Grade-based credentials include `current_grades` and `required_grades` + - Completion-based credentials include `current_completion` and `required_completion` + - Learning paths include detailed `steps` breakdown + + **Example Response for Grade-based Credential** + + ```json + { + "context_key": "course-v1:OpenedX+DemoX+DemoCourse", + "credentials": [ + { + "credential_type_id": 1, + "name": "Certificate of Achievement", + "description": "", + "is_eligible": true, + "existing_credential": null, + "current_grades": { + "Final Exam": 86, + "Overall Grade": 82 + }, + "required_grades": { + "Final Exam": 65, + "Overall Grade": 80 + } + } + ] + } + ``` + + **Example Response for Completion-based Credential** + + ```json + { + "context_key": "course-v1:OpenedX+DemoX+DemoCourse", + "credentials": [ + { + "credential_type_id": 2, + "name": "Certificate of Completion", + "description": "", + "is_eligible": false, + "existing_credential": null, + "current_completion": 74.0, + "required_completion": 100.0 + } + ] + } + ``` + """ + username = request.query_params.get('username') + user = get_object_or_404(User, username=username) if username else request.user + + configurations = CredentialConfiguration.objects.filter( + learning_context_key=learning_context_key + ).select_related('credential_type') + + eligibility_data = [self._get_eligibility_data(user, config) for config in configurations] + + response_data = { + 'context_key': learning_context_key, + 'credentials': eligibility_data, + } + + serializer = CredentialEligibilityResponseSerializer(data=response_data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "learning_context_key", + ParameterLocation.PATH, + description="Learning context identifier (e.g. course-v1:OpenedX+DemoX+DemoCourse)", + ), + apidocs.parameter( + "credential_type_id", + ParameterLocation.PATH, + int, + description="ID of the credential type to generate", + ), + ], + responses={ + 201: "Credential generation started.", + 400: "User is not eligible for this credential or validation error.", + 403: "User is not authenticated.", + 404: "Learning context or credential type not found, or user does not have access.", + 409: "User already has a valid credential of this type.", + 500: "Internal server error during credential generation.", + }, + ) + def post(self, request: "Request", learning_context_key: str, credential_type_id: int) -> Response: + """ + Trigger credential generation for an eligible user. + + This endpoint initiates the credential generation process for a specific credential type. + The user must be eligible for the credential based on the configured requirements. + + **Prerequisites:** + - User must be authenticated + - User must be enrolled in the course or have access to the learning path + - User must meet the eligibility criteria for the specific credential type + - User must not already have an existing valid credential of this type + + **Process:** + 1. Validates user eligibility using the configured processor function + 2. Checks for existing credentials to prevent duplicates + 3. Initiates asynchronous credential generation + 4. Returns credential status and tracking information + + **Notification:** + Users will receive an email notification when credential generation completes. + + **Query Parameters:** + - `username` (staff only): Trigger credential generation for a specific user + + **Example Request** + + POST /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/1/ + + **Response Values** + + If the request is successful, an HTTP 201 "Created" response is returned. + + **Example Response** + + ```json + { + "status": "generating", + "credential_id": "123e4567-e89b-12d3-a456-426614174000", + "message": "Credential generation started. You will receive an email when ready." + } + ``` + """ + username = request.query_params.get('username') + user = get_object_or_404(User, username=username) if username else request.user + + config = get_object_or_404( + CredentialConfiguration.objects.select_related('credential_type'), + learning_context_key=learning_context_key, + credential_type_id=credential_type_id, + ) + + existing_credential = ( + Credential.objects.filter( + user_id=user.id, + learning_context_key=learning_context_key, + credential_type=config.credential_type.name, + ) + .exclude(status=Credential.Status.ERROR) + .first() + ) + + if existing_credential: + return Response({"detail": "User already has a credential of this type."}, status=status.HTTP_409_CONFLICT) + + if not config.get_eligible_user_ids(user_id=user.id): + return Response({"detail": "User is not eligible for this credential."}, status=status.HTTP_400_BAD_REQUEST) + + generate_credential_for_user_task.delay(config.id, user.id) + return Response({"detail": "Credential generation started."}, status=status.HTTP_201_CREATED) + + +class CredentialListView(APIView): + """ + API view to list user credentials with staff override capability. + + This endpoint provides access to user credential records with optional filtering + by learning context and staff oversight capabilities. + + **Authentication Required**: Yes + + **Staff Features**: + - Staff users can view credentials for any user by providing `username` parameter + - Non-staff users can only view their own credentials + """ + + def get_permissions(self) -> list: + """Instantiate and return the list of permissions required for this view.""" + permission_classes = [IsAuthenticated, IsAdminOrSelf] + + if self.request.query_params.get('learning_context_key'): + permission_classes.append(CanAccessLearningContext) + + return [permission() for permission in permission_classes] + + @apidocs.schema( + parameters=[ + apidocs.string_parameter( + "learning_context_key", + ParameterLocation.QUERY, + description="Optional learning context to filter credentials (e.g. course-v1:OpenedX+DemoX+DemoCourse)", + ), + apidocs.string_parameter( + "username", + ParameterLocation.QUERY, + description="Username to view credentials for (staff only)", + ), + ], + responses={ + 200: CredentialListResponseSerializer, + 403: "User is not authenticated or lacks permission to view specified user's credentials.", + 404: "Specified user not found or learning context not found/accessible.", + }, + ) + def get(self, request: "Request") -> Response: + """ + Retrieve a list of credentials for the authenticated user or a specified user. + + This endpoint returns credential records with filtering options: + - Filter by learning context (course or learning path) + - Staff users can view credentials for any user + - Regular users can only view their own credentials + + **Query Parameters:** + - `username` (staff only): View credentials for a specific user + - `learning_context_key` (optional): Filter credentials by learning context + + **Response includes:** + - Credential ID and type + - Learning context information + - Creation date and status + - Download URL for completed credentials + + **Example Request** + + GET /api/learning_credentials/v1/credentials/ + GET /api/learning_credentials/v1/credentials/course-v1:OpenedX+DemoX+DemoCourse/ + GET /api/learning_credentials/v1/credentials/?username=student123 # staff only + + **Response Values** + + If the request is successful, an HTTP 200 "OK" response is returned. + + **Example Response** + + ```json + { + "credentials": [ + { + "credential_id": "123e4567-e89b-12d3-a456-426614174000", + "credential_type": "Certificate of Achievement", + "context_key": "course-v1:OpenedX+DemoX+DemoCourse", + "status": "available", + "created_date": "2024-08-20T10:30:00Z", + "download_url": "https://example.com/credentials/123e4567.pdf" + } + ] + } + ``` + """ + learning_context_key = request.query_params.get('learning_context_key') + username = request.query_params.get('username') + user = get_object_or_404(User, username=username) if username else request.user + + credentials_queryset = Credential.objects.filter(user_id=user.pk) + + if learning_context_key: + credentials_queryset = credentials_queryset.filter(learning_context_key=learning_context_key) + + credentials_data = CredentialModelSerializer(credentials_queryset, many=True).data + return Response({'credentials': credentials_data}) diff --git a/learning_credentials/apps.py b/learning_credentials/apps.py index 8ff56bd..23ef079 100644 --- a/learning_credentials/apps.py +++ b/learning_credentials/apps.py @@ -15,10 +15,20 @@ class LearningCredentialsConfig(AppConfig): # https://edx.readthedocs.io/projects/edx-django-utils/en/latest/plugins/how_tos/how_to_create_a_plugin_app.html plugin_app: ClassVar[dict[str, dict[str, dict]]] = { + 'url_config': { + 'lms.djangoapp': { + 'namespace': 'learning_credentials', + 'app_name': 'learning_credentials', + } + }, 'settings_config': { 'lms.djangoapp': { 'common': {'relative_path': 'settings.common'}, 'production': {'relative_path': 'settings.production'}, }, + # 'cms.djangoapp': { + # 'common': {'relative_path': 'settings.common'}, + # 'production': {'relative_path': 'settings.production'}, + # }, }, } diff --git a/learning_credentials/compat.py b/learning_credentials/compat.py index 7af0099..b5394ed 100644 --- a/learning_credentials/compat.py +++ b/learning_credentials/compat.py @@ -73,12 +73,21 @@ def get_learning_context_name(learning_context_key: LearningContextKey) -> str: return _get_learning_path_name(learning_context_key) -def get_course_enrollments(course_id: CourseKey) -> list[User]: - """Get the course enrollments from Open edX.""" +def get_course_enrollments(course_id: CourseKey, user_id: int | None = None) -> list[User]: + """ + Get the course enrollments from Open edX. + + :param course_id: The course ID. + :param user_id: Optional. If provided, will filter the enrollments by user. + :return: A list of users enrolled in the course. + """ # noinspection PyUnresolvedReferences,PyPackageRequirements from common.djangoapps.student.models import CourseEnrollment enrollments = CourseEnrollment.objects.filter(course_id=course_id, is_active=True).select_related('user') + if user_id: + enrollments = enrollments.filter(user__id=user_id) + return [enrollment.user for enrollment in enrollments] diff --git a/learning_credentials/core_api.py b/learning_credentials/core_api.py new file mode 100644 index 0000000..cb76814 --- /dev/null +++ b/learning_credentials/core_api.py @@ -0,0 +1,77 @@ +"""API functions for the Learning Credentials app.""" + +import logging +from typing import TYPE_CHECKING + +from .models import Credential, CredentialConfiguration +from .tasks import generate_credential_for_user_task + +if TYPE_CHECKING: + from django.contrib.auth.models import User + from opaque_keys.edx.keys import CourseKey + +logger = logging.getLogger(__name__) + + +def get_eligible_users_by_credential_type( + course_id: 'CourseKey', user_id: int | None = None +) -> dict[str, list['User']]: + """ + Retrieve eligible users for each credential type in the given course. + + :param course_id: The key of the course for which to check eligibility. + :param user_id: Optional. If provided, will check eligibility for the specific user. + :return: A dictionary with credential type as the key and eligible users as the value. + """ + credential_configs = CredentialConfiguration.objects.filter(course_id=course_id) + + if not credential_configs: + return {} + + eligible_users_by_type = {} + for credential_config in credential_configs: + user_ids = credential_config.get_eligible_user_ids(user_id) + filtered_user_ids = credential_config.filter_out_user_ids_with_credentials(user_ids) + + if user_id: + eligible_users_by_type[credential_config.credential_type.name] = list(set(filtered_user_ids) & {user_id}) + else: + eligible_users_by_type[credential_config.credential_type.name] = filtered_user_ids + + return eligible_users_by_type + + +def get_user_credentials_by_type(course_id: 'CourseKey', user_id: int) -> dict[str, dict[str, str]]: + """ + Retrieve the available credentials for a given user in a course. + + :param course_id: The course ID for which to retrieve credentials. + :param user_id: The ID of the user for whom credentials are being retrieved. + :return: A dict where keys are credential types and values are dicts with the download link and status. + """ + credentials = Credential.objects.filter(user_id=user_id, course_id=course_id) + + return {cred.credential_type: {'download_url': cred.download_url, 'status': cred.status} for cred in credentials} + + +def generate_credential_for_user(course_id: 'CourseKey', credential_type: str, user_id: int, force: bool = False): + """ + Generate a credential for a user in a course. + + :param course_id: The course ID for which to generate the credential. + :param credential_type: The type of credential to generate. + :param user_id: The ID of the user for whom the credential is being generated. + :param force: If True, will generate the credential even if the user is not eligible. + """ + credential_config = CredentialConfiguration.objects.get(course_id=course_id, credential_type__name=credential_type) + + if not credential_config: + logger.error('No course configuration found for course %s', course_id) + return + + if not force and not credential_config.get_eligible_user_ids(user_id): + logger.error('User %s is not eligible for the credential in course %s', user_id, course_id) + msg = 'User is not eligible for the credential.' + raise ValueError(msg) + + generate_credential_for_user_task.delay(credential_config.id, user_id) diff --git a/learning_credentials/models.py b/learning_credentials/models.py index 56edc9b..cc72187 100644 --- a/learning_credentials/models.py +++ b/learning_credentials/models.py @@ -7,7 +7,7 @@ import uuid from importlib import import_module from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import jsonfield from django.conf import settings @@ -165,11 +165,12 @@ def filter_out_user_ids_with_credentials(self, user_ids: list[int]) -> list[int] filtered_user_ids_set = set(user_ids) - set(users_ids_with_credentials) return list(filtered_user_ids_set) - def get_eligible_user_ids(self) -> list[int]: + def _call_retrieval_func(self, user_id: int | None = None) -> list[int] | dict[str, Any]: """ - Get the list of eligible learners for the given course. + Call the retrieval function with the given parameters. - :return: A list of user IDs. + :param user_id: Optional. If provided, will check eligibility for the specific user. + :return: Raw result from the retrieval function - list of user IDs or user details dict. """ func_path = self.credential_type.retrieval_func module_path, func_name = func_path.rsplit('.', 1) @@ -177,7 +178,40 @@ def get_eligible_user_ids(self) -> list[int]: func = getattr(module, func_name) custom_options = {**self.credential_type.custom_options, **self.custom_options} - return func(self.learning_context_key, custom_options) + return func(self.learning_context_key, custom_options, user_id=user_id) + + def get_eligible_user_ids(self, user_id: int | None = None) -> list[int]: + """ + Get the list of eligible learners for the given course. + + :param user_id: Optional. If provided, will check eligibility for the specific user. + :return: A list of user IDs. + """ + result = self._call_retrieval_func(user_id) + + if user_id is not None: + # Single user case: return list with user ID if eligible + if isinstance(result, dict) and result.get('is_eligible', False): + return [user_id] + return [] + + # Multiple users case: result should already be a list of user IDs + return result if isinstance(result, list) else [] + + def get_user_eligibility_details(self, user_id: int) -> dict[str, Any]: + """ + Get detailed eligibility information for a specific user. + + :param user_id: The user ID to check eligibility for. + :return: Dictionary containing eligibility details and progress information. + """ + result = self._call_retrieval_func(user_id) + + if isinstance(result, dict): + return result + + # Fallback for processors that don't support detailed results + return {'is_eligible': False} def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0): """ @@ -294,15 +328,15 @@ def send_email(self): learning_context_name = get_learning_context_name(self.learning_context_key) user = get_user_model().objects.get(id=self.user_id) msg = Message( - name="certificate_generated", - app_label="learning_credentials", - recipient=Recipient(lms_user_id=user.id, email_address=user.email), - language='en', + name="certificate_generated", # type: ignore[unknown-argument] + app_label="learning_credentials", # type: ignore[unknown-argument] + recipient=Recipient(lms_user_id=user.id, email_address=user.email), # type: ignore[unknown-argument] + language='en', # type: ignore[unknown-argument] context={ 'certificate_link': self.download_url, 'course_name': learning_context_name, 'platform_name': settings.PLATFORM_NAME, - }, + }, # type: ignore[unknown-argument] ) ace.send(msg) diff --git a/learning_credentials/processors.py b/learning_credentials/processors.py index c7ef216..b74e897 100644 --- a/learning_credentials/processors.py +++ b/learning_credentials/processors.py @@ -38,46 +38,63 @@ def _process_learning_context( learning_context_key: LearningContextKey, - course_processor: Callable[[CourseKey, dict[str, Any]], list[int]], + course_processor: Callable[[CourseKey, dict[str, Any], int | None], dict[int, dict[str, Any]]], options: dict[str, Any], -) -> list[int]: + user_id: int | None = None, +) -> dict[int, dict[str, Any]]: """ Process a learning context (course or learning path) using the given course processor function. For courses, runs the processor directly. For learning paths, runs the processor on each - course in the path with step-specific options (if available), and returns the intersection - of eligible users across all courses. + course in the path with step-specific options (if available), and returns detailed results + with step breakdown. Args: learning_context_key: A course key or learning path key to process - course_processor: A function that processes a single course and returns eligible user IDs + course_processor: A function that processes a single course and returns detailed progress for all users options: Options to pass to the processor. For learning paths, may contain a "steps" key with step-specific options in the format: {"steps": {"": {...}}} + user_id: Optional. If provided, will filter to the specific user. Returns: - A list of eligible user IDs + A dict mapping user_id to detailed progress, with step breakdown for learning paths """ if learning_context_key.is_course: - return course_processor(learning_context_key, options) + return course_processor(learning_context_key, options, user_id) # type: ignore[invalid-argument-type] learning_path = LearningPath.objects.get(key=learning_context_key) - results = None - for course in learning_path.steps.all(): - course_options = options.get("steps", {}).get(str(course.course_key), options) - course_results = set(course_processor(course.course_key, course_options)) + step_results_by_course = {} + all_user_ids = set() - if results is None: - results = course_results - else: - results &= course_results + # TODO: Use a single Completion Aggregator request when retrieving step results for a single user. + for step in learning_path.steps.all(): + course_options = options.get("steps", {}).get(str(step.course_key), options) + step_results = course_processor(step.course_key, course_options, user_id) + + step_results_by_course[str(step.course_key)] = step_results + all_user_ids.update(step_results.keys()) # Filter out users who are not enrolled in the Learning Path. - results &= set( - learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True), + all_user_ids &= set( + learning_path.enrolled_users.filter(learningpathenrollment__is_active=True).values_list('id', flat=True) ) - return list(results) if results else [] + final_results = {} + for uid in all_user_ids: + overall_eligible = True + user_step_results = {} + + for course_key, step_results in step_results_by_course.items(): + if uid in step_results: + user_step_results[course_key] = step_results[uid] + overall_eligible = overall_eligible and step_results[uid].get('is_eligible', False) + else: + overall_eligible = False + + final_results[uid] = {'is_eligible': overall_eligible, 'steps': user_step_results} + + return final_results def _get_category_weights(course_id: CourseKey) -> dict[str, float]: @@ -100,7 +117,7 @@ def _get_category_weights(course_id: CourseKey) -> dict[str, float]: return category_weight_ratios -def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, int]]: +def _get_grades_by_format(course_id: CourseKey, users: list[User]) -> dict[int, dict[str, float]]: """ Get the grades for each user, categorized by assignment types. @@ -162,30 +179,65 @@ def _are_grades_passing_criteria( return total_score >= required_grades.get('total', 0) -def _retrieve_course_subsection_grades(course_id: CourseKey, options: dict[str, Any]) -> list[int]: - """Implementation for retrieving course grades.""" +def _calculate_grades_progress( + user_grades: dict[str, float], + required_grades: dict[str, float], + category_weights: dict[str, float], +) -> dict[str, Any]: + """ + Calculate detailed progress information for grade-based criteria. + + :param user_grades: The grades of the user, divided by category. + :param required_grades: The required grades for each category. + :param category_weights: The weight of each category. + :returns: Dict with is_eligible, current_grades, and required_grades. + """ + # Calculate total score + total_score = 0 + for category, score in user_grades.items(): + if category in category_weights: + total_score += score * category_weights[category] + + # Add total to user grades + user_grades_with_total = {**user_grades, 'total': total_score} + + is_eligible = _are_grades_passing_criteria(user_grades, required_grades, category_weights) + + return { + 'is_eligible': is_eligible, + 'current_grades': user_grades_with_total, + 'required_grades': required_grades, + } + + +def _retrieve_course_subsection_grades( + course_id: CourseKey, options: dict[str, Any], user_id: int | None = None +) -> dict[int, dict[str, Any]]: + """Implementation for retrieving course grades. Always returns detailed progress for all users.""" required_grades: dict[str, int] = options['required_grades'] required_grades = {key.lower(): value * 100 for key, value in required_grades.items()} - users = get_course_enrollments(course_id) + users = get_course_enrollments(course_id, user_id) grades = _get_grades_by_format(course_id, users) log.debug(grades) weights = _get_category_weights(course_id) - eligible_users = [] - for user_id, user_grades in grades.items(): - if _are_grades_passing_criteria(user_grades, required_grades, weights): - eligible_users.append(user_id) + results = {} + for uid, user_grades in grades.items(): + results[uid] = _calculate_grades_progress(user_grades, required_grades, weights) - return eligible_users + return results -def retrieve_subsection_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]: +def retrieve_subsection_grades( + learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None +) -> list[int] | dict[str, Any]: """ Retrieve the users that have passing grades in all required categories. :param learning_context_key: The learning context key (course or learning path). :param options: The custom options for the credential. + :param user_id: Optional. If provided, will check eligibility for the specific user. :returns: The IDs of the users that have passing grades in all required categories. Options: @@ -232,7 +284,16 @@ def retrieve_subsection_grades(learning_context_key: LearningContextKey, options } } """ - return _process_learning_context(learning_context_key, _retrieve_course_subsection_grades, options) + detailed_results = _process_learning_context( + learning_context_key, _retrieve_course_subsection_grades, options, user_id + ) + + if user_id is not None: + if user_id in detailed_results: + return detailed_results[user_id] + return {'is_eligible': False, 'current_grades': {}, 'required_grades': {}} + + return [uid for uid, result in detailed_results.items() if result.get('is_eligible', False)] def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params: dict, url: str) -> APIView: @@ -252,7 +313,7 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params drf_request = Request(django_request) # convert django.core.handlers.wsgi.WSGIRequest to DRF request view = CompletionDetailView() - view.request = drf_request + view.request = drf_request # type: ignore[invalid-assignment] # HACK: Bypass the API permissions. staff_user = get_user_model().objects.filter(is_staff=True).first() @@ -262,8 +323,10 @@ def _prepare_request_to_completion_aggregator(course_id: CourseKey, query_params return view -def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) -> list[int]: - """Implementation for retrieving course completions.""" +def _retrieve_course_completions( + course_id: CourseKey, options: dict[str, Any], user_id: int | None = None +) -> dict[int, dict[str, Any]]: + """Implementation for retrieving course completions. Always returns detailed progress for all users.""" # If it turns out to be too slow, we can: # 1. Modify the Completion Aggregator to emit a signal/event when a user achieves a certain completion threshold. # 2. Get this data from the `Aggregator` model. Filter by `aggregation name == 'course'`, `course_key`, `percent`. @@ -276,29 +339,47 @@ def _retrieve_course_completions(course_id: CourseKey, options: dict[str, Any]) # TODO: Extract the logic of this view into an API. The current approach is very hacky. view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url) - completions = [] + completions = {} while True: # noinspection PyUnresolvedReferences response = view.get(view.request, str(course_id)) log.debug(response.data) - completions.extend( - res['username'] for res in response.data['results'] if res['completion']['percent'] >= required_completion - ) + for res in response.data['results']: + completions[res['username']] = res['completion']['percent'] if not response.data['pagination']['next']: break query_params['page'] += 1 view = _prepare_request_to_completion_aggregator(course_id, query_params.copy(), url) - return list(get_user_model().objects.filter(username__in=completions).values_list('id', flat=True)) + # Get all enrolled users and map usernames to user IDs + users = get_course_enrollments(course_id, user_id) + username_to_id = {user.username: user.id for user in users} # type: ignore[unresolved-attribute] + + # Always return detailed progress for all users as dict + detailed_results = {} + for username, current_completion in completions.items(): + if username in username_to_id: + user_id_for_result = username_to_id[username] + progress = { + 'is_eligible': current_completion >= required_completion, + 'current_completion': current_completion, + 'required_completion': required_completion, + } + detailed_results[user_id_for_result] = progress + return detailed_results -def retrieve_completions(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]: + +def retrieve_completions( + learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None +) -> list[int] | dict[str, Any]: """ Retrieve the course completions for all users through the Completion Aggregator API. :param learning_context_key: The learning context key (course or learning path). :param options: The custom options for the credential. + :param user_id: Optional. If provided, will check eligibility for the specific user. :returns: The IDs of the users that have achieved the required completion percentage. Options: @@ -319,10 +400,19 @@ def retrieve_completions(learning_context_key: LearningContextKey, options: dict } } """ - return _process_learning_context(learning_context_key, _retrieve_course_completions, options) + detailed_results = _process_learning_context(learning_context_key, _retrieve_course_completions, options, user_id) + + if user_id is not None: + if user_id in detailed_results: + return detailed_results[user_id] + return {'is_eligible': False, 'current_completion': 0.0, 'required_completion': 0.9} + return [uid for uid, result in detailed_results.items() if result.get('is_eligible', False)] -def retrieve_completions_and_grades(learning_context_key: LearningContextKey, options: dict[str, Any]) -> list[int]: + +def retrieve_completions_and_grades( + learning_context_key: LearningContextKey, options: dict[str, Any], user_id: int | None = None +) -> list[int] | dict[str, Any]: """ Retrieve the users that meet both completion and grade criteria. @@ -331,6 +421,7 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op :param learning_context_key: The learning context key (course or learning path). :param options: The custom options for the credential. + :param user_id: Optional. If provided, will check eligibility for the specific user. :returns: The IDs of the users that meet both sets of criteria. Options: @@ -372,6 +463,23 @@ def retrieve_completions_and_grades(learning_context_key: LearningContextKey, op } } """ + if user_id is not None: + completion_result = retrieve_completions(learning_context_key, options, user_id) + grades_result = retrieve_subsection_grades(learning_context_key, options, user_id) + + if type(grades_result) is not dict or type(completion_result) is not dict: + msg = 'Both results must be dictionaries when user_id is provided.' + raise ValueError(msg) + + completion_eligible = completion_result.get('is_eligible', False) + grades_eligible = grades_result.get('is_eligible', False) + + return { + **completion_result, + **grades_result, + 'is_eligible': completion_eligible and grades_eligible, + } + completion_eligible_users = set(retrieve_completions(learning_context_key, options)) grades_eligible_users = set(retrieve_subsection_grades(learning_context_key, options)) diff --git a/learning_credentials/public/css/credentials_xblock.css b/learning_credentials/public/css/credentials_xblock.css new file mode 100644 index 0000000..1716822 --- /dev/null +++ b/learning_credentials/public/css/credentials_xblock.css @@ -0,0 +1,7 @@ +.credentials-block .credentials-list .credential { + padding-bottom: 20px; + + .credential-status { + margin-bottom: 10px; + } +} diff --git a/learning_credentials/public/html/credentials_xblock.html b/learning_credentials/public/html/credentials_xblock.html new file mode 100644 index 0000000..944f418 --- /dev/null +++ b/learning_credentials/public/html/credentials_xblock.html @@ -0,0 +1,48 @@ +
+ {% if is_author_mode %} +

The Studio view of this XBlock is not supported yet. Please preview the XBlock in the LMS.

+ {% else %} +

Check Your Certificate Eligibility Status

+
    + {% if credentials %} + {% for credential_type, credential in credentials.items %} +
  • + Type: {{ credential_type }} + {% if credential.download_url %} +

    Congratulations on finishing strong!

    + Download Link: Download Certificate + {% elif credential.status == credential.Status.ERROR %} +

    Something went wrong. Please contact us via the Help page for assistance.

    + {% endif %} + +
    +
  • + {% endfor %} + {% endif %} + + {% if eligible_types %} + {% for credential_type, is_eligible in eligible_types.items %} + {% if not credentials or credential_type not in credentials %} +
  • + Type: {{ credential_type }} + {% if is_eligible %} +

    Congratulations! You have earned this certificate. Please claim it below.

    + + {% else %} +

    You are not yet eligible for this certificate.

    + + {% endif %} +
    +
  • + {% endif %} + {% endfor %} + {% endif %} +
+ {% endif %} +
diff --git a/learning_credentials/public/js/credentials_xblock.js b/learning_credentials/public/js/credentials_xblock.js new file mode 100644 index 0000000..dfb9ebc --- /dev/null +++ b/learning_credentials/public/js/credentials_xblock.js @@ -0,0 +1,23 @@ +function CredentialsXBlock(runtime, element) { + function generateCredential(event) { + const button = event.target; + const credentialType = $(button).data('credential-type'); + const handlerUrl = runtime.handlerUrl(element, 'generate_credential'); + + $.post(handlerUrl, JSON.stringify({ credential_type: credentialType })) + .done(function(data) { + const messageArea = $(element).find('#message-area-' + credentialType); + if (data.status === 'success') { + messageArea.html('

Certificate generation initiated successfully.

'); + } else { + messageArea.html('

' + data.message + '

'); + } + }) + .fail(function() { + const messageArea = $(element).find('#message-area-' + credentialType); + messageArea.html('

An error occurred while processing your request.

'); + }); + } + + $(element).find('.generate-credential').on('click', generateCredential); +} diff --git a/learning_credentials/urls.py b/learning_credentials/urls.py index 28671fe..8d963f7 100644 --- a/learning_credentials/urls.py +++ b/learning_credentials/urls.py @@ -1,9 +1,7 @@ """URLs for learning_credentials.""" -# from django.urls import re_path # noqa: ERA001, RUF100 -# from django.views.generic import TemplateView # noqa: ERA001, RUF100 +from django.urls import include, path -urlpatterns = [ # pragma: no cover - # TODO: Fill in URL patterns and views here. - # re_path(r'', TemplateView.as_view(template_name="learning_credentials/base.html")), # noqa: ERA001, RUF100 +urlpatterns = [ + path('api/learning_credentials/', include('learning_credentials.api.urls')), ] diff --git a/learning_credentials/views.py b/learning_credentials/views.py deleted file mode 100644 index 4578657..0000000 --- a/learning_credentials/views.py +++ /dev/null @@ -1 +0,0 @@ -"""TODO.""" diff --git a/learning_credentials/xblocks.py b/learning_credentials/xblocks.py new file mode 100644 index 0000000..ca13703 --- /dev/null +++ b/learning_credentials/xblocks.py @@ -0,0 +1,85 @@ +"""XBlocks for Learning Credentials.""" + +import logging + +from xblock.core import XBlock +from xblock.fields import Scope, String +from xblock.fragment import Fragment +from xblock.utils.resources import ResourceLoader +from xblock.utils.studio_editable import StudioEditableXBlockMixin + +from .core_api import generate_credential_for_user, get_eligible_users_by_credential_type, get_user_credentials_by_type + +loader = ResourceLoader(__name__) +logger = logging.getLogger(__name__) + + +class CredentialsXBlock(StudioEditableXBlockMixin, XBlock): + """XBlock that displays the credential eligibility status and allows eligible users to generate credentials.""" + + display_name = String( + help='The display name for this component.', + scope=Scope.content, + display_name="Display name", + default='Credentials', + ) + + def student_view(self, context) -> Fragment: # noqa: ANN001, ARG002 + """Main view for the student. Displays the credential eligibility or ineligibility status.""" + fragment = Fragment() + eligible_types = False + credentials = [] + + if not (is_author_mode := getattr(self.runtime, 'is_author_mode', False)): + credentials = self.get_credentials() + eligible_types = self.get_eligible_credential_types() + + # Filter out the eligible types that already have a credential generated + for cred_type in credentials: + if cred_type in eligible_types: + del eligible_types[cred_type] + + fragment.add_content( + loader.render_django_template( + 'public/html/credentials_xblock.html', + { + 'credentials': credentials, + 'eligible_types': eligible_types, + 'is_author_mode': is_author_mode, + }, + ) + ) + + fragment.add_css_url(self.runtime.local_resource_url(self, "public/css/credentials_xblock.css")) + fragment.add_javascript_url(self.runtime.local_resource_url(self, "public/js/credentials_xblock.js")) + fragment.initialize_js('CredentialsXBlock') + return fragment + + def get_eligible_credential_types(self) -> dict[str, bool]: + """Retrieve the eligibility status for each credential type.""" + eligible_users = get_eligible_users_by_credential_type(self.runtime.course_id, user_id=self.scope_ids.user_id) + + return {credential_type: bool(users) for credential_type, users in eligible_users.items()} + + def get_credentials(self) -> dict[str, dict[str, str]]: + """Retrieve the credentials for the current user in the current course.""" + return get_user_credentials_by_type(self.runtime.course_id, self.scope_ids.user_id) + + @XBlock.json_handler + def generate_credential(self, data: dict, suffix: str = '') -> dict[str, str]: # noqa: ARG002 + """Handler for generating a credential for a specific type.""" + credential_type = data.get('credential_type') + if not credential_type: + return {'status': 'error', 'message': 'No credential type specified.'} + + course_id = self.runtime.course_id + user_id = self.scope_ids.user_id + logger.info( + 'Generating a credential for user %s in course %s with type %s.', user_id, course_id, credential_type + ) + + try: + generate_credential_for_user(course_id, credential_type, user_id) + except ValueError as e: + return {'status': 'error', 'message': str(e)} + return {'status': 'success'} diff --git a/pyproject.toml b/pyproject.toml index 9417f58..6079298 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "learning-credentials" -version = "0.2.4" +version = "0.3.0-rc4" description = "A pluggable service for preparing Open edX credentials." dynamic = ["readme"] requires-python = ">=3.11" @@ -32,6 +32,7 @@ dependencies = [ "reportlab", # PDF generation library "openedx-completion-aggregator", # Completion aggregation service "edx_ace", # Messaging library + "edx-api-doc-tools", # API documentation tools "learning-paths-plugin>=0.3.4", # Learning Paths support ] @@ -43,6 +44,12 @@ Documentation = "https://learning-credentials.readthedocs.io/" [project.entry-points."lms.djangoapp"] learning_credentials = "learning_credentials.apps:LearningCredentialsConfig" +# [project.entry-points."cms.djangoapp"] +# learning_credentials = "learning_credentials.apps:LearningCredentialsConfig" + +[project.entry-points."xblock.v1"] +certificates = "learning_credentials.xblocks:CredentialsXBlock" + [tool.setuptools.packages.find] include = ["learning_credentials", "learning_credentials.*"] exclude = ["*tests"] @@ -74,6 +81,8 @@ dev = [ { include-group = "doc" }, "diff-cover", "edx-i18n-tools", + "ty", # Type checker. + "django-types", # Type stubs for Django. # External dev constraints (DO NOT REMOVE THIS LINE) "Django<5.0", ] @@ -193,6 +202,11 @@ convention = "google" [tool.ruff.lint.pylint] allow-magic-value-types = ['int', 'str'] +[tool.ruff.lint.flake8-type-checking] +# Add quotes around type annotations, if doing so would allow +# an import to be moved into a type-checking block. +quote-annotations = true + [tool.ruff.format] quote-style = "preserve" @@ -207,3 +221,6 @@ filterwarnings = [ DJANGO_SETTINGS_MODULE = "test_settings" addopts = "--cov learning_credentials --cov tests --cov-report term-missing --cov-report xml" norecursedirs = ".* docs requirements site-packages" + +[tool.ty.rules] +unresolved-attribute = "warn" diff --git a/test_settings.py b/test_settings.py index b9bf32d..c7e76a7 100644 --- a/test_settings.py +++ b/test_settings.py @@ -53,9 +53,9 @@ def root(path: Path) -> Path: SECRET_KEY = 'insecure-secret-key' # noqa: S105 MIDDLEWARE = ( + 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', ) TEMPLATES = [ diff --git a/tests/test_models.py b/tests/test_models.py index d1ff0c4..3afc11b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -25,7 +25,15 @@ from opaque_keys.edx.keys import LearningContextKey -def _mock_retrieval_func(_context_id: LearningContextKey, _options: dict[str, Any]) -> list[int]: +def _mock_retrieval_func( + _context_id: LearningContextKey, _options: dict[str, Any], user_id: int | None = None +) -> list[int] | dict[str, Any]: + if user_id is not None: + if user_id == 1: + return {'is_eligible': True, 'current_grades': {'total': 87.0}, 'required_grades': {'total': 75.0}} + if user_id == 2: + return {'is_eligible': False, 'current_grades': {'total': 67.0}, 'required_grades': {'total': 75.0}} + return {'is_eligible': False} return [1, 2, 3] @@ -160,6 +168,25 @@ def test_get_eligible_user_ids(self): eligible_user_ids = self.config.get_eligible_user_ids() assert eligible_user_ids == [1, 2, 3] + @pytest.mark.parametrize(('user_id', 'expected'), [(1, [1]), (2, [])]) + def test_get_eligible_user_ids_with_for_single_user(self, user_id: int, expected: list[int]) -> None: + """Test the get_eligible_user_ids method with a specific user.""" + eligible_user_ids = self.config.get_eligible_user_ids(user_id=user_id) + assert eligible_user_ids == expected + + @pytest.mark.parametrize( + ("user_id", "expected"), + [ + (1, {"is_eligible": True, "current_grades": {"total": 87.0}, "required_grades": {"total": 75.0}}), + (2, {"is_eligible": False, "current_grades": {"total": 67.0}, "required_grades": {"total": 75.0}}), + (999, {"is_eligible": False}), + ], + ) + def test_get_user_eligibility_details(self, user_id: int, expected: dict[str, Any]) -> None: + """Test the get_user_eligibility_details method.""" + details = self.config.get_user_eligibility_details(user_id=user_id) + assert details == expected + @pytest.mark.django_db def test_filter_out_user_ids_with_credentials(self): """Test the filter_out_user_ids_with_credentials method.""" diff --git a/tests/test_processors.py b/tests/test_processors.py index 1763394..ee05c35 100644 --- a/tests/test_processors.py +++ b/tests/test_processors.py @@ -23,6 +23,8 @@ from test_utils.factories import UserFactory if TYPE_CHECKING: + from collections.abc import Callable + from django.contrib.auth.models import User @@ -186,7 +188,7 @@ def test_retrieve_subsection_grades( result = retrieve_subsection_grades(course_id, options) assert result == [101] - mock_get_course_enrollments.assert_called_once_with(course_id) + mock_get_course_enrollments.assert_called_once_with(course_id, None) mock_get_grades_by_format.assert_called_once_with(course_id, users) mock_get_category_weights.assert_called_once_with(course_id) mock_are_grades_passing_criteria.assert_has_calls( @@ -226,9 +228,12 @@ def test_prepare_request_to_completion_aggregator(): assert view.request.query_params.urlencode() == query_params_qdict.urlencode() +@patch('learning_credentials.processors.get_course_enrollments') @patch('learning_credentials.processors._prepare_request_to_completion_aggregator') -@patch('learning_credentials.processors.get_user_model') -def test_retrieve_course_completions(mock_get_user_model: Mock, mock_prepare_request_to_completion_aggregator: Mock): +def test_retrieve_course_completions( + mock_prepare_request_to_completion_aggregator: Mock, + mock_get_course_enrollments: Mock, +): """Test that we retrieve the course completions for all users and return IDs of users who meet the criteria.""" course_id = Mock(spec=CourseKey) options = {'required_completion': 0.8} @@ -252,27 +257,8 @@ def test_retrieve_course_completions(mock_get_user_model: Mock, mock_prepare_req mock_view_page2.get.return_value.data = completions_page2 mock_prepare_request_to_completion_aggregator.side_effect = [mock_view_page1, mock_view_page2] - def filter_side_effect(*_args, **kwargs) -> list[int]: - """ - A mock side effect function for User.objects.filter(). - - It allows testing this code without a database access. - - :returns: The user IDs corresponding to the provided usernames. - """ - usernames = kwargs['username__in'] - - values_list_mock = Mock() - values_list_mock.return_value = [username_id_map[username] for username in usernames] - queryset_mock = Mock() - queryset_mock.values_list = values_list_mock - - return queryset_mock - - username_id_map = {"user1": 1, "user2": 2, "user3": 3} - mock_user_model = Mock() - mock_user_model.objects.filter.side_effect = filter_side_effect - mock_get_user_model.return_value = mock_user_model + users = [Mock(username="user1", id=1), Mock(username="user2", id=2), Mock(username="user3", id=3)] + mock_get_course_enrollments.return_value = users result = retrieve_completions(course_id, options) @@ -285,7 +271,7 @@ def filter_side_effect(*_args, **kwargs) -> list[int]: ) mock_view_page1.get.assert_called_once_with(mock_view_page1.request, str(course_id)) mock_view_page2.get.assert_called_once_with(mock_view_page2.request, str(course_id)) - mock_user_model.objects.filter.assert_called_once_with(username__in=['user1', 'user3']) + mock_get_course_enrollments.assert_called_once_with(course_id, None) @pytest.mark.parametrize( @@ -361,7 +347,7 @@ def learning_path_with_courses(users: list[User]) -> LearningPath: @pytest.mark.django_db def test_retrieve_data_for_learning_path( patch_target: str, - function_to_test: callable, + function_to_test: Callable[[str, dict], list[int]], learning_path_with_courses: LearningPath, users: list[User], ): @@ -369,9 +355,9 @@ def test_retrieve_data_for_learning_path( with patch(patch_target) as mock_retrieve: options = {} mock_retrieve.side_effect = ( - (users[i].id for i in (0, 1, 2, 4, 5)), # Users passing/completing course0 - (users[i].id for i in (0, 1, 2, 3, 4, 5)), # Users passing/completing course1 - (users[i].id for i in (0, 2, 3, 4, 5)), # Users passing/completing course2 + ({users[i].id: {'is_eligible': True} for i in (0, 1, 2, 4, 5)}), # Users passing/completing course0 + ({users[i].id: {'is_eligible': True} for i in (0, 1, 2, 3, 4, 5)}), # Users passing/completing course1 + ({users[i].id: {'is_eligible': True} for i in (0, 2, 3, 4, 5)}), # Users passing/completing course2 ) result = function_to_test(learning_path_with_courses.key, options) @@ -382,7 +368,7 @@ def test_retrieve_data_for_learning_path( course_keys = [step.course_key for step in learning_path_with_courses.steps.all()] for i, course_key in enumerate(course_keys): call_args = mock_retrieve.call_args_list[i] - assert call_args[0] == (course_key, options) + assert call_args[0] == (course_key, options, None) @patch("learning_credentials.processors._retrieve_course_completions") @@ -406,6 +392,271 @@ def test_retrieve_data_for_learning_path_with_step_options( retrieve_completions(learning_path_with_courses.key, options) assert mock_retrieve.call_count == 3 - assert mock_retrieve.call_args_list[0][0] == (course_keys[0], options["steps"][str(course_keys[0])]) - assert mock_retrieve.call_args_list[1][0] == (course_keys[1], options["steps"][str(course_keys[1])]) - assert mock_retrieve.call_args_list[2][0] == (course_keys[2], options) + assert mock_retrieve.call_args_list[0][0] == (course_keys[0], options["steps"][str(course_keys[0])], None) + assert mock_retrieve.call_args_list[1][0] == (course_keys[1], options["steps"][str(course_keys[1])], None) + assert mock_retrieve.call_args_list[2][0] == (course_keys[2], options, None) + + +@pytest.mark.parametrize( + ('patch_target', 'function_to_test'), + [ + ("learning_credentials.processors._retrieve_course_subsection_grades", retrieve_subsection_grades), + ("learning_credentials.processors._retrieve_course_completions", retrieve_completions), + ], + ids=['subsection_grades', 'completions'], +) +def test_retrieve_data_for_individual_user_course(patch_target: str, function_to_test: Callable): + """Test retrieving progress data for an individual user in a course.""" + course_key = CourseKey.from_string("course-v1:TestX+CS101+2024") + user_id = 123 + options = {} + + # Mock the internal function to return detailed progress for all users + with patch(patch_target) as mock_retrieve: + mock_retrieve.return_value = { + user_id: { + 'is_eligible': True, + 'current_grades' if 'grades' in patch_target else 'current_completion': 85.5 + if 'grades' in patch_target + else 0.95, + 'required_grades' if 'grades' in patch_target else 'required_completion': {'total': 80.0} + if 'grades' in patch_target + else 0.9, + }, + 456: { + 'is_eligible': False, + 'current_grades' if 'grades' in patch_target else 'current_completion': 75.0 + if 'grades' in patch_target + else 0.85, + 'required_grades' if 'grades' in patch_target else 'required_completion': {'total': 80.0} + if 'grades' in patch_target + else 0.9, + }, + } + + result = function_to_test(course_key, options, user_id=user_id) + + # Should return detailed progress for the specific user + assert isinstance(result, dict) + assert result['is_eligible'] is True + if 'grades' in patch_target: + assert result['current_grades'] == 85.5 + assert result['required_grades'] == {'total': 80.0} + else: + assert result['current_completion'] == 0.95 + assert result['required_completion'] == 0.9 + + mock_retrieve.assert_called_once_with(course_key, options, user_id) + + +@pytest.mark.parametrize( + ('patch_target', 'function_to_test'), + [ + ("learning_credentials.processors._retrieve_course_subsection_grades", retrieve_subsection_grades), + ("learning_credentials.processors._retrieve_course_completions", retrieve_completions), + ], + ids=['subsection_grades', 'completions'], +) +def test_retrieve_data_for_individual_user_not_found(patch_target: str, function_to_test: Callable): + """Test retrieving progress data for a user not found in course.""" + course_id = Mock(spec=CourseKey) + user_id = 999 # User not in results + options = {} + + # Mock the internal function to return detailed progress without the requested user + with patch(patch_target) as mock_retrieve: + mock_retrieve.return_value = { + 123: { + 'is_eligible': True, + 'current_grades' if 'grades' in patch_target else 'current_completion': 85.5 + if 'grades' in patch_target + else 0.95, + 'required_grades' if 'grades' in patch_target else 'required_completion': {'total': 80.0} + if 'grades' in patch_target + else 0.9, + } + } + + result = function_to_test(course_id, options, user_id=user_id) + + # Should return not eligible for user not found + assert isinstance(result, dict) + assert result['is_eligible'] is False + if 'grades' in patch_target: + assert result['current_grades'] == {} + assert result['required_grades'] == {} + else: + assert result['current_completion'] == 0.0 + assert result['required_completion'] == 0.9 + + mock_retrieve.assert_called_once_with(course_id, options, user_id) + + +@pytest.mark.parametrize( + ('patch_target', 'function_to_test'), + [ + ("learning_credentials.processors._retrieve_course_subsection_grades", retrieve_subsection_grades), + ("learning_credentials.processors._retrieve_course_completions", retrieve_completions), + ], + ids=['subsection_grades', 'completions'], +) +@pytest.mark.django_db +def test_retrieve_data_for_individual_user_learning_path( + patch_target: str, + function_to_test: Callable, + learning_path_with_courses: LearningPath, + users: list[User], +): + """Test retrieving progress data for an individual user in a learning path with steps breakdown.""" + user_id = users[0].id + options = {} + + # Mock the internal function to return detailed progress for each course step + with patch(patch_target) as mock_retrieve: + mock_retrieve.side_effect = [ + { # Course 1 results + user_id: { + 'is_eligible': True, + 'current_grades' if 'grades' in patch_target else 'current_completion': { + 'homework': 85.0, + 'total': 82.0, + } + if 'grades' in patch_target + else 0.95, + 'required_grades' if 'grades' in patch_target else 'required_completion': { + 'homework': 50.0, + 'total': 80.0, + } + if 'grades' in patch_target + else 0.9, + } + }, + { # Course 2 results + user_id: { + 'is_eligible': True, + 'current_grades' if 'grades' in patch_target else 'current_completion': { + 'exam': 90.0, + 'total': 85.0, + } + if 'grades' in patch_target + else 0.88, + 'required_grades' if 'grades' in patch_target else 'required_completion': { + 'exam': 85.0, + 'total': 80.0, + } + if 'grades' in patch_target + else 0.8, + } + }, + { # Course 3 results + user_id: { + 'is_eligible': True, + 'current_grades' if 'grades' in patch_target else 'current_completion': {'total': 83.0} + if 'grades' in patch_target + else 0.92, + 'required_grades' if 'grades' in patch_target else 'required_completion': {'total': 80.0} + if 'grades' in patch_target + else 0.9, + } + }, + ] + + result = function_to_test(learning_path_with_courses.key, options, user_id=user_id) + + # Should return detailed progress with steps breakdown + assert isinstance(result, dict) + assert result['is_eligible'] is True + assert 'steps' in result + assert len(result['steps']) == 3 + + # Check that each step has the expected structure + for step_result in result['steps'].values(): + assert isinstance(step_result, dict) + assert step_result['is_eligible'] is True + if 'grades' in patch_target: + assert 'current_grades' in step_result + assert 'required_grades' in step_result + else: + assert 'current_completion' in step_result + assert 'required_completion' in step_result + + # Verify internal function was called for each course step + assert mock_retrieve.call_count == 3 + + +@pytest.mark.django_db +def test_retrieve_completions_and_grades_for_individual_user(): + """Test the combined processor for individual user progress.""" + course_id = Mock(spec=CourseKey) + user_id = 123 + options = {} + + # Mock both individual processors + with ( + patch('learning_credentials.processors.retrieve_completions') as mock_completions, + patch('learning_credentials.processors.retrieve_subsection_grades') as mock_grades, + ): + mock_completions.return_value = { + 'is_eligible': True, + 'current_completion': 0.95, + 'required_completion': 0.9, + } + + mock_grades.return_value = { + 'is_eligible': True, + 'current_grades': {'homework': 85.0, 'exam': 90.0, 'total': 87.0}, + 'required_grades': {'homework': 40.0, 'exam': 80.0, 'total': 75.0}, + } + + result = retrieve_completions_and_grades(course_id, options, user_id=user_id) + + # Should return combined progress information + assert isinstance(result, dict) + assert result['is_eligible'] is True + + # Should include both completion and grades information + assert result['current_completion'] == 0.95 + assert result['required_completion'] == 0.9 + assert result['current_grades'] == {'homework': 85.0, 'exam': 90.0, 'total': 87.0} + assert result['required_grades'] == {'homework': 40.0, 'exam': 80.0, 'total': 75.0} + + # Verify both processors were called + mock_completions.assert_called_once_with(course_id, options, user_id) + mock_grades.assert_called_once_with(course_id, options, user_id) + + +@pytest.mark.django_db +def test_retrieve_completions_and_grades_for_individual_user_mixed_eligibility(): + """Test combined processor when user meets one criteria but not the other.""" + course_id = Mock(spec=CourseKey) + user_id = 123 + options = {} + + with ( + patch('learning_credentials.processors.retrieve_completions') as mock_completions, + patch('learning_credentials.processors.retrieve_subsection_grades') as mock_grades, + ): + # User meets completion but not grade requirements + mock_completions.return_value = { + 'is_eligible': True, + 'current_completion': 0.95, + 'required_completion': 0.9, + } + + mock_grades.return_value = { + 'is_eligible': False, + 'current_grades': {'homework': 65.0, 'exam': 70.0, 'total': 67.0}, + 'required_grades': {'homework': 40.0, 'exam': 80.0, 'total': 75.0}, + } + + result = retrieve_completions_and_grades(course_id, options, user_id=user_id) + + # Should be not eligible overall despite meeting completion requirement + assert isinstance(result, dict) + assert result['is_eligible'] is False + + # Should still include all progress information + assert result['current_completion'] == 0.95 + assert result['required_completion'] == 0.9 + assert result['current_grades'] == {'homework': 65.0, 'exam': 70.0, 'total': 67.0} + assert result['required_grades'] == {'homework': 40.0, 'exam': 80.0, 'total': 75.0} diff --git a/tests/test_serializers.py b/tests/test_serializers.py new file mode 100644 index 0000000..651c0d8 --- /dev/null +++ b/tests/test_serializers.py @@ -0,0 +1,205 @@ +"""Tests for the Learning Credentials API serializers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from opaque_keys.edx.keys import CourseKey + +from learning_credentials.api.v1.serializers import ( + CredentialEligibilityResponseSerializer, + CredentialEligibilitySerializer, + CredentialListResponseSerializer, + CredentialModelSerializer, +) +from learning_credentials.models import Credential, CredentialConfiguration, CredentialType +from test_utils.factories import UserFactory + +if TYPE_CHECKING: + from django.contrib.auth.models import User + + +@pytest.fixture +def user() -> User: + """Return a test user.""" + return UserFactory() # ty: ignore[invalid-return-type] + + +@pytest.fixture +def course_key() -> CourseKey: + """Return a course key.""" + return CourseKey.from_string("course-v1:OpenedX+DemoX+DemoCourse") + + +@pytest.fixture +def credential_type() -> CredentialType: + """Create a credential type.""" + return CredentialType.objects.create( + name="Certificate of Achievement", + retrieval_func="learning_credentials.processors.retrieve_subsection_grades", + generation_func="learning_credentials.generators.generate_pdf_credential", + custom_options={}, + ) + + +@pytest.fixture +def credential_config(course_key: CourseKey, credential_type: CredentialType) -> CredentialConfiguration: + """Create credential configuration.""" + return CredentialConfiguration.objects.create( + learning_context_key=course_key, + credential_type=credential_type, + custom_options={'required_grades': {'Final Exam': 65, 'Overall Grade': 80}}, + ) + + +@pytest.fixture +def credential(user: User, course_key: CourseKey) -> Credential: + """Create a credential instance.""" + return Credential.objects.create( + user_id=user.id, + user_full_name=user.get_full_name() or user.username, + learning_context_key=course_key, + credential_type="Certificate of Achievement", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/credential.pdf", + ) + + +@pytest.mark.django_db +class TestCredentialModelSerializer: + """Test the CredentialModelSerializer.""" + + def test_serialization_fields(self, credential: Credential): + """Test that all expected fields are serialized correctly.""" + serializer = CredentialModelSerializer(credential) + data = serializer.data + + assert data['credential_id'] == str(credential.uuid) + assert data['credential_type'] == credential.credential_type + assert data['context_key'] == str(credential.learning_context_key) + assert data['status'] == credential.status + + assert 'created_date' in data + assert isinstance(data['created_date'], str) + assert data['download_url'] == credential.download_url + + def test_serialization_multiple_credentials(self, user: User, course_key: CourseKey): + """Test serialization of multiple credentials.""" + credential1 = Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key, + credential_type="Certificate A", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/cert_a.pdf", + ) + credential2 = Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key, + credential_type="Certificate B", + status=Credential.Status.GENERATING, + download_url="https://example.com/cert_b.pdf", + ) + + serializer = CredentialModelSerializer([credential1, credential2], many=True) + data = serializer.data + + assert len(data) == 2 + assert data[0]['credential_type'] == "Certificate A" + assert data[0]['status'] == Credential.Status.AVAILABLE + assert data[1]['credential_type'] == "Certificate B" + assert data[1]['status'] == Credential.Status.GENERATING + + +class TestCredentialEligibilitySerializer: + """Test the CredentialEligibilitySerializer.""" + + def test_serialization_with_all_fields(self): + """Test serialization with all possible fields.""" + data = { + 'credential_type_id': 1, + 'name': 'Certificate of Achievement', + 'is_eligible': True, + 'existing_credential': '123e4567-e89b-12d3-a456-426614174000', + 'current_grades': {'Final Exam': 86, 'Overall Grade': 82}, + 'required_grades': {'Final Exam': 65, 'Overall Grade': 80}, + 'current_completion': 95.0, + 'required_completion': 100.0, + 'steps': {'step1': {'is_eligible': True}}, + } + + serializer = CredentialEligibilitySerializer(data) + result = serializer.to_representation(data) + + for key, value in data.items(): + assert result[key] == value + + def test_serialization_filters_null_empty_values(self): + """Test that null and empty values are filtered out.""" + data = { + 'credential_type_id': 1, + 'name': 'Certificate', + 'is_eligible': False, + 'existing_credential': None, + 'current_grades': {}, + 'required_grades': None, + 'current_completion': None, + 'steps': {}, + } + + serializer = CredentialEligibilitySerializer() + result = serializer.to_representation(data) + + assert result == { + 'credential_type_id': 1, + 'name': 'Certificate', + 'is_eligible': False, + } + + +class TestCredentialEligibilityResponseSerializer: + """Test the CredentialEligibilityResponseSerializer.""" + + def test_serialization_structure(self): + """Test the overall response structure.""" + credentials_data = [ + { + 'credential_type_id': 1, + 'name': 'Certificate A', + 'is_eligible': True, + }, + { + 'credential_type_id': 2, + 'name': 'Certificate B', + 'is_eligible': False, + }, + ] + data = { + 'context_key': 'course-v1:OpenedX+DemoX+DemoCourse', + 'credentials': credentials_data, + } + + serializer = CredentialEligibilityResponseSerializer(data=data) + assert serializer.is_valid() + + result = serializer.validated_data + assert result['context_key'] == 'course-v1:OpenedX+DemoX+DemoCourse' + assert len(result['credentials']) == 2 + + +class TestCredentialListResponseSerializer: + """Test the CredentialListResponseSerializer.""" + + @pytest.mark.django_db + def test_serialization_with_credential_list(self, credential: Credential): + """Test serialization with list of credentials.""" + credentials_data = CredentialModelSerializer([credential], many=True).data + data = {'credentials': credentials_data} + + serializer = CredentialListResponseSerializer(data=data) + assert serializer.is_valid() + + result = serializer.validated_data + assert len(result['credentials']) == 1 diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 0000000..34488c1 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,934 @@ +"""Tests for the Learning Credentials API views.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import Mock, patch + +import pytest +from django.urls import reverse +from learning_paths.keys import LearningPathKey +from opaque_keys.edx.keys import CourseKey +from rest_framework import status +from rest_framework.test import APIClient + +from learning_credentials.models import Credential, CredentialConfiguration, CredentialType +from test_utils.factories import UserFactory + +if TYPE_CHECKING: + from django.contrib.auth.models import User + + +# Base fixtures +@pytest.fixture +def api_client() -> APIClient: + """Return API client.""" + return APIClient() + + +@pytest.fixture +def user() -> User: + """Return a test user.""" + return UserFactory() # ty: ignore[invalid-return-type] + + +@pytest.fixture +def staff_user() -> User: + """Return a staff user.""" + return UserFactory(is_staff=True) # ty: ignore[invalid-return-type] + + +@pytest.fixture +def other_user() -> User: + """Return another test user.""" + return UserFactory() # ty: ignore[invalid-return-type] + + +@pytest.fixture +def authenticated_client(user: User) -> APIClient: + """Return authenticated API client.""" + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def staff_client(staff_user: User) -> APIClient: + """Return authenticated staff API client.""" + client = APIClient() + client.force_authenticate(user=staff_user) + return client + + +@pytest.fixture +def course_key() -> CourseKey: + """Return a course key.""" + return CourseKey.from_string("course-v1:OpenedX+DemoX+DemoCourse") + + +@pytest.fixture +def learning_path_key() -> LearningPathKey: + """Return a learning path key.""" + return LearningPathKey.from_string("path-v1:OpenedX+DemoX+DemoPath+Demo") + + +@pytest.fixture +def grade_credential_type() -> CredentialType: + """Create a grade-based credential type.""" + return CredentialType.objects.create( + name="Certificate of Achievement", + retrieval_func="learning_credentials.processors.retrieve_subsection_grades", + generation_func="learning_credentials.generators.generate_pdf_credential", + custom_options={}, + ) + + +@pytest.fixture +def completion_credential_type() -> CredentialType: + """Create a completion-based credential type.""" + return CredentialType.objects.create( + name="Certificate of Completion", + retrieval_func="learning_credentials.processors.retrieve_completions", + generation_func="learning_credentials.generators.generate_pdf_credential", + custom_options={}, + ) + + +@pytest.fixture +def grade_config(course_key: CourseKey, grade_credential_type: CredentialType) -> CredentialConfiguration: + """Create grade-based credential configuration.""" + return CredentialConfiguration.objects.create( + learning_context_key=course_key, + credential_type=grade_credential_type, + custom_options={'required_grades': {'Final Exam': 65, 'Overall Grade': 80}}, + ) + + +@pytest.fixture +def completion_config(course_key: CourseKey, completion_credential_type: CredentialType) -> CredentialConfiguration: + """Create completion-based credential configuration.""" + return CredentialConfiguration.objects.create( + learning_context_key=course_key, + credential_type=completion_credential_type, + custom_options={'required_completion': 100}, + ) + + +@pytest.fixture +def credential_instance(user: User, course_key: CourseKey) -> Credential: + """Create a credential instance.""" + return Credential.objects.create( + user_id=user.id, + user_full_name=user.get_full_name() or user.username, + learning_context_key=course_key, + credential_type="Certificate of Achievement", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/credential.pdf", + ) + + +# Test classes +@pytest.mark.django_db +class TestCredentialConfigurationCheckViewAuthentication: + """Test authentication requirements for credential configuration check endpoint.""" + + def test_unauthenticated_user_gets_403(self, api_client: APIClient, course_key: CourseKey): + """Test that unauthenticated user gets 403.""" + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCredentialConfigurationCheckViewPermissions: + """Test permission requirements for credential configuration check endpoint.""" + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_enrolled_user_can_access_course_check( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test that enrolled user can access course configuration check.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['has_credentials'] is False + assert data['credential_count'] == 0 + mock_course_enrollments.assert_called_once_with(course_key, user.id) + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_non_enrolled_user_denied_course_access( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, course_key: CourseKey + ): + """Test that non-enrolled user is denied course access.""" + mock_course_enrollments.return_value = [] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'Course not found or user does not have access' in str(response.data) + + @patch('learning_paths.models.LearningPathEnrollment.objects') + def test_enrolled_user_can_access_learning_path_check( + self, mock_learning_path_enrollment: Mock, authenticated_client: APIClient, learning_path_key: LearningPathKey + ): + """Test that enrolled user can access learning path configuration check.""" + mock_learning_path_enrollment.filter.return_value.exists.return_value = True + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(learning_path_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['has_credentials'] is False + assert data['credential_count'] == 0 + + @patch('learning_paths.models.LearningPathEnrollment.objects') + def test_non_enrolled_user_denied_learning_path_access( + self, mock_learning_path_enrollment: Mock, authenticated_client: APIClient, learning_path_key: LearningPathKey + ): + """Test that non-enrolled user is denied learning path access.""" + mock_learning_path_enrollment.filter.return_value.exists.return_value = False + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(learning_path_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'Learning path not found or user does not have access' in str(response.data) + + def test_invalid_learning_context_key_returns_400(self, authenticated_client: APIClient): + """Test that invalid learning context key returns 400.""" + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': 'invalid-key'}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'Invalid learning context key' in str(response.data) + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_staff_can_view_any_context_check( + self, mock_course_enrollments: Mock, staff_client: APIClient, course_key: CourseKey + ): + """Test that staff can view configuration check for any context without enrollment check.""" + # Staff users bypass enrollment checks, so we don't need to mock enrollment + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = staff_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['has_credentials'] is False + assert data['credential_count'] == 0 + # Staff users don't trigger enrollment checks + mock_course_enrollments.assert_not_called() + + +@pytest.mark.django_db +class TestCredentialConfigurationCheckView: + """Test the CredentialConfigurationCheckView functionality.""" + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_no_credentials_configured( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test response when no credentials are configured for a learning context.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['has_credentials'] is False + assert data['credential_count'] == 0 + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_single_credential_configured( + self, + mock_course_enrollments: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test response when one credential is configured for a learning context.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['has_credentials'] is True + assert data['credential_count'] == 1 + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_multiple_credentials_configured( + self, + mock_course_enrollments: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + completion_config: CredentialConfiguration, + ): + """Test response when multiple credentials are configured for a learning context.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['has_credentials'] is True + assert data['credential_count'] == 2 + + @patch('learning_paths.models.LearningPathEnrollment.objects') + def test_learning_path_credentials_configured( + self, mock_learning_path_enrollment: Mock, authenticated_client: APIClient, learning_path_key: LearningPathKey + ): + """Test response for learning path context with configured credentials.""" + mock_learning_path_enrollment.filter.return_value.exists.return_value = True + + # Create a credential configuration for the learning path + credential_type = CredentialType.objects.create( + name="Learning Path Certificate", + retrieval_func="learning_credentials.processors.retrieve_completions", + generation_func="learning_credentials.generators.generate_pdf_credential", + ) + CredentialConfiguration.objects.create( + learning_context_key=learning_path_key, + credential_type=credential_type, + ) + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(learning_path_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['has_credentials'] is True + assert data['credential_count'] == 1 + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_response_structure( + self, + mock_course_enrollments: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test that response has the correct structure and field types.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + # Verify all expected fields are present + assert 'has_credentials' in data + assert 'credential_count' in data + + # Verify field types + assert isinstance(data['has_credentials'], bool) + assert isinstance(data['credential_count'], int) + + # Verify values + assert data['has_credentials'] is True + assert data['credential_count'] == 1 + + def test_staff_can_check_any_context( + self, staff_client: APIClient, course_key: CourseKey, grade_config: CredentialConfiguration + ): + """Test that staff can check configuration for any context without enrollment.""" + url = reverse( + 'learning_credentials_api_v1:credential-configuration-check', + kwargs={'learning_context_key': str(course_key)}, + ) + response = staff_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['has_credentials'] is True + assert data['credential_count'] == 1 + + +@pytest.mark.django_db +class TestCredentialEligibilityViewAuthentication: + """Test authentication requirements for credential eligibility endpoints.""" + + def test_get_eligibility_requires_authentication(self, api_client: APIClient, course_key: CourseKey): + """Test that GET eligibility endpoint requires authentication.""" + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_post_generation_requires_authentication(self, api_client: APIClient, course_key: CourseKey): + """Test that POST generation endpoint requires authentication.""" + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': 1}, + ) + response = api_client.post(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCredentialEligibilityViewPermissions: + """Test permission requirements for credential eligibility endpoints.""" + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_enrolled_user_can_access_course_eligibility( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test that enrolled user can access course eligibility.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + # Should return 200 with empty credentials list (no configurations exist) + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['context_key'] == str(course_key) + assert data['credentials'] == [] + mock_course_enrollments.assert_called_once_with(course_key, user.id) + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_non_enrolled_user_denied_course_access( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, course_key: CourseKey + ): + """Test that non-enrolled user is denied course access.""" + mock_course_enrollments.return_value = [] + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'Course not found or user does not have access' in str(response.data) + + @patch('learning_paths.models.LearningPathEnrollment.objects') + def test_enrolled_user_can_access_learning_path_eligibility( + self, mock_learning_path_enrollment: Mock, authenticated_client: APIClient, learning_path_key: LearningPathKey + ): + """Test that enrolled user can access learning path eligibility.""" + mock_learning_path_enrollment.filter.return_value.exists.return_value = True + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', + kwargs={'learning_context_key': str(learning_path_key)}, + ) + response = authenticated_client.get(url) + + # Will return 200 with empty credentials list since no configs exist + assert response.status_code == status.HTTP_200_OK + + @patch('learning_paths.models.LearningPathEnrollment.objects') + def test_non_enrolled_user_denied_learning_path_access( + self, mock_learning_path_enrollment: Mock, authenticated_client: APIClient, learning_path_key: LearningPathKey + ): + """Test that non-enrolled user is denied learning path access.""" + mock_learning_path_enrollment.filter.return_value.exists.return_value = False + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', + kwargs={'learning_context_key': str(learning_path_key)}, + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert 'Learning path not found or user does not have access' in str(response.data) + + def test_invalid_learning_context_key_returns_400(self, authenticated_client: APIClient): + """Test that invalid learning context key returns 400.""" + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': 'invalid-key'} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'Invalid learning context key' in str(response.data) + + +@pytest.mark.django_db +class TestCredentialEligibilityViewGET: + """Test GET endpoint for credential eligibility.""" + + @patch('learning_credentials.models.CredentialConfiguration.get_user_eligibility_details') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_grade_based_eligible_credential( + self, + mock_course_enrollments: Mock, + mock_eligibility_details: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test eligibility response for eligible grade-based credential.""" + mock_course_enrollments.return_value = [user] + mock_eligibility_details.return_value = { + 'is_eligible': True, + 'current_grades': {'Final Exam': 90, 'Overall Grade': 85}, + 'required_grades': {'Final Exam': 65, 'Overall Grade': 80}, + } + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['context_key'] == str(course_key) + assert len(data['credentials']) == 1 + + credential = data['credentials'][0] + assert credential['name'] == 'Certificate of Achievement' + assert credential['is_eligible'] is True + assert credential['current_grades'] == {'Final Exam': 90, 'Overall Grade': 85} + assert credential['required_grades'] == {'Final Exam': 65, 'Overall Grade': 80} + # existing_credential is filtered out when None + assert 'existing_credential' not in credential + + @patch('learning_credentials.models.CredentialConfiguration.get_user_eligibility_details') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_completion_based_not_eligible_credential( + self, + mock_course_enrollments: Mock, + mock_eligibility_details: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + completion_config: CredentialConfiguration, + ): + """Test eligibility response for non-eligible completion-based credential.""" + mock_course_enrollments.return_value = [user] + mock_eligibility_details.return_value = { + 'is_eligible': False, + 'current_completion': 75.0, + 'required_completion': 100.0, + } + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + credential = data['credentials'][0] + assert credential['name'] == 'Certificate of Completion' + assert credential['is_eligible'] is False + assert credential['current_completion'] == 75.0 + assert credential['required_completion'] == 100.0 + # Should not include grade fields + assert 'current_grades' not in credential + assert 'required_grades' not in credential + + @patch('learning_credentials.models.CredentialConfiguration.get_user_eligibility_details') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_existing_credential_shown_in_response( + self, + mock_course_enrollments: Mock, + mock_eligibility_details: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + credential_instance: Credential, + ): + """Test that existing credential UUID is shown in eligibility response.""" + mock_course_enrollments.return_value = [user] + mock_eligibility_details.return_value = {'is_eligible': True} + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + credential = data['credentials'][0] + assert credential['existing_credential'] == str(credential_instance.uuid) + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_no_credential_configurations_returns_empty_list( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test that contexts with no credential configurations return empty list.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-eligibility', kwargs={'learning_context_key': str(course_key)} + ) + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert data['context_key'] == str(course_key) + assert data['credentials'] == [] + + +@pytest.mark.django_db +class TestCredentialEligibilityViewPOST: + """Test POST endpoint for credential generation.""" + + @patch('learning_credentials.models.CredentialConfiguration.generate_credential_for_user') + @patch('learning_credentials.models.CredentialConfiguration.get_eligible_user_ids') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_eligible_user_credential_generation_success( + self, + mock_course_enrollments: Mock, + mock_eligible_user_ids: Mock, + mock_generate_credential: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test successful credential generation for eligible user.""" + mock_course_enrollments.return_value = [user] + mock_eligible_user_ids.return_value = [user.id] + + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': grade_config.credential_type.pk}, + ) + response = authenticated_client.post(url) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + + assert 'detail' in data + assert 'generation started' in data['detail'].lower() or 'generation' in data['detail'].lower() + + mock_generate_credential.assert_called_once() + args, kwargs = mock_generate_credential.call_args + assert args[0] == user.id + + @patch('learning_credentials.models.CredentialConfiguration.get_eligible_user_ids') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_not_eligible_user_returns_400( + self, + mock_course_enrollments: Mock, + mock_eligible_user_ids: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test that non-eligible user gets 400 error.""" + mock_course_enrollments.return_value = [user] + mock_eligible_user_ids.return_value = [] # User not eligible + + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': grade_config.credential_type.pk}, + ) + response = authenticated_client.post(url) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert 'not eligible' in response.json()['detail'].lower() + + @patch('learning_credentials.models.CredentialConfiguration.get_eligible_user_ids') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_existing_credential_returns_409( + self, + mock_course_enrollments: Mock, + mock_eligible_user_ids: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + credential_instance: Credential, + ): + """Test that user with existing credential gets 409 error.""" + mock_course_enrollments.return_value = [user] + mock_eligible_user_ids.return_value = [user.id] + + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': grade_config.credential_type.pk}, + ) + response = authenticated_client.post(url) + + assert response.status_code == status.HTTP_409_CONFLICT + assert 'already has a credential' in response.json()['detail'].lower() + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_invalid_credential_type_returns_404( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test that invalid credential type ID returns 404.""" + mock_course_enrollments.return_value = [user] + + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': 999}, + ) + response = authenticated_client.post(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch('learning_credentials.models.CredentialConfiguration.generate_credential_for_user') + @patch('learning_credentials.models.CredentialConfiguration.get_eligible_user_ids') + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_generation_failure_returns_500( + self, + mock_course_enrollments: Mock, + mock_eligible_user_ids: Mock, + mock_generate_credential: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + grade_config: CredentialConfiguration, + ): + """Test that generation failure returns 500 error.""" + mock_course_enrollments.return_value = [user] + mock_eligible_user_ids.return_value = [user.id] + mock_generate_credential.side_effect = Exception("Generation failed") + + url = reverse( + 'learning_credentials_api_v1:credential-generation', + kwargs={'learning_context_key': str(course_key), 'credential_type_id': grade_config.credential_type.pk}, + ) + response = authenticated_client.post(url) + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert 'detail' in data + + +@pytest.mark.django_db +class TestCredentialListViewAuthentication: + """Test authentication requirements for credential list endpoint.""" + + def test_credential_list_requires_authentication(self, api_client: APIClient): + """Test that credential list endpoint requires authentication.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = api_client.get(url) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +@pytest.mark.django_db +class TestCredentialListViewPermissions: + """Test permission requirements for credential list endpoint.""" + + def test_user_can_view_own_credentials(self, authenticated_client: APIClient, credential_instance: Credential): + """Test that user can view their own credentials.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert len(data['credentials']) == 1 + credential_data = data['credentials'][0] + assert credential_data['credential_id'] == str(credential_instance.uuid) + assert credential_data['credential_type'] == credential_instance.credential_type + assert credential_data['context_key'] == str(credential_instance.learning_context_key) + assert credential_data['status'] == credential_instance.status + + def test_user_cannot_view_other_user_credentials(self, authenticated_client: APIClient, other_user: User): + """Test that user cannot view other user's credentials.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url, {'username': other_user.username}) + + assert response.status_code == status.HTTP_403_FORBIDDEN + + def test_staff_can_view_any_user_credentials( + self, staff_client: APIClient, user: User, credential_instance: Credential + ): + """Test that staff can view any user's credentials.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = staff_client.get(url, {'username': user.username}) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert len(data['credentials']) == 1 + assert data['credentials'][0]['credential_id'] == str(credential_instance.uuid) + + def test_invalid_username_returns_404(self, staff_client: APIClient): + """Test that invalid username returns 404.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = staff_client.get(url, {'username': 'nonexistent'}) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_user_can_filter_by_enrolled_context( + self, + mock_course_enrollments: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + credential_instance: Credential, + ): + """Test that user can filter by learning context they're enrolled in.""" + mock_course_enrollments.return_value = [user] + + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url, {'learning_context_key': str(course_key)}) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data['credentials']) == 1 + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_user_denied_filter_by_non_enrolled_context( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, course_key: CourseKey + ): + """Test that user is denied when filtering by non-enrolled context.""" + mock_course_enrollments.return_value = [] + + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url, {'learning_context_key': str(course_key)}) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.django_db +class TestCredentialListViewFunctionality: + """Test functionality of credential list endpoint.""" + + def test_empty_credential_list(self, authenticated_client: APIClient): + """Test response when user has no credentials.""" + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data['credentials'] == [] + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_multiple_credentials_serialization( + self, mock_course_enrollments: Mock, authenticated_client: APIClient, user: User, course_key: CourseKey + ): + """Test serialization of multiple credentials.""" + mock_course_enrollments.return_value = [user] + + Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key, + credential_type="Certificate A", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/cert_a.pdf", + ) + Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key, + credential_type="Certificate B", + status=Credential.Status.GENERATING, + download_url="https://example.com/cert_b.pdf", + ) + + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert len(data['credentials']) == 2 + + for credential_data in data['credentials']: + assert 'credential_id' in credential_data + assert 'credential_type' in credential_data + assert 'context_key' in credential_data + assert 'status' in credential_data + assert 'created_date' in credential_data + assert 'download_url' in credential_data + + @patch('learning_credentials.api.v1.permissions.get_course_enrollments') + def test_filter_by_learning_context( + self, + mock_course_enrollments: Mock, + authenticated_client: APIClient, + user: User, + course_key: CourseKey, + ): + """Test filtering credentials by learning context.""" + course_key2 = CourseKey.from_string("course-v1:OpenedX+DemoX+DemoCourse2") + mock_course_enrollments.return_value = [user] + + Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key, + credential_type="Cert 1", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/1.pdf", + ) + Credential.objects.create( + user_id=user.id, + user_full_name=user.username, + learning_context_key=course_key2, + credential_type="Cert 2", + status=Credential.Status.AVAILABLE, + download_url="https://example.com/2.pdf", + ) + + url = reverse('learning_credentials_api_v1:credential-list') + response = authenticated_client.get(url, {'learning_context_key': str(course_key)}) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + + assert len(data['credentials']) == 1 + assert data['credentials'][0]['context_key'] == str(course_key) diff --git a/uv.lock b/uv.lock index 6618205..158431a 100644 --- a/uv.lock +++ b/uv.lock @@ -721,6 +721,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/09/7a808392a751a24ffa62bec00e3085a9c1a151d728c323a5bab229ea0e58/django_timezone_field-7.1-py3-none-any.whl", hash = "sha256:93914713ed882f5bccda080eda388f7006349f25930b6122e9b07bf8db49c4b4", size = 13177, upload-time = "2025-01-11T17:49:52.142Z" }, ] +[[package]] +name = "django-types" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-psycopg2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/26/5f2873f7208dee0791710fda494a8d3ef7fb1d34785069b55168a23e2ac2/django_types-0.22.0.tar.gz", hash = "sha256:4cecc9eee846e7ff2a398bec9dfe6543e76efb922a7a58c5d6064bcb0e6a3dc5", size = 187214, upload-time = "2025-07-15T01:05:48.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3a/0ecbaab07cfe7b0a4fd72dfde4be8e7c11aad2b88c816cfcbb800c14cbda/django_types-0.22.0-py3-none-any.whl", hash = "sha256:ba15c756c7a732e58afd0737e54489f1c5e6f1bd24132e9199c637b1f88b057c", size = 376869, upload-time = "2025-07-15T01:05:46.621Z" }, +] + [[package]] name = "django-waffle" version = "5.0.0" @@ -795,6 +807,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b6/48/d72115d8c0d27c5d806fdd6cd61197cef6a7e34df2b8cb63b218ffa674b8/drf_jwt-1.19.2-py2.py3-none-any.whl", hash = "sha256:63c3d4ed61a1013958cd63416e2d5c84467d8ae3e6e1be44b1fb58743dbd1582", size = 21926, upload-time = "2022-01-09T09:23:37.257Z" }, ] +[[package]] +name = "drf-yasg" +version = "1.21.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, + { name = "djangorestframework" }, + { name = "inflection" }, + { name = "packaging" }, + { name = "pytz" }, + { name = "pyyaml" }, + { name = "uritemplate" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/92/93/c9a35e4d5dfa328c4d7caa66d28a7262a540e227843584395a32be0903cb/drf-yasg-1.21.10.tar.gz", hash = "sha256:f86d50faee3c31fcec4545985a871f832366c7fb5b77b62c48089d56ecf4f8d4", size = 4596566, upload-time = "2025-03-10T11:22:25.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/39/c833f775973944b378d76aeea2269e5d3d3d6528b08f1a4d774cb4cbdb3f/drf_yasg-1.21.10-py3-none-any.whl", hash = "sha256:4d832e108dfe38e365101c36123576b498487d33bf27d57d6a37efb4cc773438", size = 4290377, upload-time = "2025-03-10T11:22:23.268Z" }, +] + [[package]] name = "edx-ace" version = "1.15.0" @@ -816,6 +847,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/a9/61e293cba803cce681d37d442aaed492486094ae34d2a342272b3bf23ee4/edx_ace-1.15.0-py2.py3-none-any.whl", hash = "sha256:24f60a856777a2a4ced42ab0d9c0b56289f8080000532795e82e3d17f93e1f31", size = 65600, upload-time = "2025-06-25T07:43:26.377Z" }, ] +[[package]] +name = "edx-api-doc-tools" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-dev' or extra == 'group-20-learning-credentials-django42'" }, + { name = "django", version = "5.2.5", source = { registry = "https://pypi.org/simple" }, marker = "extra == 'group-20-learning-credentials-django52' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra != 'group-20-learning-credentials-dev' and extra != 'group-20-learning-credentials-django42')" }, + { name = "djangorestframework" }, + { name = "drf-yasg" }, + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/57/ffb66d0216ec090cf035112b820e3d5e25988d1474d48fbecceccd642d1f/edx_api_doc_tools-2.1.0.tar.gz", hash = "sha256:ea1b9afc2cc0c587b0de301916298437abd736e40f2a0048f3c4dd54f3575417", size = 20321, upload-time = "2025-04-29T18:59:05.966Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/9a/6ae603beaa5386ab83e9989d0744291b2f7f64fee476ac49e58a6be01cba/edx_api_doc_tools-2.1.0-py2.py3-none-any.whl", hash = "sha256:6a6095fa7229569ebfc95e3a9a9d93900db0e1b06af99df03162cfde5852ec41", size = 14794, upload-time = "2025-04-29T18:59:04.014Z" }, +] + [[package]] name = "edx-ccx-keys" version = "2.0.2" @@ -1323,13 +1370,22 @@ name = "importlib-metadata" version = "8.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.13' or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django42') or (extra == 'group-20-learning-credentials-dev' and extra == 'group-20-learning-credentials-django52') or (extra == 'group-20-learning-credentials-django42' and extra == 'group-20-learning-credentials-django52')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, ] +[[package]] +name = "inflection" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/7e/691d061b7329bc8d54edbf0ec22fbfb2afe61facb681f9aaa9bff7a27d04/inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417", size = 15091, upload-time = "2020-08-22T08:16:29.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/91/aa6bde563e0085a02a435aa99b49ef75b0a4b062635e606dab23ce18d720/inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2", size = 9454, upload-time = "2020-08-22T08:16:27.816Z" }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -1465,6 +1521,7 @@ dependencies = [ { name = "django-reverse-admin" }, { name = "djangorestframework" }, { name = "edx-ace" }, + { name = "edx-api-doc-tools" }, { name = "edx-opaque-keys" }, { name = "learning-paths-plugin" }, { name = "openedx-completion-aggregator" }, @@ -1483,6 +1540,7 @@ dev = [ { name = "dj-inmemorystorage" }, { name = "django", version = "4.2.23", source = { registry = "https://pypi.org/simple" } }, { name = "django-coverage-plugin" }, + { name = "django-types" }, { name = "doc8" }, { name = "edx-i18n-tools" }, { name = "factory-boy" }, @@ -1494,6 +1552,7 @@ dev = [ { name = "tox" }, { name = "tox-uv" }, { name = "twine" }, + { name = "ty" }, { name = "yamllint" }, ] django42 = [ @@ -1531,6 +1590,7 @@ requires-dist = [ { name = "django-reverse-admin" }, { name = "djangorestframework" }, { name = "edx-ace" }, + { name = "edx-api-doc-tools" }, { name = "edx-opaque-keys" }, { name = "learning-paths-plugin", specifier = ">=0.3.4" }, { name = "openedx-completion-aggregator" }, @@ -1549,6 +1609,7 @@ dev = [ { name = "dj-inmemorystorage" }, { name = "django", specifier = "<5.0" }, { name = "django-coverage-plugin" }, + { name = "django-types" }, { name = "doc8" }, { name = "edx-i18n-tools" }, { name = "factory-boy" }, @@ -1560,6 +1621,7 @@ dev = [ { name = "tox" }, { name = "tox-uv" }, { name = "twine" }, + { name = "ty" }, { name = "yamllint" }, ] django42 = [{ name = "django", specifier = ">=4.2,<5.0" }] @@ -2891,6 +2953,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7c/b6/74e927715a285743351233f33ea3c684528a0d374d2e43ff9ce9585b73fe/twine-6.1.0-py3-none-any.whl", hash = "sha256:a47f973caf122930bf0fbbf17f80b83bc1602c9ce393c7845f289a3001dc5384", size = 40791, upload-time = "2025-01-21T18:45:24.584Z" }, ] +[[package]] +name = "ty" +version = "0.0.1a19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/04/281c1a3c9c53dae5826b9d01a3412de653e3caf1ca50ce1265da66e06d73/ty-0.0.1a19.tar.gz", hash = "sha256:894f6a13a43989c8ef891ae079b3b60a0c0eae00244abbfbbe498a3840a235ac", size = 4098412, upload-time = "2025-08-19T13:29:58.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/65/a61cfcc7248b0257a3110bf98d3d910a4729c1063abdbfdcd1cad9012323/ty-0.0.1a19-py3-none-linux_armv6l.whl", hash = "sha256:e0e7762f040f4bab1b37c57cb1b43cc3bc5afb703fa5d916dfcafa2ef885190e", size = 8143744, upload-time = "2025-08-19T13:29:13.88Z" }, + { url = "https://files.pythonhosted.org/packages/02/d9/232afef97d9afa2274d23a4c49a3ad690282ca9696e1b6bbb6e4e9a1b072/ty-0.0.1a19-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cd0a67ac875f49f34d9a0b42dcabf4724194558a5dd36867209d5695c67768f7", size = 8305799, upload-time = "2025-08-19T13:29:17.322Z" }, + { url = "https://files.pythonhosted.org/packages/20/14/099d268da7a9cccc6ba38dfc124f6742a1d669bc91f2c61a3465672b4f71/ty-0.0.1a19-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ff8b1c0b85137333c39eccd96c42603af8ba7234d6e2ed0877f66a4a26750dd4", size = 7901431, upload-time = "2025-08-19T13:29:21.635Z" }, + { url = "https://files.pythonhosted.org/packages/c2/cd/3f1ca6e1d7f77cc4d08910a3fc4826313c031c0aae72286ae859e737670c/ty-0.0.1a19-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fef34a29f4b97d78aa30e60adbbb12137cf52b8b2b0f1a408dd0feb0466908a", size = 8051501, upload-time = "2025-08-19T13:29:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/47/72/ddbec39f48ce3f5f6a3fa1f905c8fff2873e59d2030f738814032bd783e3/ty-0.0.1a19-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b0f219cb43c0c50fc1091f8ebd5548d3ef31ee57866517b9521d5174978af9fd", size = 7981234, upload-time = "2025-08-19T13:29:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0f/58e76b8d4634df066c790d362e8e73b25852279cd6f817f099b42a555a66/ty-0.0.1a19-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:22abb6c1f14c65c1a2fafd38e25dd3c87994b3ab88cb0b323235b51dbad082d9", size = 8916394, upload-time = "2025-08-19T13:29:27.932Z" }, + { url = "https://files.pythonhosted.org/packages/70/30/01bfd93ccde11540b503e2539e55f6a1fc6e12433a229191e248946eb753/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5b49225c349a3866e38dd297cb023a92d084aec0e895ed30ca124704bff600e6", size = 9412024, upload-time = "2025-08-19T13:29:30.942Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a2/2216d752f5f22c5c0995f9b13f18337301220f2a7d952c972b33e6a63583/ty-0.0.1a19-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:88f41728b3b07402e0861e3c34412ca963268e55f6ab1690208f25d37cb9d63c", size = 9032657, upload-time = "2025-08-19T13:29:33.933Z" }, + { url = "https://files.pythonhosted.org/packages/24/c7/e6650b0569be1b69a03869503d07420c9fb3e90c9109b09726c44366ce63/ty-0.0.1a19-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33814a1197ec3e930fcfba6fb80969fe7353957087b42b88059f27a173f7510b", size = 8812775, upload-time = "2025-08-19T13:29:36.505Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/b8a20e06b97fe8203059d56d8f91cec4f9633e7ba65f413d80f16aa0be04/ty-0.0.1a19-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d71b7f2b674a287258f628acafeecd87691b169522945ff6192cd8a69af15857", size = 8631417, upload-time = "2025-08-19T13:29:38.837Z" }, + { url = "https://files.pythonhosted.org/packages/be/99/821ca1581dcf3d58ffb7bbe1cde7e1644dbdf53db34603a16a459a0b302c/ty-0.0.1a19-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3a7f8ef9ac4c38e8651c18c7380649c5a3fa9adb1a6012c721c11f4bbdc0ce24", size = 7928900, upload-time = "2025-08-19T13:29:41.08Z" }, + { url = "https://files.pythonhosted.org/packages/08/cb/59f74a0522e57565fef99e2287b2bc803ee47ff7dac250af26960636939f/ty-0.0.1a19-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:60f40e72f0fbf4e54aa83d9a6cb1959f551f83de73af96abbb94711c1546bd60", size = 8003310, upload-time = "2025-08-19T13:29:43.165Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b3/1209b9acb5af00a2755114042e48fb0f71decc20d9d77a987bf5b3d1a102/ty-0.0.1a19-py3-none-musllinux_1_2_i686.whl", hash = "sha256:64971e4d3e3f83dc79deb606cc438255146cab1ab74f783f7507f49f9346d89d", size = 8496463, upload-time = "2025-08-19T13:29:46.136Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d6/a4b6ba552d347a08196d83a4d60cb23460404a053dd3596e23a922bce544/ty-0.0.1a19-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9aadbff487e2e1486e83543b4f4c2165557f17432369f419be9ba48dc47625ca", size = 8700633, upload-time = "2025-08-19T13:29:49.351Z" }, + { url = "https://files.pythonhosted.org/packages/96/c5/258f318d68b95685c8d98fb654a38882c9d01ce5d9426bed06124f690f04/ty-0.0.1a19-py3-none-win32.whl", hash = "sha256:00b75b446357ee22bcdeb837cb019dc3bc1dc5e5013ff0f46a22dfe6ce498fe2", size = 7811441, upload-time = "2025-08-19T13:29:52.077Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/039227eee3c0c0cddc25f45031eea0f7f10440713f12d333f2f29cf8e934/ty-0.0.1a19-py3-none-win_amd64.whl", hash = "sha256:aaef76b2f44f6379c47adfe58286f0c56041cb2e374fd8462ae8368788634469", size = 8441186, upload-time = "2025-08-19T13:29:54.53Z" }, + { url = "https://files.pythonhosted.org/packages/74/5f/bceb29009670ae6f759340f9cb434121bc5ed84ad0f07bdc6179eaaa3204/ty-0.0.1a19-py3-none-win_arm64.whl", hash = "sha256:893755bb35f30653deb28865707e3b16907375c830546def2741f6ff9a764710", size = 8000810, upload-time = "2025-08-19T13:29:56.796Z" }, +] + +[[package]] +name = "types-psycopg2" +version = "2.9.21.20250809" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/17/d0/66f3f04bab48bfdb2c8b795b2b3e75eb20c7d1fb0516916db3be6aa4a683/types_psycopg2-2.9.21.20250809.tar.gz", hash = "sha256:b7c2cbdcf7c0bd16240f59ba694347329b0463e43398de69784ea4dee45f3c6d", size = 26539, upload-time = "2025-08-09T03:14:54.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/98/182497602921c47fadc8470d51a32e5c75343c8931c0b572a5c4ae3b948b/types_psycopg2-2.9.21.20250809-py3-none-any.whl", hash = "sha256:59b7b0ed56dcae9efae62b8373497274fc1a0484bdc5135cdacbe5a8f44e1d7b", size = 24824, upload-time = "2025-08-09T03:14:53.908Z" }, +] + [[package]] name = "typing-extensions" version = "4.14.1"