Skip to content

feat: add certificate management v2 API endpoints#38404

Draft
wgu-jesse-stewart wants to merge 5 commits intoopenedx:masterfrom
WGU-Open-edX:wgu-jesse-stewart/instructor_dashboard_certificates_v2
Draft

feat: add certificate management v2 API endpoints#38404
wgu-jesse-stewart wants to merge 5 commits intoopenedx:masterfrom
WGU-Open-edX:wgu-jesse-stewart/instructor_dashboard_certificates_v2

Conversation

@wgu-jesse-stewart
Copy link
Copy Markdown
Contributor

@wgu-jesse-stewart wgu-jesse-stewart commented Apr 21, 2026

  • Add ToggleCertificateGenerationView endpoint to enable/disable certificate generation
  • Add CertificateExceptionsView endpoint to grant and remove certificate exceptions (allowlist)
  • Add CertificateInvalidationsView endpoint to invalidate and re-validate certificates
  • Update certificate status labels to be more user-friendly (e.g., "Received" instead of "already received")
  • Update certificate generation history labels (e.g., "All Learners" instead of "All learners")
  • Add invalidation notes to IssuedCertificateSerializer
  • Add certificatesEnabled flag to CourseInformationSerializerV2

Description

This PR adds comprehensive v2 API endpoints for certificate management in the instructor dashboard, supporting the new React-based instructor dashboard UI.

Changes include:

  • New API Endpoints:

    • POST /api/instructor/v2/courses/{course_id}/certificates/toggle_generation - Enable/disable certificate generation for a course
    • POST /api/instructor/v2/courses/{course_id}/certificates/exceptions - Grant certificate exceptions (allowlist) to learners
    • DELETE /api/instructor/v2/courses/{course_id}/certificates/exceptions - Remove certificate exceptions
    • POST /api/instructor/v2/courses/{course_id}/certificates/invalidations - Invalidate certificates for learners
    • DELETE /api/instructor/v2/courses/{course_id}/certificates/invalidations - Re-validate (remove invalidation) certificates
  • Data Model Updates:

    • Updated certificate status labels to be more user-friendly:
      • "downloadable" → "Received"
      • "notpassing" → "Not Received"
      • "unavailable" → "Invalidated"
      • "error" → "Error State"
      • "audit_passing" → "Audit - Passing"
      • "audit_notpassing" → "Audit - Not Passing"
    • Updated certificate generation history labels:
      • "All learners" → "All Learners"
      • "For exceptions" → "Granted Exceptions"
  • Serializer Enhancements:

    • Added invalidation_note field to IssuedCertificateSerializer to include notes about certificate invalidations
    • Added certificatesEnabled flag to CourseInformationSerializerV2 to indicate if certificate management is available

Key Features:

  • Bulk operations support with detailed error handling - returns separate success and error lists
  • Validation checks to prevent invalid operations (e.g., granting exceptions to non-enrolled users, invalidating already-invalidated certificates)
  • Proper permission checks using existing instructor permissions

User Roles Impacted:

  • Instructors/Course Staff - Can manage certificate exceptions and invalidations through the new instructor dashboard UI

Supporting information

Related to the instructor dashboard v2 modernization effort.

Testing instructions

  1. Toggle Certificate Generation:

    curl -X POST /api/instructor/v2/courses/{course_id}/certificates/toggle_generation \
      -H "Authorization: Bearer {token}" \
      -d '{"enabled": false}'

    Expected: Returns {"enabled": false}

  2. Grant Certificate Exceptions (bulk):

    curl -X POST /api/instructor/v2/courses/{course_id}/certificates/exceptions \
      -H "Authorization: Bearer {token}" \
      -d '{"learners": ["user1", "user2@example.com"], "notes": "Test exception"}'

    Expected: Returns success/error lists for each learner

  3. Remove Certificate Exception:

    curl -X DELETE /api/instructor/v2/courses/{course_id}/certificates/exceptions \
      -H "Authorization: Bearer {token}" \
      -d '{"username": "user1"}'

    Expected: Returns success message

  4. Invalidate Certificates (bulk):

    curl -X POST /api/instructor/v2/courses/{course_id}/certificates/invalidations \
      -H "Authorization: Bearer {token}" \
      -d '{"learners": ["user1", "user2@example.com"], "notes": "Reason for invalidation"}'

    Expected: Returns success/error lists for each learner

  5. Remove Certificate Invalidation:

    curl -X DELETE /api/instructor/v2/courses/{course_id}/certificates/invalidations \
      -H "Authorization: Bearer {token}" \
      -d '{"username": "user1"}'

    Expected: Returns success message

Deadline

None

Other information

  • These endpoints follow the same patterns as other v2 instructor API endpoints
  • No database migrations required - uses existing models
  • Backwards compatible - does not modify existing v1 endpoints
  • Frontend PR: [link to frontend-app-instruct PR when created]

- Add ToggleCertificateGenerationView endpoint to enable/disable certificate generation
- Add CertificateExceptionsView endpoint to grant and remove certificate exceptions (allowlist)
- Add CertificateInvalidationsView endpoint to invalidate and re-validate certificates
- Update certificate status labels to be more user-friendly (e.g., "Received" instead of "already received")
- Update certificate generation history labels (e.g., "All Learners" instead of "All learners")
- Add invalidation notes to IssuedCertificateSerializer
- Add certificatesEnabled flag to CourseInformationSerializerV2
@openedx-webhooks openedx-webhooks added open-source-contribution PR author is not from Axim or 2U core contributor PR author is a Core Contributor (who may or may not have write access to this repo). labels Apr 21, 2026
@openedx-webhooks
Copy link
Copy Markdown

Thanks for the pull request, @wgu-jesse-stewart!

This repository is currently maintained by @openedx/wg-maintenance-openedx-platform.

Once you've gone through the following steps feel free to tag them in a comment and let them know that your changes are ready for engineering review.

🔘 Get product approval

If you haven't already, check this list to see if your contribution needs to go through the product review process.

  • If it does, you'll need to submit a product proposal for your contribution, and have it reviewed by the Product Working Group.
    • This process (including the steps you'll need to take) is documented here.
  • If it doesn't, simply proceed with the next step.
🔘 Provide context

To help your reviewers and other members of the community understand the purpose and larger context of your changes, feel free to add as much of the following information to the PR description as you can:

  • Dependencies

    This PR must be merged before / after / at the same time as ...

  • Blockers

    This PR is waiting for OEP-1234 to be accepted.

  • Timeline information

    This PR must be merged by XX date because ...

  • Partner information

    This is for a course on edx.org.

  • Supporting documentation
  • Relevant Open edX discussion forum threads
🔘 Get a green build

If one or more checks are failing, continue working on your changes until this is no longer the case and your build turns green.

🔘 Update the status of your PR

Your PR is currently marked as a draft. After completing the steps above, update its status by clicking "Ready for Review", or removing "WIP" from the title, as appropriate.


Where can I find more information?

If you'd like to get more details on all aspects of the review process for open source pull requests (OSPRs), check out the following resources:

When can I expect my changes to be merged?

Our goal is to get community contributions seen and reviewed as efficiently as possible.

However, the amount of time that it takes to review and merge a PR can vary significantly based on factors such as:

  • The size and impact of the changes that it introduces
  • The need for product review
  • Maintenance status of the parent repository

💡 As a result it may take up to several weeks or months to complete a review and merge your PR.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds new Instructor Dashboard v2 endpoints to manage course certificate generation, exceptions (allowlist), and invalidations, plus updates to certificate-related labels/serialization to support the React-based instructor dashboard UI.

Changes:

  • Introduces v2 API endpoints to toggle certificate generation, manage certificate exceptions, and manage certificate invalidations.
  • Extends v2 serializers to expose certificate-management availability and invalidation notes.
  • Updates certificate status/history “human-readable” labels and adjusts related tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 16 comments.

Show a summary per file
File Description
lms/djangoapps/instructor/views/serializers_v2.py Adds certificates_enabled and invalidation_note to v2 serializers.
lms/djangoapps/instructor/views/api_v2.py Adds new v2 certificate management APIViews and extends issued-certs context with invalidation notes.
lms/djangoapps/instructor/views/api_urls.py Wires new v2 certificate management routes.
lms/djangoapps/instructor/tests/test_api_v2.py Updates expected certificate generation history label casing.
lms/djangoapps/certificates/tests/test_models.py Updates expected label strings for generation history candidate rendering.
lms/djangoapps/certificates/models.py Updates generation history candidate strings (“All Learners”, “Granted Exceptions”).
lms/djangoapps/certificates/data.py Updates human-readable certificate status labels and adds mapping for unavailable.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

help_text="Date when certificate was invalidated in ISO 8601 format"
)
invalidation_note = serializers.SerializerMethodField(
allow_null=True,
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidation_note is declared with allow_null=True, but with the current context-building (inv.notes or '') this will almost always be an empty string rather than null. Either return None when notes are missing or drop allow_null=True if the field is always a string.

Suggested change
allow_null=True,

Copilot uses AI. Check for mistakes.
Comment on lines +1863 to +1867
# Check if already invalidated
if CertificateInvalidation.objects.filter(
generated_certificate=certificate,
active=True
).exists():
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bulk invalidation does multiple queries per learner (GeneratedCertificate.objects.get(...) + active invalidation .exists() + .create()), which can be costly for large batches. Consider fetching all target certificates and existing active invalidations in bulk and then processing in-memory.

Copilot uses AI. Check for mistakes.

def post(self, request, course_id):
"""Toggle certificate generation for a course."""
course_key = CourseKey.from_string(course_id)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint doesn’t validate that the course actually exists (no get_course_by_id(course_key)), so a valid-looking course key could still create/update certificate-generation settings for a non-existent course. Recommend calling get_course_by_id(course_key) up front (consistent with other v2 views).

Suggested change
course_key = CourseKey.from_string(course_id)
course_key = CourseKey.from_string(course_id)
# Validate that the course exists before updating certificate settings.
get_course_by_id(course_key)

Copilot uses AI. Check for mistakes.

def post(self, request, course_id):
"""Grant certificate exceptions (add to allowlist)."""
course_key = CourseKey.from_string(course_id)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This view does not validate that course_id corresponds to an existing course (no get_course_by_id(course_key) call). Because CertificateAllowlist.course_id is not a foreign key, this can create allowlist entries for nonexistent courses if an arbitrary course key is provided.

Suggested change
course_key = CourseKey.from_string(course_id)
course_key = CourseKey.from_string(course_id)
try:
course = get_course_by_id(course_key)
except (ItemNotFoundError, NotFound):
raise NotFound(_('Course not found'))
if course is None:
raise NotFound(_('Course not found'))

Copilot uses AI. Check for mistakes.
Comment on lines +85 to +87
certificates_enabled = serializers.SerializerMethodField(
help_text="Whether certificate management features are enabled for this course"
)
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description mentions adding a certificatesEnabled flag, but the serializer exposes certificates_enabled (snake_case). If API consumers expect camelCase here, this will be a breaking mismatch—either rename the field to match the contract or update the PR description/frontend to use certificates_enabled.

Copilot uses AI. Check for mistakes.
Comment on lines +1760 to +1771
# Remove exception
deleted_count, __ = CertificateAllowlist.objects.filter(
course_id=course_key,
user=user
).delete()

if deleted_count == 0:
return Response(
{'message': _('No certificate exception found for this user')},
status=status.HTTP_404_NOT_FOUND
)

Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This delete path bypasses certs_api.remove_allowlist_entry(...), which (per certificates API) invalidates any existing certificate before removing the allowlist entry. Deleting the row directly may leave an already-generated certificate valid/downloadable even after the exception is removed. Recommend using certs_api.remove_allowlist_entry(user, course_key) (or duplicating its invalidation behavior) instead of raw .delete().

Suggested change
# Remove exception
deleted_count, __ = CertificateAllowlist.objects.filter(
course_id=course_key,
user=user
).delete()
if deleted_count == 0:
return Response(
{'message': _('No certificate exception found for this user')},
status=status.HTTP_404_NOT_FOUND
)
# Remove exception via certificates API so any existing certificate
# is invalidated before the allowlist entry is removed.
allowlist_entry_exists = CertificateAllowlist.objects.filter(
course_id=course_key,
user=user
).exists()
if not allowlist_entry_exists:
return Response(
{'message': _('No certificate exception found for this user')},
status=status.HTTP_404_NOT_FOUND
)
certs_api.remove_allowlist_entry(user, course_key)

Copilot uses AI. Check for mistakes.
Comment on lines +1724 to +1729
# Grant exception
CertificateAllowlist.objects.create(
user=user,
course_id=course_key,
allowlist=True,
notes=notes
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Creating CertificateAllowlist entries directly after a separate .exists() check is race-prone given the model’s unique_together (course_id, user) constraint (concurrent requests can still raise IntegrityError). Prefer using certs_api.create_or_update_certificate_allowlist_entry(...) / update_or_create to make this idempotent and consistent with v1 behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +1864 to +1881
if CertificateInvalidation.objects.filter(
generated_certificate=certificate,
active=True
).exists():
results['errors'].append({
'learner': learner,
'message': _('Certificate is already invalidated')
})
continue

# Invalidate certificate
CertificateInvalidation.objects.create(
generated_certificate=certificate,
invalidated_by=request.user,
notes=notes,
active=True
)
certificate.invalidate()
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This invalidation flow bypasses the certificates API helpers (e.g., certs_api.create_certificate_invalidation_entry and v1 checks like generated_certificate.is_valid()) and calls certificate.invalidate() without a source. Reusing the existing helper logic and passing an explicit source would keep behavior consistent and improve auditability.

Suggested change
if CertificateInvalidation.objects.filter(
generated_certificate=certificate,
active=True
).exists():
results['errors'].append({
'learner': learner,
'message': _('Certificate is already invalidated')
})
continue
# Invalidate certificate
CertificateInvalidation.objects.create(
generated_certificate=certificate,
invalidated_by=request.user,
notes=notes,
active=True
)
certificate.invalidate()
if not certificate.is_valid():
results['errors'].append({
'learner': learner,
'message': _('Certificate is already invalidated')
})
continue
# Invalidate certificate using the shared certificates API flow
certs_api.create_certificate_invalidation_entry(
generated_certificate=certificate,
invalidated_by=request.user,
notes=notes,
source='instructor_api_v2',
)

Copilot uses AI. Check for mistakes.
Comment on lines +1582 to +1586
class ToggleCertificateGenerationView(DeveloperErrorViewMixin, APIView):
"""
View to toggle certificate generation for a course.

**Example Requests**
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New v2 certificate-management endpoints are introduced here, but the PR only updates an existing history-label assertion in tests. Please add API v2 tests covering permission enforcement, course-level 404s, request validation, and key side-effects (e.g., allowlist removal invalidates certs; re-validation regenerates/restores certs).

Copilot uses AI. Check for mistakes.
Comment on lines 1289 to 1292
'invalidated_by': inv.invalidated_by.email,
'created': inv.created.isoformat()
'created': inv.created.isoformat(),
'notes': inv.notes or ''
}
Copy link

Copilot AI Apr 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

invalidation_dict coerces missing notes to an empty string (inv.notes or ''). If the API intends to represent “no notes” as null (e.g., serializer fields use allow_null=True), consider preserving None here instead of "", or align serializer definitions accordingly.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core contributor PR author is a Core Contributor (who may or may not have write access to this repo). open-source-contribution PR author is not from Axim or 2U

Projects

Status: Needs Triage

Development

Successfully merging this pull request may close these issues.

3 participants