Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3af2c58
feat: implement eligibility REST API endpoint
Agrendalath Feb 13, 2026
51c6170
temp: bump version to create a pre-release
Agrendalath Feb 13, 2026
774d0ec
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
4d0b39b
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
6d33844
fixup! temp: bump version to create a pre-release
Agrendalath Mar 17, 2026
7a4d162
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
f5de9d6
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
3b7f75d
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
a22883b
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 17, 2026
70695e4
fixup! temp: bump version to create a pre-release
Agrendalath Mar 17, 2026
fd1ff19
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 25, 2026
00459c3
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 25, 2026
a9526c8
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 25, 2026
da84b4f
fixup! feat: implement eligibility REST API endpoint
Agrendalath Mar 26, 2026
ba52f72
fixup! temp: bump version to create a pre-release
Agrendalath Mar 26, 2026
72724d1
fixup! temp: bump version to create a pre-release
Agrendalath Mar 26, 2026
bff573a
chore: allow filtering Credential Configurations by periodic task status
Agrendalath Mar 31, 2026
9836c9c
fixup! temp: bump version to create a pre-release
Agrendalath Mar 31, 2026
8b20548
fixup! feat: implement eligibility REST API endpoint
Agrendalath May 1, 2026
63015b5
fixup! temp: bump version to create a pre-release
Agrendalath May 1, 2026
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
Comment thread
Agrendalath marked this conversation as resolved.
******************
Comment thread
Agrendalath marked this conversation as resolved.

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.

Comment thread
Agrendalath marked this conversation as resolved.
0.5.0 - 2026-01-29
******************

Expand Down
2 changes: 1 addition & 1 deletion learning_credentials/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ class CredentialConfigurationAdmin(DjangoObjectActions, ReverseModelAdmin):
]
list_display = ('learning_context_key', 'credential_type', 'enabled', 'interval')
search_fields = ('learning_context_key', 'credential_type__name')
list_filter = ('learning_context_key', 'credential_type')
list_filter = ('learning_context_key', 'credential_type', 'periodic_task__enabled')

def get_inline_instances(
self,
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',
),
]
138 changes: 136 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,134 @@ 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.

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)."
),
),
Comment thread
Agrendalath marked this conversation as resolved.
apidocs.string_parameter(
"username",
ParameterLocation.QUERY,
description=(
"Operate on behalf of the specified user. "
"Staff users may specify any username; non-staff users are limited to their own."
),
),
],
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

Comment thread
Agrendalath marked this conversation as resolved.
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]
Comment thread
Agrendalath marked this conversation as resolved.
)
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)

Comment thread
Agrendalath marked this conversation as resolved.
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