Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ Unreleased

*

0.5.1 - 2026-03-17
******************

Added
=====

* Credential eligibility check endpoint (``GET /api/learning_credentials/v1/eligibility/<learning_context_key>/``)
with detailed progress information.

Changed
=======

* Processor functions now return ``dict[int, dict[str, Any]]`` with detailed eligibility information instead of ``list[int]`` of eligible user IDs.
* Processor functions now accept an optional ``user_id`` parameter for single-user eligibility checks.

0.5.0 - 2026-01-29
******************

Expand Down
17 changes: 17 additions & 0 deletions learning_credentials/api/v1/permissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@
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 a ``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

if username := request.query_params.get("username"):
return request.user.username == username
return True


class CanAccessLearningContext(BasePermission):
"""Permission to allow access to learning context if the user is enrolled."""

Expand Down
35 changes: 34 additions & 1 deletion learning_credentials/api/v1/serializers.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,48 @@
"""API serializers for learning credentials."""

from typing import Any

from rest_framework import serializers

from learning_credentials.models import Credential


class CredentialSerializer(serializers.ModelSerializer):
"""Serializer that returns credential metadata."""
"""Serializer that returns credential metadata (for the public verification endpoint)."""

class Meta:
"""Serializer metadata."""

model = Credential
fields = ('user_full_name', 'created', 'learning_context_name', 'status', 'invalidation_reason')


class CredentialEligibilitySerializer(serializers.Serializer):
"""Serializer for credential eligibility information with dynamic fields."""

credential_type_id = serializers.IntegerField()
name = serializers.CharField()
is_generation_enabled = serializers.BooleanField()
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)
7 changes: 6 additions & 1 deletion learning_credentials/api/v1/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from django.urls import path

from .views import CredentialConfigurationCheckView, CredentialMetadataView
from .views import CredentialConfigurationCheckView, CredentialEligibilityView, CredentialMetadataView

urlpatterns = [
path(
Expand All @@ -11,4 +11,9 @@
name='credential_configuration_check',
),
path('metadata/<uuid:uuid>/', CredentialMetadataView.as_view(), name='credential-metadata'),
path(
'eligibility/<str:learning_context_key>/',
CredentialEligibilityView.as_view(),
name='credential-eligibility',
),
]
131 changes: 129 additions & 2 deletions learning_credentials/api/v1/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from typing import TYPE_CHECKING

import edx_api_doc_tools as apidocs
from django.contrib.auth import get_user_model
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
Expand All @@ -11,10 +13,11 @@

from learning_credentials.models import Credential, CredentialConfiguration

from .permissions import CanAccessLearningContext
from .serializers import CredentialSerializer
from .permissions import CanAccessLearningContext, IsAdminOrSelf
from .serializers import CredentialEligibilityResponseSerializer, CredentialSerializer

if TYPE_CHECKING:
from django.contrib.auth.models import User
from rest_framework.request import Request


Expand Down Expand Up @@ -141,3 +144,127 @@ def get(self, _request: "Request", uuid: str) -> Response:

serializer = CredentialSerializer(credential)
return Response(serializer.data, status=status.HTTP_200_OK)


class CredentialEligibilityView(APIView):
"""
API view for credential eligibility checking and generation.

**GET**: Returns detailed eligibility info for all configured credentials in a learning context.
**POST**: Triggers credential generation for an eligible user.

Staff users can operate on behalf of other users via the ``username`` parameter.
"""

permission_classes = (IsAuthenticated, IsAdminOrSelf, CanAccessLearningContext)

def _get_eligibility_data(
self, user: "User", config: CredentialConfiguration, credentials_by_config_id: dict[int, Credential]
) -> dict:
"""Calculate eligibility data for a credential configuration."""
progress_data = config.get_user_eligibility_details(user_id=user.id)
existing_credential = credentials_by_config_id.get(config.id)

return {
'credential_type_id': config.credential_type.pk,
'name': config.credential_type.name,
'is_generation_enabled': config.periodic_task.enabled,
**progress_data,
'existing_credential': existing_credential.uuid if existing_credential else None,
'existing_credential_url': existing_credential.download_url if existing_credential else None,
}

@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)"
),
),
apidocs.string_parameter(
"retrieval_func",
ParameterLocation.QUERY,
description=(
"Filter by credential type retrieval function "
"(e.g. learning_credentials.processors.retrieve_subsection_grades)."
),
),
],
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.

Returns detailed eligibility information for all configured credentials, including:

- Current grades and requirements for grade-based credentials
- Completion percentages for completion-based credentials
- Step-by-step progress for learning paths
- Existing credential info if already generated

**Query Parameters**

- ``username`` (staff only): View eligibility for a specific user.
- ``retrieval_func``: Filter by credential type retrieval function
(e.g. ``learning_credentials.processors.retrieve_subsection_grades``).

**Example Request**

``GET /api/learning_credentials/v1/eligibility/course-v1:OpenedX+DemoX+DemoCourse/``

**Example Response**

.. code-block:: json

{
"context_key": "course-v1:OpenedX+DemoX+DemoCourse",
"credentials": [
{
"credential_type_id": 1,
"name": "Certificate of Achievement",
"is_eligible": true,
"existing_credential": null,
"current_grades": {"final exam": 86, "total": 82},
"required_grades": {"final exam": 65, "total": 80}
}
]
}
"""
username = request.query_params.get('username')
user = get_object_or_404(get_user_model(), username=username) if username else request.user

configurations = CredentialConfiguration.objects.filter(
learning_context_key=learning_context_key
).select_related('credential_type', 'periodic_task')

retrieval_func = request.query_params.get('retrieval_func')
if retrieval_func:
configurations = configurations.filter(credential_type__retrieval_func=retrieval_func)

# Pre-fetch all credentials for this user and learning context to avoid N+1 queries in the loop below.
credentials = Credential.objects.filter(user_id=user.id, configuration__in=configurations).exclude(
status__in=[Credential.Status.ERROR, Credential.Status.INVALIDATED]
)
credentials_by_config_id = {credential.configuration_id: credential for credential in credentials}

eligibility_data = [
self._get_eligibility_data(user, config, credentials_by_config_id) 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)
31 changes: 26 additions & 5 deletions learning_credentials/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import uuid as uuid_lib
from importlib import import_module
from pathlib import Path
from typing import TYPE_CHECKING, Self
from typing import TYPE_CHECKING, Any, Self

import jsonfield
from django.conf import settings
Expand Down Expand Up @@ -183,19 +183,40 @@ 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) -> dict[int, dict[str, Any]]:
"""
Get the list of eligible learners for the given course.
Call the retrieval function and return detailed results.

:return: A list of user IDs.
:param user_id: Optional. If provided, only check eligibility for this user.
:return: A dict mapping user IDs to their detailed progress information.
"""
func_path = self.credential_type.retrieval_func
module_path, func_name = func_path.rsplit('.', 1)
module = import_module(module_path)
func = getattr(module, func_name)

custom_options = _deep_merge(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 learning context.

:param user_id: Optional. If provided, only check eligibility for this user.
:return: A list of eligible user IDs.
"""
results = self._call_retrieval_func(user_id)
return [uid for uid, details in results.items() if details.get('is_eligible', False)]

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.
"""
results = self._call_retrieval_func(user_id)
return results.get(user_id, {'is_eligible': False})

def generate_credential_for_user(self, user_id: int, celery_task_id: int = 0) -> Credential:
"""
Expand Down
Loading
Loading