BREAKING: get rid of the LegacyWaffle-based CourseWaffleFlag. Both CourseWaffleFlag and FutureCourseWaffleFlag now use the modern WaffleFlag as parent class. FutureCourseWaffleFlag left to support ORA transition to modern waffle. Switch to the ORA version which supporting new Waffles.
547 lines
19 KiB
Python
547 lines
19 KiB
Python
"""
|
|
Views for Enhanced Staff Grader
|
|
"""
|
|
# NOTE: we intentionally do broad exception checking to return a clean error shape
|
|
# pylint: disable=broad-except
|
|
|
|
# NOTE: we intentionally add extra args using @require_params
|
|
# pylint: disable=arguments-differ
|
|
import logging
|
|
|
|
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
|
from edx_rest_framework_extensions.auth.session.authentication import (
|
|
SessionAuthenticationAllowInactiveUser,
|
|
)
|
|
from opaque_keys import InvalidKeyError
|
|
from opaque_keys.edx.keys import UsageKey
|
|
from openassessment.xblock.config_mixin import WAFFLE_NAMESPACE, ENHANCED_STAFF_GRADER
|
|
from rest_framework.generics import RetrieveAPIView
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.exceptions import ItemNotFoundError
|
|
|
|
from lms.djangoapps.ora_staff_grader.constants import (
|
|
PARAM_ORA_LOCATION,
|
|
PARAM_SUBMISSION_ID,
|
|
)
|
|
from lms.djangoapps.ora_staff_grader.errors import (
|
|
BadOraLocationResponse,
|
|
GradeContestedResponse,
|
|
InternalErrorResponse,
|
|
LockContestedError,
|
|
LockContestedResponse,
|
|
MissingParamResponse,
|
|
UnknownErrorResponse,
|
|
XBlockInternalError,
|
|
)
|
|
from lms.djangoapps.ora_staff_grader.ora_api import (
|
|
batch_delete_submission_locks,
|
|
check_submission_lock,
|
|
claim_submission_lock,
|
|
delete_submission_lock,
|
|
get_assessment_info,
|
|
get_submission_info,
|
|
get_submissions,
|
|
submit_grade,
|
|
)
|
|
from lms.djangoapps.ora_staff_grader.serializers import (
|
|
FileListSerializer,
|
|
InitializeSerializer,
|
|
LockStatusSerializer,
|
|
StaffAssessSerializer,
|
|
SubmissionFetchSerializer,
|
|
SubmissionStatusFetchSerializer,
|
|
)
|
|
from lms.djangoapps.ora_staff_grader.utils import require_params
|
|
from openedx.core.djangoapps.content.course_overviews.api import (
|
|
get_course_overview_or_none,
|
|
)
|
|
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
|
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
class StaffGraderBaseView(RetrieveAPIView):
|
|
"""
|
|
Base view for common auth/permission setup used across ESG views.
|
|
"""
|
|
|
|
authentication_classes = (
|
|
JwtAuthentication,
|
|
BearerAuthenticationAllowInactiveUser,
|
|
SessionAuthenticationAllowInactiveUser,
|
|
)
|
|
|
|
permission_classes = (IsAuthenticated,)
|
|
|
|
|
|
class InitializeView(StaffGraderBaseView):
|
|
"""
|
|
GET course metadata
|
|
|
|
Response: {
|
|
courseMetadata
|
|
oraMetadata
|
|
submissions
|
|
isEnabled
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- BadOraLocationResponse (HTTP 400) for bad ORA location
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
def _is_staff_grader_enabled(self, course_key):
|
|
""" Helper to evaluate if the staff grader flag / overrides are enabled """
|
|
# This toggle is documented on the edx-ora2 repo in openassessment/xblock/config_mixin.py
|
|
# Note: Do not copy this practice of directly using a toggle from a library.
|
|
# Instead, see docs for exposing a wrapper api:
|
|
# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/implement_the_right_toggle_type.html#using-other-toggles pylint: disable=line-too-long
|
|
# pylint: disable=toggle-missing-annotation
|
|
enhanced_staff_grader_flag = CourseWaffleFlag(
|
|
f"{WAFFLE_NAMESPACE}.{ENHANCED_STAFF_GRADER}",
|
|
module_name='openassessment.xblock.config_mixin'
|
|
)
|
|
return enhanced_staff_grader_flag.is_enabled(course_key)
|
|
|
|
@require_params([PARAM_ORA_LOCATION])
|
|
def get(self, request, ora_location, *args, **kwargs):
|
|
try:
|
|
init_data = {}
|
|
|
|
# Get ORA block and config (incl. rubric)
|
|
ora_usage_key = UsageKey.from_string(ora_location)
|
|
init_data["oraMetadata"] = modulestore().get_item(ora_usage_key)
|
|
|
|
# Get course metadata
|
|
course_id = str(ora_usage_key.course_key)
|
|
init_data["courseMetadata"] = get_course_overview_or_none(course_id)
|
|
|
|
# Get list of submissions for this ORA
|
|
init_data["submissions"] = get_submissions(request, ora_location)
|
|
|
|
# Is the Staff Grader enabled for this course?
|
|
init_data["isEnabled"] = self._is_staff_grader_enabled(ora_usage_key.course_key)
|
|
|
|
response_data = InitializeSerializer(init_data).data
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Catch bad ORA location
|
|
except (InvalidKeyError, ItemNotFoundError):
|
|
log.error(f"Bad ORA location provided: {ora_location}")
|
|
return BadOraLocationResponse()
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class SubmissionFetchView(StaffGraderBaseView):
|
|
"""
|
|
GET submission contents and assessment info, if any
|
|
|
|
Response: {
|
|
gradeData: {
|
|
score: (dict or None) {
|
|
pointsEarned: (int) earned points
|
|
pointsPossible: (int) possible points
|
|
}
|
|
overallFeedback: (string) overall feedback
|
|
criteria: (list of dict) [{
|
|
name: (str) name of criterion
|
|
feedback: (str) feedback for criterion
|
|
points: (int) points of selected option or None if feedback-only criterion
|
|
selectedOption: (str) name of selected option or None if feedback-only criterion
|
|
}]
|
|
}
|
|
response: {
|
|
text: (list of string), [the html content of text responses]
|
|
files: (list of dict) [{
|
|
downloadUrl: (string) file download url
|
|
description: (string) file description
|
|
name: (string) filename
|
|
}]
|
|
}
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def get(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
try:
|
|
submission_info = get_submission_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
assessment_info = get_assessment_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
|
|
response_data = SubmissionFetchSerializer(
|
|
{
|
|
"submission_info": submission_info,
|
|
"assessment_info": assessment_info,
|
|
"lock_info": lock_info,
|
|
}
|
|
).data
|
|
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class SubmissionStatusFetchView(StaffGraderBaseView):
|
|
"""
|
|
GET submission grade status, lock status, and grade data
|
|
|
|
Response: {
|
|
gradeStatus: (str) one of [graded, ungraded]
|
|
lockStatus: (str) one of [locked, unlocked, in-progress]
|
|
gradeData: {
|
|
score: (dict or None) {
|
|
pointsEarned: (int) earned points
|
|
pointsPossible: (int) possible points
|
|
}
|
|
overallFeedback: (string) overall feedback
|
|
criteria: (list of dict) [{
|
|
name: (str) name of criterion
|
|
feedback: (str) feedback for criterion
|
|
points: (int) points of selected option or None if feedback-only criterion
|
|
selectedOption: (str) name of selected option or None if feedback-only criterion
|
|
}]
|
|
}
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def get(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
try:
|
|
assessment_info = get_assessment_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
|
|
response_data = SubmissionStatusFetchSerializer(
|
|
{
|
|
"assessment_info": assessment_info,
|
|
"lock_info": lock_info,
|
|
}
|
|
).data
|
|
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class SubmissionFilesFetchView(StaffGraderBaseView):
|
|
"""
|
|
GET file metadata for a submission.
|
|
|
|
Used to get updated file download links to avoid signed download link expiration
|
|
issues.
|
|
|
|
Response: {
|
|
files: [
|
|
downloadUrl (url),
|
|
description (string),
|
|
name (string),
|
|
size (bytes),
|
|
]
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def get(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
try:
|
|
submission_info = get_submission_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
|
|
response_data = FileListSerializer(submission_info).data
|
|
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class UpdateGradeView(StaffGraderBaseView):
|
|
"""
|
|
POST submit a grade for a submission
|
|
|
|
Body: {
|
|
overallFeedback: (string) overall feedback
|
|
criteria: [
|
|
{
|
|
name: (string) name of criterion
|
|
feedback: (string, optional) feedback for criterion
|
|
selectedOption: (string) name of selected option or None if feedback-only criterion
|
|
},
|
|
... (one per criteria)
|
|
]
|
|
}
|
|
|
|
Response: {
|
|
gradeStatus: (string) - One of ['graded', 'ungraded']
|
|
lockStatus: (string) - One of ['unlocked', 'locked', 'in-progress']
|
|
gradeData: {
|
|
score: (dict or None) {
|
|
pointsEarned: (int) earned points
|
|
pointsPossible: (int) possible points
|
|
}
|
|
overallFeedback: (string) overall feedback
|
|
criteria: (list of dict) [{
|
|
name: (str) name of criterion
|
|
feedback: (str) feedback for criterion
|
|
selectedOption: (str) name of selected option or None if feedback-only criterion
|
|
}]
|
|
}
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- GradeContestedResponse (HTTP 409) for trying to submit a grade for a submission you don't have an active lock for
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def post(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
"""Update a grade"""
|
|
try:
|
|
# Reassert that we have ownership of the submission lock
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
if not lock_info.get("lock_status") == "in-progress":
|
|
assessment_info = get_assessment_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
submission_status = SubmissionStatusFetchSerializer(
|
|
{
|
|
"assessment_info": assessment_info,
|
|
"lock_info": lock_info,
|
|
}
|
|
).data
|
|
log.error(f"Grade contested for submission: {submission_uuid}")
|
|
return GradeContestedResponse(context=submission_status)
|
|
|
|
# Transform grade data and submit assessment, raises on failure
|
|
context = {"submission_uuid": submission_uuid}
|
|
grade_data = StaffAssessSerializer(request.data, context=context).data
|
|
submit_grade(request, ora_location, grade_data)
|
|
|
|
# Clear the lock on the graded submission
|
|
delete_submission_lock(request, ora_location, submission_uuid)
|
|
|
|
# Return submission status info to frontend
|
|
assessment_info = get_assessment_info(
|
|
request, ora_location, submission_uuid
|
|
)
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
response_data = SubmissionStatusFetchSerializer(
|
|
{
|
|
"assessment_info": assessment_info,
|
|
"lock_info": lock_info,
|
|
}
|
|
).data
|
|
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class SubmissionLockView(StaffGraderBaseView):
|
|
"""
|
|
POST claim a submission lock for grading
|
|
DELETE release a submission lock
|
|
|
|
Params:
|
|
- ora_location (str/UsageID): ORA location for XBlock handling
|
|
- submissionUUID (UUID): A submission to lock/unlock
|
|
|
|
Response: {
|
|
lockStatus
|
|
}
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- LockContestedResponse (HTTP 409) for contested lock
|
|
- XBlockInternalError (HTTP 500) for an issue with ORA
|
|
- UnknownError (HTTP 500) for other errors
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def post(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
"""Claim a submission lock"""
|
|
try:
|
|
# Validate ORA location
|
|
UsageKey.from_string(ora_location)
|
|
lock_info = claim_submission_lock(request, ora_location, submission_uuid)
|
|
|
|
response_data = LockStatusSerializer(lock_info).data
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Catch bad ORA location
|
|
except (InvalidKeyError, ItemNotFoundError):
|
|
log.error(f"Bad ORA location provided: {ora_location}")
|
|
return BadOraLocationResponse()
|
|
|
|
# Return updated lock info on error
|
|
except LockContestedError:
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
lock_status = LockStatusSerializer(lock_info).data
|
|
log.error(f"Lock contested for submission: {submission_uuid}")
|
|
return LockContestedResponse(context=lock_status)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
@require_params([PARAM_ORA_LOCATION, PARAM_SUBMISSION_ID])
|
|
def delete(self, request, ora_location, submission_uuid, *args, **kwargs):
|
|
"""Clear a submission lock"""
|
|
try:
|
|
# Validate ORA location
|
|
UsageKey.from_string(ora_location)
|
|
lock_info = delete_submission_lock(request, ora_location, submission_uuid)
|
|
|
|
response_data = LockStatusSerializer(lock_info).data
|
|
log.info(response_data)
|
|
return Response(response_data)
|
|
|
|
# Catch bad ORA location
|
|
except (InvalidKeyError, ItemNotFoundError):
|
|
log.error(f"Bad ORA location provided: {ora_location}")
|
|
return BadOraLocationResponse()
|
|
|
|
# Return updated lock info on error
|
|
except LockContestedError:
|
|
lock_info = check_submission_lock(request, ora_location, submission_uuid)
|
|
lock_status = LockStatusSerializer(lock_info).data
|
|
return LockContestedResponse(context=lock_status)
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling in case something blows up
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|
|
|
|
|
|
class SubmissionBatchUnlockView(StaffGraderBaseView):
|
|
"""
|
|
POST delete a group of submission locks, limited to just those in the list that the user owns.
|
|
|
|
Params:
|
|
- ora_location (str/UsageID): ORA location for XBlock handling
|
|
|
|
Body:
|
|
- submissionUUIDs (UUID): A list of submission/team submission UUIDS to lock/unlock
|
|
|
|
Response: None
|
|
|
|
Errors:
|
|
- MissingParamResponse (HTTP 400) for missing params
|
|
- XBlockInternalError (HTTP 500) for an issue within ORA
|
|
"""
|
|
|
|
@require_params([PARAM_ORA_LOCATION])
|
|
def post(self, request, ora_location, *args, **kwargs):
|
|
"""Batch delete submission locks"""
|
|
try:
|
|
# Validate ORA location
|
|
UsageKey.from_string(ora_location)
|
|
|
|
# Pull submission UUIDs list from request body
|
|
submission_uuids = request.data.get('submissionUUIDs')
|
|
if not isinstance(submission_uuids, list):
|
|
return MissingParamResponse()
|
|
batch_delete_submission_locks(request, ora_location, submission_uuids)
|
|
|
|
# Return empty response
|
|
return Response({})
|
|
|
|
# Catch bad ORA location
|
|
except (InvalidKeyError, ItemNotFoundError):
|
|
log.error(f"Bad ORA location provided: {ora_location}")
|
|
return BadOraLocationResponse()
|
|
|
|
# Issues with the XBlock handlers
|
|
except XBlockInternalError as ex:
|
|
log.error(ex)
|
|
return InternalErrorResponse(context=ex.context)
|
|
|
|
# Blanket exception handling
|
|
except Exception as ex:
|
|
log.exception(ex)
|
|
return UnknownErrorResponse()
|