Skip to content

feat: add certificate proctoring status API endpoint - #447

Open
rahulkanneri-2u wants to merge 2 commits into
mainfrom
feat/certificate-proctoring-status-endpoint
Open

rahulkanneri-2u wants to merge 2 commits into
mainfrom
feat/certificate-proctoring-status-endpoint

Conversation

@rahulkanneri-2u

@rahulkanneri-2u rahulkanneri-2u commented Oct 14, 2026 •

Copy link
Copy Markdown

JIRA:

Description: Adds a read-only endpoint, GET /api/v1/certificate_proctoring/course_id/{course_id}/status?lms_user_id={id}, that lets the LMS fetch a learner's proctoring status for certificate decisions. For each active, non-practice proctored exam in the course it returns the learner's latest attempt (id and status), or nulls when there is no attempt. Access is limited to service accounts and global staff through a new ServiceUserPermissions class.

Author concerns:

  • ServiceUserPermissions only checks is_staff, so any global staff user passes, not just the LMS service account. It matches the existing is_staff behaviour, but the name and docstring imply tighter scoping.
  • Attempts are matched on user__lms_user_id, which is indexed but not unique. If duplicate user rows share an id, the newest attempt across them is returned.
  • The endpoint intentionally skips the timeout-to-submitted transition that learner-facing views perform. A timed-out started attempt is therefore reported as started, which the LMS needs to account for.

Dependencies:
edx/edx-platform#522

Installation instructions: None. There are no migrations or new settings.

Testing instructions:

  1. Run the new tests: pytest edx_exams/apps/api/v1/tests/test_views.py -k CertificateProctoringStatusViewTests
  2. As a staff or service user, call GET /api/v1/certificate_proctoring/course_id/<course_id>/status?lms_user_id=<id>.
  3. Expect a 200 with course_id, lms_user_id and an exams list, one entry per active proctored exam, each with the latest attempt_id and status.
  4. Call it as a learner or course staff user and expect 403.
  5. Omit lms_user_id or pass a non-integer value and expect 400.
  6. Check that practice, onboarding, timed and inactive exams are not listed, and that no attempt records change after the call.

Merge checklist:

  • All reviewers approved
  • CI build is green
  • Changelog record added
  • Documentation updated (not only docstrings)
  • Commits are squashed

Post merge:

  • Delete working branch (if not needed anymore)

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  edx_exams/apps/api
  permissions.py
  serializers.py
  edx_exams/apps/api/v1
  urls.py
  views.py
  edx_exams/apps/api/v1/tests
  test_views.py
  edx_exams/apps/core
  api.py
Project Total  

This report was generated by python-coverage-comment-action

"""

authentication_classes = (JwtAuthentication,)
permission_classes = (ServiceUserPermissions,)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

ServiceUserPermissions is the same check as DRF's IsAdminUser (bool(request.user and request.user.is_staff)). The built-in would let us drop the new class from permissions.py and the import here (add IsAdminUser to the rest_framework.permissions import):

Suggested change
permission_classes = (ServiceUserPermissions,)
permission_classes = (IsAuthenticated, IsAdminUser)

Comment on lines +422 to +424
latest_by_exam = {attempt.exam_id: attempt for attempt in attempts}

return [(exam, latest_by_exam.get(exam.id)) for exam in exams]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

check_if_exam_timed_out only runs when the learner reopens the exam, so a timed-out attempt can sit in started indefinitely and the LMS shows the wrong reason. get_exam_attempt_time_remaining is a pure function, so the effective status can be reported without writing anything:

Suggested change
latest_by_exam = {attempt.exam_id: attempt for attempt in attempts}
return [(exam, latest_by_exam.get(exam.id)) for exam in exams]
latest_by_exam = {attempt.exam_id: attempt for attempt in attempts}
# Report an in-progress attempt whose time has run out as submitted, without saving it:
# check_if_exam_timed_out only runs when the learner reopens the exam, so the stored
# status can stay "started" indefinitely.
for attempt in latest_by_exam.values():
if (
attempt.status in ExamAttemptStatus.in_progress_statuses
and get_exam_attempt_time_remaining(attempt) == 0
):
attempt.status = ExamAttemptStatus.submitted
return [(exam, latest_by_exam.get(exam.id)) for exam in exams]

@abhalsod-sonata abhalsod-sonata left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Clean, read-only and batched (one query for exams, one for attempts) instead of get_current_exam_attempt per exam. Two suggestions, both inline:

  1. Timed-out attempts. Because this skips the timeout transition, an attempt whose time ran out keeps reporting started until the learner reopens the exam, and nothing else advances it. edx/edx-platform#522 treats started as incomplete, so the learner is told to "complete your required proctored exam" when they can't, instead of seeing the "under review" message. Reporting it as submitted without saving keeps the endpoint read-only and matches what check_if_exam_timed_out would store.
  2. Permission class. ServiceUserPermissions is DRF's IsAdminUser. Using the built-in drops a class whose name implies tighter scoping than it has (your own concern in the description).

Others LGTM.

Pin created/id as the source of truth for the latest attempt: an older
attempt with a higher attempt_number or newer modified time must not win.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants