Merge branch 'master' into final-dj52

This commit is contained in:
Usama Sadiq
2025-10-06 14:43:09 +05:00
committed by GitHub
7 changed files with 487 additions and 38 deletions

View File

@@ -34,6 +34,7 @@ FEATURES["ENABLE_MKTG_SITE"] = False
INSTALLED_APPS.extend(
[
"cms.djangoapps.contentstore.apps.ContentstoreConfig",
'cms.djangoapps.modulestore_migrator',
"cms.djangoapps.course_creators",
"cms.djangoapps.xblock_config.apps.XBlockConfig",
"lms.djangoapps.lti_provider",

View File

@@ -3523,11 +3523,77 @@ paths:
in: path
required: true
type: string
/dashboard/v0/programs/:
get:
operationId: dashboard_v0_programs_list
description: |-
For a learner, get list of enrolled programs with progress.
If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner.
**Example Request**
GET /api/dashboard/v1/programs/{enterprise_uuid}/
**Parameters**
* `enterprise_uuid`: UUID of an enterprise customer.
**Example Response**
[
{
"uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c",
"title": "Demonstration Program",
"type": "MicroMasters",
"banner_image": {
"large": {
"url": "http://example.com/images/foo.large.jpg",
"width": 1440,
"height": 480
},
"medium": {
"url": "http://example.com/images/foo.medium.jpg",
"width": 726,
"height": 242
},
"small": {
"url": "http://example.com/images/foo.small.jpg",
"width": 435,
"height": 145
},
"x-small": {
"url": "http://example.com/images/foo.x-small.jpg",
"width": 348,
"height": 116
}
},
"authoring_organizations": [
{
"key": "example"
}
],
"progress": {
"uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c",
"completed": 0,
"in_progress": 0,
"not_started": 2
}
}
]
parameters: []
responses:
'200':
description: ''
tags:
- dashboard
parameters: []
/dashboard/v0/programs/{enterprise_uuid}/:
get:
operationId: dashboard_v0_programs_read
summary: For an enterprise learner, get list of enrolled programs with progress.
description: |-
For a learner, get list of enrolled programs with progress.
If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner.
**Example Request**
GET /api/dashboard/v1/programs/{enterprise_uuid}/
@@ -6446,11 +6512,77 @@ paths:
tags:
- learner_home
parameters: []
/learner_home/v1/programs/:
get:
operationId: learner_home_v1_programs_list
description: |-
For a learner, get list of enrolled programs with progress.
If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner.
**Example Request**
GET /api/dashboard/v1/programs/{enterprise_uuid}/
**Parameters**
* `enterprise_uuid`: UUID of an enterprise customer.
**Example Response**
[
{
"uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c",
"title": "Demonstration Program",
"type": "MicroMasters",
"banner_image": {
"large": {
"url": "http://example.com/images/foo.large.jpg",
"width": 1440,
"height": 480
},
"medium": {
"url": "http://example.com/images/foo.medium.jpg",
"width": 726,
"height": 242
},
"small": {
"url": "http://example.com/images/foo.small.jpg",
"width": 435,
"height": 145
},
"x-small": {
"url": "http://example.com/images/foo.x-small.jpg",
"width": 348,
"height": 116
}
},
"authoring_organizations": [
{
"key": "example"
}
],
"progress": {
"uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c",
"completed": 0,
"in_progress": 0,
"not_started": 2
}
}
]
parameters: []
responses:
'200':
description: ''
tags:
- learner_home
parameters: []
/learner_home/v1/programs/{enterprise_uuid}/:
get:
operationId: learner_home_v1_programs_read
summary: For an enterprise learner, get list of enrolled programs with progress.
description: |-
For a learner, get list of enrolled programs with progress.
If an enterprise UUID ias provided, filter out all non-enterprise enrollments for the learner.
**Example Request**
GET /api/dashboard/v1/programs/{enterprise_uuid}/
@@ -6912,7 +7044,7 @@ paths:
django settings. This is a temporary change as a part of the migration of some legacy
pages to MFEs. This is a temporary compatibility layer which will eventually be deprecated.
See [Link to DEPR ticket] for more details. todo: add link
See [DEPR ticket](https://github.com/openedx/edx-platform/issues/37210) for more details.
The compatability means that settings from the legacy locations will continue to work but
the settings listed below in the `_get_legacy_config` function should be added to the MFE

View File

@@ -35,6 +35,128 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase #
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
@ddt.ddt
class CertificateTaskViewTests(SharedModuleStoreTestCase):
"""Tests for the certificate panel of the instructor dash. """
@classmethod
def setUpClass(cls):
"""
Set up the test class with a test course and instructor dashboard URL.
"""
super().setUpClass()
cls.course = CourseFactory.create()
cls.url = reverse(
'instructor_dashboard',
kwargs={'course_id': str(cls.course.id)}
)
def setUp(self):
"""
Set up test users and enable certificate generation configuration.
"""
super().setUp()
self.user = UserFactory.create()
self.global_staff = GlobalStaffFactory()
self.instructor = InstructorFactory(course_key=self.course.id)
# Need to clear the cache for model-based configuration
cache.clear()
# Enable the certificate generation feature
CertificateGenerationConfiguration.objects.create(enabled=True)
def _login_as(self, role):
"""
Log in the test client as the specified user role.
"""
user_map = {
"user": self.user.username,
"instructor": self.instructor.username,
"global_staff": self.global_staff.username
}
self.client.login(username=user_map.get(role, "user"), password=self.TEST_PASSWORD)
def _get_url(self, action):
"""
Build the unified certificate task URL for the given action.
"""
return reverse("certificate_task", kwargs={"course_id": self.course.id, "action": action})
def _assert_redirects_to_instructor_dash(self, response):
"""Check that the response redirects to the certificates section. """
expected_redirect = reverse(
'instructor_dashboard',
kwargs={'course_id': str(self.course.id)}
)
expected_redirect += '#view-certificates'
self.assertRedirects(response, expected_redirect)
@ddt.data(True, False)
def test_certificate_generation_enable(self, is_enabled):
"""
Test enabling or disabling self-generated certificates as global staff.
"""
self._login_as("global_staff")
params = {"certificates-enabled": "true" if is_enabled else "false"}
response = self.client.post(
self._get_url("toggle"),
data=params
)
# Expect a redirect back to the instructor dashboard
self._assert_redirects_to_instructor_dash(response)
# Expect that certificate generation is now enabled for the course
actual_enabled = certs_api.has_self_generated_certificates_enabled(str(self.course.id))
assert is_enabled == actual_enabled
@ddt.data("user", "instructor", "global_staff")
def test_certificate_generation(self, role):
"""
Test permission-based access to certificate generation by role.
"""
self._login_as(role)
response = self.client.post(self._get_url("generate"))
actual_status_code = {
"user": 403,
"instructor": 200,
"global_staff": 200
}
assert response.status_code == actual_status_code[role]
@ddt.data(
("downloadable", 200, True, 'Certificate regeneration task has been started. You can view '
'the status of the generation task in the "Pending Tasks" section.'),
("generating", 400, False, 'Please select certificate statuses that lie with '
'in "certificate_statuses" entry in POST data.')
)
@ddt.unpack
def test_certificate_regeneration_status_handling(self, cert_status, expected_status, success, expected_message):
"""
Test certificate regeneration with valid and invalid certificate statuses.
"""
# Create a certificate with the given status
GeneratedCertificateFactory.create(
user=self.user,
course_id=self.course.id,
status=cert_status,
mode='honor'
)
self._login_as("global_staff")
response = self.client.post(
self._get_url("regenerate"),
data={'certificate_statuses': [cert_status]},
)
assert response.status_code == expected_status
res_json = response.json()
assert res_json.get('success', False) is success
assert res_json.get('message') == expected_message
@ddt.ddt
class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
"""Tests for the certificate panel of the instructor dash. """
@@ -138,7 +260,11 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase):
self.assertContains(response, 'enable-certificates-submit')
self.assertNotContains(response, 'Generate Example Certificates')
@mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True})
@mock.patch.dict(settings.FEATURES, {
'CERTIFICATES_HTML_VIEW': True,
'CERTIFICATES_INSTRUCTOR_GENERATION': False
}
)
def test_buttons_for_html_certs_in_self_paced_course(self):
"""
Tests `Enable Student-Generated Certificates` button is enabled

View File

@@ -39,7 +39,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name
from rest_framework.exceptions import MethodNotAllowed
from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.permissions import IsAdminUser, IsAuthenticated # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.permissions import IsAdminUser, IsAuthenticated, BasePermission # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order
from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order
from submissions import api as sub_api # installed from the edx-submissions repository # lint-amnesty, pylint: disable=wrong-import-order
@@ -3402,17 +3402,57 @@ def _instructor_dash_url(course_key, section=None):
return url
@require_course_permission(permissions.ENABLE_CERTIFICATE_GENERATION)
@require_POST
def enable_certificate_generation(request, course_id=None):
"""Enable/disable self-generated certificates for a course.
class HasCertificateActionPermission(BasePermission):
"""
DRF permission class to validate course-level certificate task permissions
based on the `action` URL parameter.
"""
Once self-generated certificates have been enabled, students
who have passed the course will be able to generate certificates.
permission_map = {
'toggle': permissions.ENABLE_CERTIFICATE_GENERATION,
'generate': permissions.START_CERTIFICATE_GENERATION,
'regenerate': permissions.START_CERTIFICATE_REGENERATION,
}
Redirects back to the instructor dashboard once the
setting has been updated.
def has_permission(self, request, view):
"""
Check whether the user has permission to perform the requested certificate action
on the specified course.
"""
course_id = view.kwargs.get('course_id')
action = view.kwargs.get('action')
if not course_id or not action:
return False
required_perm = self.permission_map.get(action)
if required_perm is None:
return False
try:
course_key = CourseKey.from_string(course_id)
except (ValueError, TypeError):
return False
return request.user.has_perm(required_perm, course_key)
def toggle_certificate_generation(request, course_id):
"""
Enable or disable student-generated certificates for a course.
Based on the value of the POST field `certificates-enabled`, this function
updates the course setting to allow or prevent students from generating their
own certificates. This function assumes that permission checks
have already been performed.
Args:
request (HttpRequest): The incoming POST request.
course_id (str): The course identifier in string format.
Returns:
HttpResponseRedirect: Redirects back to the instructor dashboard
(certificates section) after updating the course setting.
"""
course_key = CourseKey.from_string(course_id)
is_enabled = (request.POST.get('certificates-enabled', 'false') == 'true')
@@ -3420,6 +3460,167 @@ def enable_certificate_generation(request, course_id=None):
return redirect(_instructor_dash_url(course_key, section='certificates'))
def start_certificate_generation(request, course_id):
"""
Initiates the generation of certificates for all enrolled students in the course.
This function triggers an asynchronous background task that generates certificates
for every student enrolled in the specified course. It returns a response payload
containing a confirmation message and the task ID for tracking the task's progress.
Args:
request (HttpRequest): The HTTP request object.
course_key (CourseKey): The course identifier for which to generate certificates.
Returns:
dict: A dictionary with a success message and the task ID.
"""
course_key = CourseKey.from_string(course_id)
task = task_api.generate_certificates_for_students(request, course_key)
return {
"message": _(
"Certificate generation task for all students of this course has been started. "
"You can view the status of the generation task in the \"Pending Tasks\" section."
),
"task_id": task.task_id
}
def start_certificate_regeneration(request, course_id, certificates_statuses):
"""
Initiates regeneration of certificates for students based on given certificate statuses.
This function triggers a background task that regenerates certificates for students
whose certificates match the provided list of statuses.
Args:
request (HttpRequest): The HTTP request object.
course_key (CourseKey): The identifier of the course for which certificates are being regenerated.
certificates_statuses (list[str]): A list of certificate statuses to filter the affected certificates.
Returns:
dict: A dictionary with a success message and success status.
"""
course_key = CourseKey.from_string(course_id)
task_api.regenerate_certificates(request, course_key, certificates_statuses)
return {
'message': _(
'Certificate regeneration task has been started. '
'You can view the status of the generation task in the "Pending Tasks" section.'
),
'success': True
}
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
@method_decorator(transaction.non_atomic_requests, name='dispatch')
class CertificateTask(DeveloperErrorViewMixin, APIView):
"""
API endpoint for handling certificate-related administrative tasks for a given course.
Supported actions:
- "toggle": Enable or disable self-generated certificates.
- "generate": Initiate certificate generation for all enrolled students.
- "regenerate": Regenerate certificates based on selected certificate statuses.
URL pattern:
POST /courses/{course_id}/instructor/api/certificates/{action}/
The `action` path parameter determines the task to perform.
The request must be authenticated and the user must have the appropriate permission for the action.
"""
permission_classes = [IsAuthenticated, HasCertificateActionPermission]
@method_decorator(ensure_csrf_cookie)
def post(self, request, course_id, action=None):
"""
Handles POST requests for certificate actions.
Depending on the `action` parameter, different tasks are performed:
Args:
request (HttpRequest): The HTTP request object.
course_id (str): The ID of the course on which to perform the action.
action (str, optional): The certificate task to perform. Must be one of:
- "toggle": Enable or disable certificates for the course. No additional
parameters are required.
- "generate": Generate certificates for eligible learners. No additional
parameters are required.
- "regenerate": Regenerate certificates for learners. Requires an additional
parameter in the request body:
- `statuses` (list of str): List of certificate statuses to regenerate
(e.g., ["downloaded", "issued"]).
Returns:
Response: A DRF Response object containing a success message or error details.
If the `action` is invalid, returns HTTP 400 with an error message.
Example request body for `regenerate` action:
{
"statuses": ["downloaded", "issued"]
}
"""
if action == "toggle":
return self._handle_toggle(request, course_id)
elif action == "generate":
return self._handle_generate(request, course_id)
elif action == "regenerate":
return self._handle_regenerate(request, course_id)
else:
return Response(
{"error": f"Invalid action: {action}"},
status=status.HTTP_400_BAD_REQUEST
)
def _handle_toggle(self, request, course_id):
"""Handle certificate generation toggle."""
# TODO: Update this to return a proper API response (e.g., {"enabled": true})
return toggle_certificate_generation(request, course_id)
def _handle_generate(self, request, course_id):
"""Handle certificate generation for all students."""
payload = start_certificate_generation(request, course_id)
return Response(payload, status=status.HTTP_200_OK)
def _handle_regenerate(self, request, course_id):
"""Handle certificate regeneration based on status."""
# Validate and extract certificate statuses from the request
serializer = CertificateStatusesSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{'message': _(
'Please select certificate statuses that '
'lie with in "certificate_statuses" entry in POST data.'
)},
status=status.HTTP_400_BAD_REQUEST
)
statuses = serializer.validated_data['certificate_statuses']
payload = start_certificate_regeneration(request, course_id, statuses)
return Response(payload, status=status.HTTP_200_OK)
@require_course_permission(permissions.ENABLE_CERTIFICATE_GENERATION)
@require_POST
def enable_certificate_generation(request, course_id=None):
"""
View to toggle self-generated certificate availability for a course.
This endpoint is protected by course-level permission checks and allows
enabling or disabling student-generated certificates. The logic is handled
by `toggle_certificate_generation`.
Args:
request (HttpRequest): The incoming POST request.
course_id (str): The course identifier in string format.
Returns:
HttpResponseRedirect: Redirects to the instructor dashboard after update.
"""
return toggle_certificate_generation(request, course_id)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
class MarkStudentCanSkipEntranceExam(APIView):
"""
@@ -3471,16 +3672,8 @@ class StartCertificateGeneration(DeveloperErrorViewMixin, APIView):
"""
Generating certificates for all students enrolled in given course.
"""
course_key = CourseKey.from_string(course_id)
task = task_api.generate_certificates_for_students(request, course_key)
message = _('Certificate generation task for all students of this course has been started. '
'You can view the status of the generation task in the "Pending Tasks" section.')
response_payload = {
'message': message,
'task_id': task.task_id
}
return JsonResponse(response_payload)
payload = start_certificate_generation(request, course_id=course_id)
return JsonResponse(payload)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')
@@ -3501,7 +3694,6 @@ class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView):
"""
certificate_statuses 'certificate_statuses' in POST data.
"""
course_key = CourseKey.from_string(course_id)
serializer = self.serializer_class(data=request.data)
if not serializer.is_valid():
@@ -3511,13 +3703,8 @@ class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView):
)
certificates_statuses = serializer.validated_data['certificate_statuses']
task_api.regenerate_certificates(request, course_key, certificates_statuses)
response_payload = {
'message': _('Certificate regeneration task has been started. '
'You can view the status of the generation task in the "Pending Tasks" section.'),
'success': True
}
return JsonResponse(response_payload)
payload = start_certificate_regeneration(request, course_id, certificates_statuses)
return JsonResponse(payload)
@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch')

View File

@@ -82,6 +82,9 @@ urlpatterns = [
# Cohort management
path('add_users_to_cohorts', api.AddUsersToCohorts.as_view(), name='add_users_to_cohorts'),
# Unified endpoint for Certificate tasks
path('certificate_task/<action>', api.CertificateTask.as_view(), name='certificate_task'),
# Certificates
path('enable_certificate_generation', api.enable_certificate_generation, name='enable_certificate_generation'),
path('start_certificate_generation', api.StartCertificateGeneration.as_view(), name='start_certificate_generation'),

View File

@@ -385,16 +385,16 @@ def _section_certificates(course):
CertificateGenerationHistory.objects.filter(course_id=course.id).order_by("-created"),
'urls': {
'enable_certificate_generation': reverse(
'enable_certificate_generation',
kwargs={'course_id': course.id}
'certificate_task',
kwargs={'course_id': course.id, "action": "toggle"}
),
'start_certificate_generation': reverse(
'start_certificate_generation',
kwargs={'course_id': course.id}
'certificate_task',
kwargs={'course_id': course.id, "action": "generate"}
),
'start_certificate_regeneration': reverse(
'start_certificate_regeneration',
kwargs={'course_id': course.id}
'certificate_task',
kwargs={'course_id': course.id, "action": "regenerate"}
),
'list_instructor_tasks_url': reverse(
'list_instructor_tasks',

View File

@@ -27,7 +27,7 @@
@media only screen and (max-width: 767px) {
.survey-table .survey-option .visible-mobile-only {
width: calc(100% - 21px) !important;
width: calc(100% - 54px) !important;
}
.survey-percentage .percentage {