feat: ora staff grader backend (#29828)
- Adds Enhanced Staff Grader (ESG) backend-for-frontend (BFF) in `lms/djangoapps/ora_staff_grader`
- Adds routing to ESG BFF at `{lms_url}/api/ora_staff_grader/*`
- Adds mock implementation routing at `{lms_url}/api/ora_staff_grader/mock/*`
- Adds `ORA_GRADING_MICROFRONTEND_URL` setting for routing to ESG microfrontend (MFE)
- Updates to the teams app:
- Add`get_teams_in_teamset` to the teams API.
- Add `get_team_names` to teams service.
- Adds `openassessment.staffgrader` app for appropriate ORA migrations.
- Modifies management commands for creation of users.
- Updates test factory to return display org with course overview.
Co-authored-by: jansenk <jkantor@edx.org>
Co-authored-by: Leangseu Kim <lkim@edx.org>
Co-authored-by: Ben Warzeski <bwarzeski@edx.org>
This commit is contained in:
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
- module-name: lms-1
|
||||
path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/ lms/djangoapps/save_for_later/"
|
||||
- module-name: lms-2
|
||||
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py"
|
||||
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/envs/ lms/lib/ lms/tests.py"
|
||||
- module-name: openedx-1
|
||||
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/coursegraph/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/demographics/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/"
|
||||
- module-name: openedx-2
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -59,6 +59,7 @@
|
||||
"lms/djangoapps/mailing/",
|
||||
"lms/djangoapps/mobile_api/",
|
||||
"lms/djangoapps/monitoring/",
|
||||
"lms/djangoapps/ora_staff_grader/",
|
||||
"lms/djangoapps/program_enrollments/",
|
||||
"lms/djangoapps/rss_proxy/",
|
||||
"lms/djangoapps/save_for_later/",
|
||||
|
||||
@@ -1816,6 +1816,7 @@ OPTIONAL_APPS = (
|
||||
('openassessment', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.assessment', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.fileupload', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.staffgrader', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.workflow', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.xblock', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
""" Shared behavior between create_test_users and create_random_users """
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.validators import ValidationError
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
|
||||
from lms.djangoapps.instructor.access import allow_access
|
||||
from openedx.core.djangoapps.user_authn.views.registration_form import AccountCreationForm
|
||||
from common.djangoapps.student.helpers import do_create_account
|
||||
from common.djangoapps.student.helpers import do_create_account, AccountValidationError
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
def create_users(
|
||||
course_key,
|
||||
user_data,
|
||||
enrollment_mode=None,
|
||||
course_staff=False,
|
||||
activate=False
|
||||
activate=False,
|
||||
ignore_user_already_exists=False,
|
||||
):
|
||||
"""Create users, enrolling them in course_key if it's not None"""
|
||||
for single_user_data in user_data:
|
||||
@@ -21,7 +28,26 @@ def create_users(
|
||||
tos_required=False
|
||||
)
|
||||
|
||||
(user, _, _) = do_create_account(account_creation_form)
|
||||
user_already_exists = False
|
||||
try:
|
||||
(user, _, _) = do_create_account(account_creation_form)
|
||||
except (ValidationError, AccountValidationError) as account_creation_error:
|
||||
# It would be convenient if we just had the AccountValidationError raised, because we include a
|
||||
# helpful error code in there, but we also do form validation on account_creation_form and those
|
||||
# are pretty opaque.
|
||||
try:
|
||||
# Check to see if there's a user with our username. If the username and email match our input,
|
||||
# we're good, it's the same user, probably. If the email doesn't match, just to be safe we will
|
||||
# continue to fail.
|
||||
user = User.objects.get(username=single_user_data['username'])
|
||||
if user.email == single_user_data['email'] and ignore_user_already_exists:
|
||||
user_already_exists = True
|
||||
print(f'Test user {user.username} already exists. Continuing to attempt to enroll.')
|
||||
else:
|
||||
raise account_creation_error
|
||||
except User.DoesNotExist:
|
||||
# If a user with the username doesn't exist the error was probably something else, so reraise
|
||||
raise account_creation_error # pylint: disable=raise-missing-from
|
||||
|
||||
if activate:
|
||||
user.is_active = True
|
||||
@@ -33,7 +59,7 @@ def create_users(
|
||||
course = modulestore().get_course(course_key, depth=1)
|
||||
allow_access(course, user, 'staff', send_email=False)
|
||||
|
||||
if course_key and course_staff:
|
||||
if course_key and course_staff and not user_already_exists:
|
||||
print(f'Created user {user.username} as course staff')
|
||||
else:
|
||||
elif not user_already_exists:
|
||||
print(f'Created user {user.username}')
|
||||
|
||||
@@ -64,6 +64,11 @@ class Command(BaseCommand):
|
||||
),
|
||||
action='store_true'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--ignore_user_already_exists',
|
||||
help="Don't fail if a user already exists. Log the error and attempt to enroll them in the course.",
|
||||
action='store_true'
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
course_key = options['course']
|
||||
@@ -78,5 +83,6 @@ class Command(BaseCommand):
|
||||
),
|
||||
enrollment_mode=enrollment_mode,
|
||||
course_staff=course_staff,
|
||||
activate=True
|
||||
activate=True,
|
||||
ignore_user_already_exists=options['ignore_user_already_exists']
|
||||
)
|
||||
|
||||
@@ -29,10 +29,18 @@ class CreateTestUsersTestCase(SharedModuleStoreTestCase):
|
||||
self.user_model = get_user_model()
|
||||
self.num_users_start = len(self.user_model.objects.all())
|
||||
|
||||
def call_command(self, users, course=None, mode=None, password=None, domain=None, course_staff=False):
|
||||
def call_command(
|
||||
self,
|
||||
users,
|
||||
course=None,
|
||||
mode=None,
|
||||
password=None,
|
||||
domain=None,
|
||||
course_staff=False,
|
||||
ignore_user_already_exists=False
|
||||
):
|
||||
""" Helper method to call the management command with various arguments """
|
||||
args = ['create_test_users']
|
||||
args.extend(users)
|
||||
args = list(users)
|
||||
if course:
|
||||
args.extend(['--course', course])
|
||||
if mode:
|
||||
@@ -43,7 +51,10 @@ class CreateTestUsersTestCase(SharedModuleStoreTestCase):
|
||||
args.extend(['--domain', domain])
|
||||
if course_staff:
|
||||
args.append('--course_staff')
|
||||
call_command(*args)
|
||||
if ignore_user_already_exists:
|
||||
args.append('--ignore_user_already_exists')
|
||||
|
||||
call_command('create_test_users', *args)
|
||||
|
||||
def test_create_users(self):
|
||||
"""
|
||||
@@ -203,3 +214,19 @@ class CreateTestUsersTestCase(SharedModuleStoreTestCase):
|
||||
user = self.user_model.objects.get(username=username)
|
||||
assert not CourseAccessRole.objects.filter(user=user).exists()
|
||||
assert not CourseEnrollment.objects.filter(user=user).exists()
|
||||
|
||||
def test_create_user__ignore_user_already_exists(self):
|
||||
"""
|
||||
Test that ignore_user_already_exists will allow us to specify a username
|
||||
that already exists without raising an exception
|
||||
"""
|
||||
test_username = 'IgnoreUserAlreadyExistsUser'
|
||||
assert not self.user_model.objects.filter(username=test_username).exists()
|
||||
|
||||
self.call_command([test_username])
|
||||
assert self.user_model.objects.filter(username=test_username).exists()
|
||||
|
||||
with self.assertRaises(ValidationError):
|
||||
self.call_command([test_username], ignore_user_already_exists=False)
|
||||
|
||||
self.call_command([test_username], ignore_user_already_exists=True)
|
||||
|
||||
17
lms/djangoapps/ora_staff_grader/README.md
Normal file
17
lms/djangoapps/ora_staff_grader/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Enhanced Staff Grader (ESG) App
|
||||
|
||||
A backend-for-frontend (BFF) for ESG. It provides endpoints at the path `{lms-url}/api/ora_staff_grader/{endpoint}`.
|
||||
|
||||
ESG is an application on top of Open Response Assessments (ORA) designed to simplify staff grading of assignments. The BFF is designed to service the ESG microfrontend (MFE) by aggregating and packaging requests to both `edx-platform` and `edx-ora2`.
|
||||
|
||||
The BFF includes both an API and mock API (/mock) for testing. Exercise either with the attached Postman collections (and included examples) or see [Enhanced Staff Grader Data Flow Design](https://openedx.atlassian.net/wiki/spaces/PT/pages/3154542730/Enhanced+Staff+Grader+Data+Flow+Design) for API reference.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Connect to or exercise endpoints at `{lms-url}/api/ora_staff_grader/{endpoint}`.
|
||||
|
||||
Alternatively, use the attached postman collections to perform headless testing of endpoints. Following the setup below:
|
||||
|
||||
1. Perform headless login: in `lms.postman_collection.json` perform the `GET Login` call to generate a new CSRF token followed by a `POST Login` with valid staff credentials to authenticate with LMS.
|
||||
2. Configure needed envirionment variables including `{{mock}} = False`
|
||||
2. Exercise endpoints: in `ora_staff_grader.postman_collection.json`
|
||||
3
lms/djangoapps/ora_staff_grader/__init__.py
Normal file
3
lms/djangoapps/ora_staff_grader/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
App for Enhanced Staff Grader (ESG) backend-for-frontend (BFF)
|
||||
"""
|
||||
13
lms/djangoapps/ora_staff_grader/constants.py
Normal file
13
lms/djangoapps/ora_staff_grader/constants.py
Normal file
@@ -0,0 +1,13 @@
|
||||
""" Constants used throughout ESG """
|
||||
|
||||
# Query params
|
||||
PARAM_ORA_LOCATION = "oraLocation"
|
||||
PARAM_SUBMISSION_ID = "submissionUUID"
|
||||
|
||||
# Error codes
|
||||
ERR_UNKNOWN = "ERR_UNKNOWN"
|
||||
ERR_INTERNAL = "ERR_INTERNAL"
|
||||
ERR_MISSING_PARAM = "ERR_MISSING_PARAM"
|
||||
ERR_BAD_ORA_LOCATION = "ERR_BAD_ORA_LOCATION"
|
||||
ERR_LOCK_CONTESTED = "ERR_LOCK_CONTESTED"
|
||||
ERR_GRADE_CONTESTED = "ERR_GRADE_CONTESTED"
|
||||
116
lms/djangoapps/ora_staff_grader/errors.py
Normal file
116
lms/djangoapps/ora_staff_grader/errors.py
Normal file
@@ -0,0 +1,116 @@
|
||||
""" Error codes and exceptions for ESG """
|
||||
from rest_framework import serializers
|
||||
from rest_framework.response import Response
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.constants import (
|
||||
ERR_BAD_ORA_LOCATION,
|
||||
ERR_GRADE_CONTESTED,
|
||||
ERR_INTERNAL,
|
||||
ERR_LOCK_CONTESTED,
|
||||
ERR_MISSING_PARAM,
|
||||
ERR_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
class ExceptionWithContext(Exception):
|
||||
"""An exception with optional context dict to be supplied in serialized result"""
|
||||
|
||||
def __init__(self, context=None):
|
||||
super().__init__(self)
|
||||
self.context = context
|
||||
|
||||
|
||||
class XBlockInternalError(ExceptionWithContext):
|
||||
"""Errors from XBlock handlers"""
|
||||
|
||||
|
||||
class LockContestedError(ExceptionWithContext):
|
||||
"""Signal for trying to operate on a lock owned by someone else"""
|
||||
|
||||
|
||||
class ErrorSerializer(serializers.Serializer):
|
||||
"""Returns error code and unpacks additional context, returns unknown error code if not supplied"""
|
||||
|
||||
error = serializers.CharField(default=ERR_UNKNOWN)
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Override to unpack context alongside error code"""
|
||||
output = super().to_representation(instance)
|
||||
|
||||
if self.context:
|
||||
for key, value in self.context.items():
|
||||
output[key] = value
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class StaffGraderErrorResponse(Response):
|
||||
"""An HTTP error response that returns serialized error data with additional provided context"""
|
||||
|
||||
status = 500
|
||||
err_code = ERR_UNKNOWN
|
||||
|
||||
def __init__(self, context=None):
|
||||
# Unpack provided content into error structure
|
||||
content = ErrorSerializer({"error": self.err_code}, context=context).data
|
||||
super().__init__(content, status=self.status)
|
||||
|
||||
|
||||
class BadOraLocationResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Error response for when the requested ORA_LOCATION could not be found in a course.
|
||||
Returns an HTTP 400 with error code.
|
||||
"""
|
||||
|
||||
status = 400
|
||||
err_code = ERR_BAD_ORA_LOCATION
|
||||
|
||||
|
||||
class MissingParamResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Error response for when a request is missing a required param/body.
|
||||
Returns an HTTP 400 with error code.
|
||||
"""
|
||||
|
||||
status = 400
|
||||
err_code = ERR_MISSING_PARAM
|
||||
|
||||
|
||||
class LockContestedResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Error response for when a user tries to operate on a submission that they do not have a lock for.
|
||||
Returns an HTTP 409 with error code and updated lock status.
|
||||
"""
|
||||
|
||||
status = 409
|
||||
err_code = ERR_LOCK_CONTESTED
|
||||
|
||||
|
||||
class GradeContestedResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Error response for when a user tries to operate on a submission that they do not have a lock for.
|
||||
Returns an HTTP 409 with error code and updated submission status.
|
||||
"""
|
||||
|
||||
status = 409
|
||||
err_code = ERR_GRADE_CONTESTED
|
||||
|
||||
|
||||
class InternalErrorResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Generic error response for an issue in an XBlock handler.
|
||||
Returns an HTTP 500 with internal error code.
|
||||
"""
|
||||
|
||||
status = 500
|
||||
err_code = ERR_INTERNAL
|
||||
|
||||
|
||||
class UnknownErrorResponse(StaffGraderErrorResponse):
|
||||
"""
|
||||
Blanket exception for when something strange breaks
|
||||
Returns an HTTP 500 with generic error code.
|
||||
"""
|
||||
|
||||
status = 500
|
||||
err_code = ERR_UNKNOWN
|
||||
105
lms/djangoapps/ora_staff_grader/lms.postman_collection.json
Normal file
105
lms/djangoapps/ora_staff_grader/lms.postman_collection.json
Normal file
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "68587cc2-2edc-45c7-a89a-7be3d5d69cd3",
|
||||
"name": "LMS",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Login",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"exec": [
|
||||
"var xsrfCookie = postman.getResponseCookie(\"csrftoken\");",
|
||||
"postman.setEnvironmentVariable('csrftoken', xsrfCookie.value);"
|
||||
],
|
||||
"type": "text/javascript"
|
||||
}
|
||||
}
|
||||
],
|
||||
"protocolProfileBehavior": {
|
||||
"disableBodyPruning": true
|
||||
},
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-CSRFToken",
|
||||
"value": "{{csrftoken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": []
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{protocol}}://{{lms_url}}/login",
|
||||
"protocol": "{{protocol}}",
|
||||
"host": [
|
||||
"{{lms_url}}"
|
||||
],
|
||||
"path": [
|
||||
"login"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "",
|
||||
"value": null,
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"description": "Run this to get an initial CSRF token. Follow with POST to login as {{user}}"
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Login",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "X-CSRFToken",
|
||||
"value": "{{csrftoken}}",
|
||||
"type": "text"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "email",
|
||||
"value": "{{user_email}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "password",
|
||||
"value": "{{user_password}}",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
},
|
||||
"url": {
|
||||
"raw": "{{protocol}}://{{lms_url}}/api/user/v1/account/login_session/",
|
||||
"protocol": "{{protocol}}",
|
||||
"host": [
|
||||
"{{lms_url}}"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"user",
|
||||
"v1",
|
||||
"account",
|
||||
"login_session",
|
||||
""
|
||||
]
|
||||
},
|
||||
"description": "Run this to authenticate with LMS. Requires running GET Login first to obtain a CSRF token."
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
22
lms/djangoapps/ora_staff_grader/mock/README.md
Normal file
22
lms/djangoapps/ora_staff_grader/mock/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Mock Enhanced Staff Grader (ESG)
|
||||
|
||||
A mock backend-for-frontend (BFF) for ESG. It provides mocked endpoints at the path http(s)://{lms-url}/api/ora_staff_grader/mock/{endpoint}.
|
||||
|
||||
This is differentiated from the "real" BFF endpoints, which omit the `mock` part of the path. This should make it easy to switch between real/mocked versions by configuring the base API path.
|
||||
|
||||
The mock is, effectively, a wrapper on top of a JSON data store. All the important data is stored in the lms/djangoapps/ora_staff_grader/mock/data/ directory. Data is generally grouped by a key that would be supplied in the request (usually the submissionUUID and/or ora_location). To add/edit data, simply edit the underlying JSON files.
|
||||
|
||||
For some endpoints (e.g. lock/unlock/grade), there is simple interactivity; hitting the endpoint will save a change to the underlying data. These can be verified by reading the updated JSON files or reverted by doing a git checkout of the file.
|
||||
|
||||
## Quickstart
|
||||
|
||||
Connect to or exercise endpoints at `{devstack-url}/api/ora_staff_grader/mock/{endpoint}`.
|
||||
|
||||
Alternatively, use the attached postman collections to perform headless testing of endpoints. Following the setup below:
|
||||
|
||||
1. Perform headless login: in `lms.postman_collection.json` perform the `GET Login` call to generate a new CSRF token followed by a `POST Login` with valid credentials to authenticate with LMS.
|
||||
2. Exercise mock endpoints: in `ora_staff_grader.postman_collection.json`, after configuring the environment variables including `{{mock}} = True`, run the example requests.
|
||||
|
||||
## API Reference
|
||||
|
||||
See [Enhanced Staff Grader Data Flow Design](https://openedx.atlassian.net/wiki/spaces/PT/pages/3154542730/Enhanced+Staff+Grader+Data+Flow+Design)
|
||||
3
lms/djangoapps/ora_staff_grader/mock/__init__.py
Normal file
3
lms/djangoapps/ora_staff_grader/mock/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Mock tooling for ESG
|
||||
"""
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"block-a": {
|
||||
"title": "Defense against the dark arts",
|
||||
"org": "Hogwarts",
|
||||
"number": "DADA101",
|
||||
"courseId": "course-v1:Hogwarts+DADA101+2021_Winter"
|
||||
},
|
||||
"block-b": {
|
||||
"title": "Introduction to Time Travel",
|
||||
"org": "Oxford",
|
||||
"number": "TT101",
|
||||
"courseId": "course-v1:Oxford+TT101+2021_Winter"
|
||||
}
|
||||
}
|
||||
93
lms/djangoapps/ora_staff_grader/mock/data/ora_metadata.json
Normal file
93
lms/djangoapps/ora_staff_grader/mock/data/ora_metadata.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"block-a": {
|
||||
"name": "Individual ORA",
|
||||
"prompts": [
|
||||
"<p>Enter a text/files response.</p>"
|
||||
],
|
||||
"type": "individual",
|
||||
"rubricConfig": {
|
||||
"feedback_prompt": "How would you grade this response?",
|
||||
"feedback_default_text": "I believe this response...",
|
||||
"feedback": "optional",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "grammar",
|
||||
"label": "Grammar",
|
||||
"prompt": "How correct is the submitter's grammar?",
|
||||
"feedback": "optional",
|
||||
"options": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Absolute rubbish",
|
||||
"points": 0
|
||||
},
|
||||
{
|
||||
"orderNum": 1,
|
||||
"name": "good",
|
||||
"label": "Good",
|
||||
"explanation": "pretty good",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"orderNum": 2,
|
||||
"name": "excellent",
|
||||
"label": "Excelent",
|
||||
"explanation": "Not absolute rubbish",
|
||||
"points": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"textResponseConfig": "optional",
|
||||
"fileUploadResponseConfig": "optional"
|
||||
},
|
||||
"block-b": {
|
||||
"name": "Team ORA",
|
||||
"prompts": [
|
||||
"<p>Enter a text/files response.</p>"
|
||||
],
|
||||
"type": "team",
|
||||
"rubricConfig": {
|
||||
"feedbackPrompt": "How would you grade this response?",
|
||||
"feedback": "optional",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "correctness",
|
||||
"label": "Correctness",
|
||||
"prompt": "How correct is the team's answer?",
|
||||
"feedback": "optional",
|
||||
"options": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Absolute rubbish",
|
||||
"points": 0
|
||||
},
|
||||
{
|
||||
"orderNum": 1,
|
||||
"name": "good",
|
||||
"label": "Good",
|
||||
"explanation": "pretty good",
|
||||
"points": 3
|
||||
},
|
||||
{
|
||||
"orderNum": 2,
|
||||
"name": "excellent",
|
||||
"label": "Excelent",
|
||||
"explanation": "Not absolute rubbish",
|
||||
"points": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"textResponseConfig": "optional",
|
||||
"fileUploadResponseConfig": "required"
|
||||
}
|
||||
}
|
||||
137
lms/djangoapps/ora_staff_grader/mock/data/responses.json
Normal file
137
lms/djangoapps/ora_staff_grader/mock/data/responses.json
Normal file
@@ -0,0 +1,137 @@
|
||||
{
|
||||
"SUBMISSION_ID-0": {
|
||||
"text": [
|
||||
"<div><h1>Response Title</h1>Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna. Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.</div>"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"name": "0_sample.bmp",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.bmp",
|
||||
"size": 818058
|
||||
},
|
||||
{
|
||||
"name": "0_sample.doc",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.doc",
|
||||
"size": 32768
|
||||
},
|
||||
{
|
||||
"name": "0_sample.docx",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.docx",
|
||||
"size": 14169117
|
||||
},
|
||||
{
|
||||
"name": "0_sample.html",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.html",
|
||||
"size": 58707
|
||||
},
|
||||
{
|
||||
"name": "0_sample.jpeg",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.jpeg",
|
||||
"size": 88731
|
||||
},
|
||||
{
|
||||
"name": "0_sample.jpg",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.jpg",
|
||||
"size": 88731
|
||||
},
|
||||
{
|
||||
"name": "0_sample.ppt",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.ppt",
|
||||
"size": 530432
|
||||
},
|
||||
{
|
||||
"name": "0_sample.pptx",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.pptx",
|
||||
"size": 413895
|
||||
},
|
||||
{
|
||||
"name": "0_sample.tiff",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.tiff",
|
||||
"size": 818184
|
||||
},
|
||||
{
|
||||
"name": "0_sample.txt",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.txt",
|
||||
"size": 3541
|
||||
},
|
||||
{
|
||||
"name": "0_sample.xls",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.xls",
|
||||
"size": 13312
|
||||
},
|
||||
{
|
||||
"name": "0_sample.xlsx",
|
||||
"description": "This is some descriptive text for (su-0). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.xlsx",
|
||||
"size": 13246
|
||||
}
|
||||
]
|
||||
},
|
||||
"SUBMISSION_ID-1": {
|
||||
"text": [
|
||||
"<div><h1>Response Title</h1>Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna. Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.</div>"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"name": "1_sample.csv",
|
||||
"description": "This is some descriptive text for (su-1). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.csv",
|
||||
"size": 7918
|
||||
},
|
||||
{
|
||||
"name": "1_sample.mp3",
|
||||
"description": "This is some descriptive text for (su-1). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.mp3",
|
||||
"size": 1693405
|
||||
},
|
||||
{
|
||||
"name": "1_sample.mp4",
|
||||
"description": "This is some descriptive text for (su-1). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/sample.mp4",
|
||||
"size": 574823
|
||||
}
|
||||
]
|
||||
},
|
||||
"SUBMISSION_ID-2": {
|
||||
"text": [
|
||||
"<div><h1>Response Title</h1>Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna. Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.</div>"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"name": "2_edX_2021_Internal_BrandTMGuidelines_v1.0.9.pdf",
|
||||
"description": "This is some descriptive text for (su-2). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/edX_2021_Internal_BrandTMGuidelines_v1.0.9.pdf",
|
||||
"size": 1000000
|
||||
},
|
||||
{
|
||||
"name": "2_irs_p5564.pdf",
|
||||
"description": "This is some descriptive text for (su-2). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/irs_p5564.pdf",
|
||||
"size": 0
|
||||
},
|
||||
{
|
||||
"name": "2_mit_Cohen_GRL16.pdf",
|
||||
"description": "This is some descriptive text for (su-2). Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna.",
|
||||
"downloadUrl": "samples/mit_Cohen_GRL16.pdf",
|
||||
"size": 1000
|
||||
}
|
||||
]
|
||||
},
|
||||
"SUBMISSION_ID-3": {
|
||||
"text": [
|
||||
"<div><h1>Response Title</h1>Phasellus tempor eros aliquam ipsum molestie, vitae varius lectus tempus. Morbi iaculis, libero euismod vehicula rutrum, nisi leo volutpat diam, quis commodo ex nunc ut odio. Pellentesque condimentum feugiat erat ac vulputate. Pellentesque porta rutrum sagittis. Curabitur vulputate tempus accumsan. Fusce bibendum gravida metus a scelerisque. Mauris fringilla orci non lobortis commodo. Quisque iaculis, quam a tincidunt vehicula, erat nisi accumsan quam, eu cursus ligula magna id odio. Nulla porttitor, lorem gravida vehicula tristique, sapien metus tristique ex, id tincidunt sapien justo nec sapien. Maecenas luctus, nisl vestibulum scelerisque pharetra, ligula orci vulputate turpis, in ultrices mauris dolor eu enim. Suspendisse quis nibh nec augue semper maximus. Morbi maximus eleifend magna. Phasellus porttitor vel magna et auctor. Nulla porttitor convallis aliquam. Donec cursus, ipsum ut egestas bibendum, purus metus dignissim est, ac condimentum leo felis eget diam. In magna mi, tincidunt id sapien id, fermentum vestibulum quam. Quisque et dui sed urna convallis rutrum pellentesque quis sapien. Cras non lectus velit. Praesent semper eros id risus mollis, quis interdum quam imperdiet. Sed nec vulputate tortor, at tristique tortor.</div>"
|
||||
],
|
||||
"files": []
|
||||
}
|
||||
}
|
||||
178
lms/djangoapps/ora_staff_grader/mock/data/submissions.json
Normal file
178
lms/djangoapps/ora_staff_grader/mock/data/submissions.json
Normal file
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"block-a": {
|
||||
"SUBMISSION_ID-0": {
|
||||
"submissionUUID": "SUBMISSION_ID-0",
|
||||
"username": "USERNAME-0",
|
||||
"dateSubmitted": 1631215154955,
|
||||
"score": {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"gradeData": {
|
||||
"showValidation": false,
|
||||
"overallFeedback": "",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "grammar",
|
||||
"selectedOption": "excellent",
|
||||
"feedback": "test3"
|
||||
}
|
||||
],
|
||||
"score": {
|
||||
"pointsEarned": 0,
|
||||
"pointsPossible": 100
|
||||
}
|
||||
},
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "unlocked"
|
||||
},
|
||||
"SUBMISSION_ID-1": {
|
||||
"submissionUUID": "SUBMISSION_ID-1",
|
||||
"username": "USERNAME-1",
|
||||
"dateSubmitted": 1631301554955,
|
||||
"score": {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"gradeData": {
|
||||
"score": {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"overallFeedback": "was okay",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "firstCriterion",
|
||||
"feedback": "did alright",
|
||||
"selectedOption": "good"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "locked"
|
||||
},
|
||||
"SUBMISSION_ID-2": {
|
||||
"submissionUUID": "SUBMISSION_ID-2",
|
||||
"username": "USERNAME-2",
|
||||
"dateSubmitted": 1631387954955,
|
||||
"score": {
|
||||
"pointsEarned": 80,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"gradeData": {
|
||||
"score": {
|
||||
"pointsEarned": 80,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"overallFeedback": "was okay",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "firstCriterion",
|
||||
"feedback": "did alright",
|
||||
"selectedOption": "good"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "unlocked"
|
||||
},
|
||||
"SUBMISSION_ID-3": {
|
||||
"submissionUUID": "SUBMISSION_ID-3",
|
||||
"username": "USERNAME-3",
|
||||
"dateSubmitted": 1631474354955,
|
||||
"score": null,
|
||||
"gradeData": null,
|
||||
"gradeStatus": "ungraded",
|
||||
"lockStatus": "in-progress"
|
||||
},
|
||||
"SUBMISSION_ID-4": {
|
||||
"submissionUUID": "SUBMISSION_ID-4",
|
||||
"username": "USERNAME-4",
|
||||
"dateSubmitted": 1631474354955,
|
||||
"score": null,
|
||||
"gradeData": null,
|
||||
"gradeStatus": "ungraded",
|
||||
"lockStatus": "locked"
|
||||
}
|
||||
},
|
||||
"block-b": {
|
||||
"TEAM_SUBMISSION_ID-0": {
|
||||
"submissionUUID": "TEAM_SUBMISSION_ID-0",
|
||||
"teamName": "TEAMNAME-0",
|
||||
"dateSubmitted": 1631215154955,
|
||||
"score": null,
|
||||
"gradeData": null,
|
||||
"gradeStatus": "ungraded",
|
||||
"lockStatus": "in-progress"
|
||||
},
|
||||
"TEAM_SUBMISSION_ID-1": {
|
||||
"submissionUUID": "TEAM_SUBMISSION_ID-1",
|
||||
"teamName": "TEAMNAME-1",
|
||||
"dateSubmitted": 1631301554955,
|
||||
"score": {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"gradeData": {
|
||||
"score": {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"overallFeedback": "was okay",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "firstCriterion",
|
||||
"feedback": "did alright",
|
||||
"selectedOption": "good"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "locked"
|
||||
},
|
||||
"TEAM_SUBMISSION_ID-2": {
|
||||
"submissionUUID": "TEAM_SUBMISSION_ID-2",
|
||||
"teamName": "TEAMNAME-2",
|
||||
"dateSubmitted": 1631387954955,
|
||||
"score": {
|
||||
"pointsEarned": 80,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"gradeData": {
|
||||
"score": {
|
||||
"pointsEarned": 80,
|
||||
"pointsPossible": 100
|
||||
},
|
||||
"overallFeedback": "was okay",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "firstCriterion",
|
||||
"feedback": "did alright",
|
||||
"selectedOption": "good"
|
||||
}
|
||||
]
|
||||
},
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "unlocked"
|
||||
},
|
||||
"TEAM_SUBMISSION_ID-3": {
|
||||
"submissionUUID": "TEAM_SUBMISSION_ID-3",
|
||||
"teamName": "TEAMNAME-3",
|
||||
"dateSubmitted": 1631474354955,
|
||||
"score": null,
|
||||
"gradeData": null,
|
||||
"gradeStatus": "ungraded",
|
||||
"lockStatus": "in-progress"
|
||||
},
|
||||
"TEAM_SUBMISSION_ID-4": {
|
||||
"submissionUUID": "TEAM_SUBMISSION_ID-4",
|
||||
"teamName": "TEAMNAME-4",
|
||||
"dateSubmitted": 1631474354955,
|
||||
"score": null,
|
||||
"gradeData": null,
|
||||
"gradeStatus": "ungraded",
|
||||
"lockStatus": "locked"
|
||||
}
|
||||
}
|
||||
}
|
||||
30
lms/djangoapps/ora_staff_grader/mock/urls.py
Normal file
30
lms/djangoapps/ora_staff_grader/mock/urls.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
URLs for Enhanced Staff Grader (ESG) backend-for-frontend (BFF)
|
||||
|
||||
NOTE - This should be the same as ../urls.py
|
||||
"""
|
||||
from django.urls import path
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.mock.views import (
|
||||
InitializeView,
|
||||
SubmissionStatusFetchView,
|
||||
SubmissionLockView,
|
||||
UpdateGradeView,
|
||||
SubmissionFetchView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = []
|
||||
app_name = "mock-ora-staff-grader"
|
||||
|
||||
urlpatterns += [
|
||||
path("initialize", InitializeView.as_view(), name="initialize"),
|
||||
path(
|
||||
"submission/status",
|
||||
SubmissionStatusFetchView.as_view(),
|
||||
name="fetch-submission-status",
|
||||
),
|
||||
path("submission/lock", SubmissionLockView.as_view(), name="lock-submission"),
|
||||
path("submission/grade", UpdateGradeView.as_view(), name="update-grade"),
|
||||
path("submission", SubmissionFetchView.as_view(), name="fetch-submission"),
|
||||
]
|
||||
79
lms/djangoapps/ora_staff_grader/mock/utils.py
Normal file
79
lms/djangoapps/ora_staff_grader/mock/utils.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Mocking/testing utils for ESG
|
||||
"""
|
||||
import json
|
||||
|
||||
from os import path
|
||||
|
||||
|
||||
DATA_ROOT = "/edx/app/edxapp/edx-platform/lms/djangoapps/ora_staff_grader/mock/data"
|
||||
|
||||
|
||||
def read_data_file(file_name):
|
||||
"""Return data from a JSON file in the /data dir"""
|
||||
with open(path.join(DATA_ROOT, file_name), "r") as data_file:
|
||||
return json.load(data_file)
|
||||
|
||||
|
||||
def update_data_file(file_name, update_key_path, update_value):
|
||||
"""
|
||||
Update a single key/value within a JSON file
|
||||
|
||||
params:
|
||||
- file_name: string path of file relative to DATA_ROOT
|
||||
- update_key_path: array-like list of keys to traverse, necessary for editing multiple levels down in hierarchy
|
||||
"""
|
||||
update_data = read_data_file(file_name)
|
||||
|
||||
# Adapted from https://ipindersinghsuri.medium.com/updating-dynamic-nested-dictionary-in-python-92f5afbd1755
|
||||
def get_updated_dict(dict_to_update, key_list, value):
|
||||
obj = dict_to_update
|
||||
|
||||
for k in key_list[:-1]:
|
||||
obj = obj[k]
|
||||
|
||||
obj[key_list[-1]] = value
|
||||
|
||||
get_updated_dict(update_data, update_key_path, update_value)
|
||||
|
||||
with open(path.join(DATA_ROOT, file_name), "w") as data_file:
|
||||
json.dump(update_data, data_file, indent=4)
|
||||
|
||||
|
||||
def get_course_metadata(ora_location):
|
||||
"""Get course metadata, indexed by ORA block location"""
|
||||
return read_data_file("course_metadata.json")[ora_location]
|
||||
|
||||
|
||||
def get_ora_metadata(ora_location):
|
||||
"""Get ORA metadata, indexed by ORA block location"""
|
||||
return read_data_file("ora_metadata.json")[ora_location]
|
||||
|
||||
|
||||
def get_submissions(ora_location): # pylint: disable=unused-argument
|
||||
"""Get Submission list, scoped by ORA block location"""
|
||||
submissions = read_data_file("submissions.json")[ora_location]
|
||||
|
||||
# For the list view, we don't return grade data
|
||||
# pylint: disable=unused-variable
|
||||
for (submission_id, submission) in submissions.items():
|
||||
submission.pop("gradeData")
|
||||
|
||||
return submissions
|
||||
|
||||
|
||||
def fetch_submission(ora_location, submission_id):
|
||||
"""Fetch an individual submission, indexed first by ORA block location then Submission ID"""
|
||||
return read_data_file("submissions.json")[ora_location].get(submission_id)
|
||||
|
||||
|
||||
def fetch_response(submission_id): # pylint: disable=unused-argument
|
||||
"""Return a default response, the same for all submissions"""
|
||||
return read_data_file("responses.json").get(submission_id)
|
||||
|
||||
|
||||
def save_submission_update(ora_location, submission):
|
||||
"""Update a submission key with new data"""
|
||||
update_data_file(
|
||||
"submissions.json", [ora_location, submission["submissionUUID"]], submission
|
||||
)
|
||||
135
lms/djangoapps/ora_staff_grader/mock/views.py
Normal file
135
lms/djangoapps/ora_staff_grader/mock/views.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Mock views for ESG
|
||||
"""
|
||||
|
||||
# pylint: disable=arguments-differ
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.generics import RetrieveAPIView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.mock.utils import (
|
||||
get_course_metadata,
|
||||
get_ora_metadata,
|
||||
get_submissions,
|
||||
fetch_submission,
|
||||
fetch_response,
|
||||
save_submission_update,
|
||||
)
|
||||
|
||||
PARAM_ORA_LOCATION = "oraLocation"
|
||||
PRAM_SUBMISSION_ID = "submissionUUID"
|
||||
|
||||
|
||||
class InitializeView(RetrieveAPIView):
|
||||
"""Returns initial app state"""
|
||||
|
||||
def get(self, request):
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
|
||||
return Response(
|
||||
{
|
||||
"courseMetadata": get_course_metadata(ora_location),
|
||||
"oraMetadata": get_ora_metadata(ora_location),
|
||||
"submissions": get_submissions(ora_location),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SubmissionFetchView(RetrieveAPIView):
|
||||
"""Get a submission"""
|
||||
|
||||
def get(self, request):
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
submission_id = request.query_params[PRAM_SUBMISSION_ID]
|
||||
|
||||
submission = fetch_submission(ora_location, submission_id)
|
||||
response = fetch_response(submission_id)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"gradeData": submission["gradeData"],
|
||||
"response": response,
|
||||
"gradeStatus": submission["gradeStatus"],
|
||||
"lockStatus": submission["lockStatus"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SubmissionStatusFetchView(RetrieveAPIView):
|
||||
"""Get a submission status, leaving out the response"""
|
||||
|
||||
def get(self, request):
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
submission_id = request.query_params[PRAM_SUBMISSION_ID]
|
||||
|
||||
submission = fetch_submission(ora_location, submission_id)
|
||||
|
||||
return Response(
|
||||
{
|
||||
"gradeStatus": submission["gradeStatus"],
|
||||
"lockStatus": submission["lockStatus"],
|
||||
"gradeData": submission["gradeData"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SubmissionLockView(APIView):
|
||||
"""Lock a submission for grading"""
|
||||
|
||||
def post(self, request):
|
||||
"""Claim a submission lock, updating lock status"""
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
submission_id = request.query_params[PRAM_SUBMISSION_ID]
|
||||
|
||||
submission = fetch_submission(ora_location, submission_id)
|
||||
submission["lockStatus"] = "in-progress"
|
||||
|
||||
save_submission_update(ora_location, submission)
|
||||
|
||||
return Response({"lockStatus": submission["lockStatus"]})
|
||||
|
||||
def delete(self, request):
|
||||
"""Delete a submission lock, updating lock status"""
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
submission_id = request.query_params[PRAM_SUBMISSION_ID]
|
||||
|
||||
submission = fetch_submission(ora_location, submission_id)
|
||||
submission["lockStatus"] = "unlocked"
|
||||
|
||||
save_submission_update(ora_location, submission)
|
||||
|
||||
return Response({"lockStatus": submission["lockStatus"]})
|
||||
|
||||
|
||||
class UpdateGradeView(RetrieveAPIView):
|
||||
"""Submit a grade"""
|
||||
|
||||
def update_grade_data(self, submission, grade_data):
|
||||
"""Mutate grade shape and add a mock score"""
|
||||
submission["gradeData"] = grade_data
|
||||
submission["gradeStatus"] = "graded"
|
||||
submission["lockStatus"] = "unlocked"
|
||||
submission["score"] = {
|
||||
"pointsEarned": 70,
|
||||
"pointsPossible": 100,
|
||||
}
|
||||
|
||||
def post(self, request):
|
||||
"""Save a grade update to the data store"""
|
||||
ora_location = request.query_params[PARAM_ORA_LOCATION]
|
||||
submission_id = request.query_params[PRAM_SUBMISSION_ID]
|
||||
grade_data = request.data
|
||||
|
||||
# this is static test data
|
||||
grade_data["score"] = {"pointsEarned": 70, "pointsPossible": 100}
|
||||
|
||||
submission = fetch_submission(ora_location, submission_id)
|
||||
self.update_grade_data(submission, grade_data)
|
||||
save_submission_update(ora_location, submission)
|
||||
return Response(
|
||||
{
|
||||
"gradeStatus": submission["gradeStatus"],
|
||||
"lockStatus": submission["lockStatus"],
|
||||
"gradeData": grade_data,
|
||||
}
|
||||
)
|
||||
169
lms/djangoapps/ora_staff_grader/ora_api.py
Normal file
169
lms/djangoapps/ora_staff_grader/ora_api.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
Functions used for interacting with ORA
|
||||
|
||||
All XBlock handlers are wrapped to return:
|
||||
- Data, on success
|
||||
- Exception, on failure
|
||||
|
||||
Some XBlock handlers natively return error codes for errors.
|
||||
These are caught by status code and raise XBlockInternalError by convention.
|
||||
|
||||
Other handlers return status OK even for an error, but contain error info in the returned payload.
|
||||
These are checked (usually by checking for a {"success":false} response) and raise errors, possibly with extra context.
|
||||
"""
|
||||
import json
|
||||
from lms.djangoapps.ora_staff_grader.errors import (
|
||||
LockContestedError,
|
||||
XBlockInternalError,
|
||||
)
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.utils import call_xblock_json_handler
|
||||
|
||||
|
||||
def get_submissions(request, usage_id):
|
||||
"""
|
||||
Get a list of submissions from the ORA's 'list_staff_workflows' XBlock.json_handler
|
||||
"""
|
||||
handler_name = "list_staff_workflows"
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, {})
|
||||
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
|
||||
|
||||
def get_rubric_config(request, usage_id):
|
||||
"""
|
||||
Get rubric data from the ORA's 'get_rubric' XBlock.json_handler
|
||||
"""
|
||||
handler_name = "get_rubric"
|
||||
data = {"target_rubric_block_id": usage_id}
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, data)
|
||||
|
||||
# Unhandled errors might not be JSON, catch before loading
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
response_data = json.loads(response.content)
|
||||
|
||||
# Handled faillure still returns HTTP 200 but with 'success': False and supplied error message "msg"
|
||||
if not response_data.get("success", False):
|
||||
raise XBlockInternalError(
|
||||
context={"handler": handler_name, "msg": response_data.get("msg", "")}
|
||||
)
|
||||
|
||||
return response_data["rubric"]
|
||||
|
||||
|
||||
def get_submission_info(request, usage_id, submission_uuid):
|
||||
"""
|
||||
Get submission content from ORA 'get_submission_info' XBlock.json_handler
|
||||
"""
|
||||
handler_name = "get_submission_info"
|
||||
data = {"submission_uuid": submission_uuid}
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, data)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
|
||||
|
||||
def get_assessment_info(request, usage_id, submission_uuid):
|
||||
"""
|
||||
Get assessment data from ORA 'get_assessment_info' XBlock.json_handler
|
||||
"""
|
||||
handler_name = "get_assessment_info"
|
||||
data = {"submission_uuid": submission_uuid}
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, data)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
|
||||
|
||||
def submit_grade(request, usage_id, grade_data):
|
||||
"""
|
||||
Submit a grade for an assessment.
|
||||
|
||||
Returns: {'success': True/False, 'msg': err_msg}
|
||||
"""
|
||||
handler_name = "staff_assess"
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, grade_data)
|
||||
|
||||
# Unhandled errors might not be JSON, catch before loading
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
response_data = json.loads(response.content)
|
||||
|
||||
# Handled faillure still returns HTTP 200 but with 'success': False and supplied error message 'msg'
|
||||
if not response_data.get("success", False):
|
||||
raise XBlockInternalError(
|
||||
context={"handler": handler_name, "msg": response_data.get("msg", "")}
|
||||
)
|
||||
|
||||
return response_data
|
||||
|
||||
|
||||
def check_submission_lock(request, usage_id, submission_uuid):
|
||||
"""
|
||||
Look up lock info for the given submission by calling the ORA's 'check_submission_lock' XBlock.json_handler
|
||||
"""
|
||||
handler_name = "check_submission_lock"
|
||||
data = {"submission_uuid": submission_uuid}
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, data)
|
||||
|
||||
# Unclear that there would every be an error (except network/auth) but good to catch here
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
|
||||
|
||||
def claim_submission_lock(request, usage_id, submission_uuid):
|
||||
"""
|
||||
Attempt to claim a submission lock for grading.
|
||||
|
||||
Returns:
|
||||
- lockStatus (string) - One of ['not-locked', 'locked', 'in-progress']
|
||||
"""
|
||||
handler_name = "claim_submission_lock"
|
||||
body = {"submission_uuid": submission_uuid}
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, body)
|
||||
|
||||
# Lock contested returns a 403
|
||||
if response.status_code == 403:
|
||||
raise LockContestedError()
|
||||
|
||||
# Other errors should raise a blanket exception
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
|
||||
|
||||
def delete_submission_lock(request, usage_id, submission_uuid):
|
||||
"""
|
||||
Attempt to claim a submission lock for grading.
|
||||
|
||||
Returns:
|
||||
- lockStatus (string) - One of ['not-locked', 'locked', 'in-progress']
|
||||
"""
|
||||
handler_name = "delete_submission_lock"
|
||||
body = {"submission_uuid": submission_uuid}
|
||||
|
||||
# Return raw response to preserve HTTP status codes for failure states
|
||||
response = call_xblock_json_handler(request, usage_id, handler_name, body)
|
||||
|
||||
# Lock contested returns a 403
|
||||
if response.status_code == 403:
|
||||
raise LockContestedError()
|
||||
|
||||
# Other errors should raise a blanket exception
|
||||
if response.status_code != 200:
|
||||
raise XBlockInternalError(context={"handler": handler_name})
|
||||
|
||||
return json.loads(response.content)
|
||||
File diff suppressed because one or more lines are too long
304
lms/djangoapps/ora_staff_grader/serializers.py
Normal file
304
lms/djangoapps/ora_staff_grader/serializers.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""
|
||||
Serializers for Enhanced Staff Grader (ESG)
|
||||
"""
|
||||
# pylint: disable=abstract-method
|
||||
# pylint: disable=missing-function-docstring
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
|
||||
class GradeStatusField(serializers.ChoiceField):
|
||||
"""Field that can have the values ['graded' 'ungraded']"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs["choices"] = ["graded", "ungraded"]
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class LockStatusField(serializers.ChoiceField):
|
||||
"""Field that can have the values ['unlocked', 'locked', 'in-progress']"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs, choices=["unlocked", "locked", "in-progress"])
|
||||
|
||||
|
||||
class CourseMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serialize top-level info about a course, used for creating header in ESG
|
||||
"""
|
||||
|
||||
title = serializers.CharField(source="display_name")
|
||||
org = serializers.CharField(source="display_org_with_default")
|
||||
number = serializers.CharField(source="display_number_with_default")
|
||||
courseId = serializers.CharField(source="id")
|
||||
|
||||
class Meta:
|
||||
model = CourseOverview
|
||||
|
||||
fields = [
|
||||
"title",
|
||||
"org",
|
||||
"number",
|
||||
"courseId",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class RubricCriterionOptionsSerializer(serializers.Serializer):
|
||||
"""Serializer for selectable options in a rubric criterion"""
|
||||
|
||||
label = serializers.CharField()
|
||||
points = serializers.IntegerField()
|
||||
explanation = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
orderNum = serializers.IntegerField(source="order_num")
|
||||
|
||||
|
||||
class RubricCriterionSerializer(serializers.Serializer):
|
||||
"""Serializer for individual criteria in a rubric"""
|
||||
|
||||
label = serializers.CharField()
|
||||
prompt = serializers.CharField()
|
||||
feedback = serializers.ChoiceField(
|
||||
required=False, choices=["optional", "disabled", "required"], default="disabled"
|
||||
)
|
||||
name = serializers.CharField()
|
||||
orderNum = serializers.IntegerField(source="order_num")
|
||||
options = serializers.ListField(child=RubricCriterionOptionsSerializer())
|
||||
|
||||
|
||||
class RubricConfigSerializer(serializers.Serializer):
|
||||
"""Serializer for rubric config"""
|
||||
|
||||
feedbackPrompt = serializers.CharField(source="rubric_feedback_prompt")
|
||||
criteria = serializers.ListField(
|
||||
source="rubric_criteria", child=RubricCriterionSerializer()
|
||||
)
|
||||
|
||||
|
||||
class OpenResponseMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serialize ORA metadata, used for setting up views in ESG
|
||||
"""
|
||||
|
||||
name = serializers.CharField(source="display_name")
|
||||
prompts = serializers.ListField()
|
||||
type = serializers.SerializerMethodField()
|
||||
textResponseConfig = serializers.SerializerMethodField()
|
||||
fileUploadResponseConfig = serializers.SerializerMethodField()
|
||||
rubricConfig = RubricConfigSerializer(source="*")
|
||||
|
||||
def get_textResponseConfig(self, instance):
|
||||
return instance.text_response or "none"
|
||||
|
||||
def get_fileUploadResponseConfig(self, instance):
|
||||
return instance.file_upload_response or "none"
|
||||
|
||||
def get_type(self, instance):
|
||||
return "team" if instance.teams_enabled else "individual"
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
"name",
|
||||
"prompts",
|
||||
"type",
|
||||
"textResponseConfig",
|
||||
"fileUploadResponseConfig",
|
||||
"rubricConfig",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class ScoreField(serializers.Field):
|
||||
"""Returns None if score is not given for a submission"""
|
||||
|
||||
def to_representation(self, value):
|
||||
if ("pointsEarned" not in value) and ("pointsPossible" not in value):
|
||||
return None
|
||||
return ScoreSerializer(value).data
|
||||
|
||||
|
||||
class ScoreSerializer(serializers.Serializer):
|
||||
"""
|
||||
Score (points earned/possible) for use in SubmissionMetadataSerializer
|
||||
"""
|
||||
|
||||
pointsEarned = serializers.IntegerField(required=False)
|
||||
pointsPossible = serializers.IntegerField(required=False)
|
||||
|
||||
|
||||
class SubmissionMetadataSerializer(serializers.Serializer):
|
||||
"""
|
||||
Submission metadata for displaying submissions table in ESG
|
||||
"""
|
||||
|
||||
submissionUUID = serializers.CharField(source="submissionUuid")
|
||||
username = serializers.CharField(allow_null=True)
|
||||
teamName = serializers.CharField(allow_null=True)
|
||||
dateSubmitted = serializers.DateTimeField()
|
||||
dateGraded = serializers.DateTimeField(allow_null=True)
|
||||
gradedBy = serializers.CharField(allow_null=True)
|
||||
gradingStatus = GradeStatusField()
|
||||
lockStatus = LockStatusField()
|
||||
score = ScoreField()
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
"submissionUUID",
|
||||
"username",
|
||||
"teamName",
|
||||
"dateSubmitted",
|
||||
"dateGraded",
|
||||
"gradedBy",
|
||||
"gradingStatus",
|
||||
"lockStatus",
|
||||
"score",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class InitializeSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serialize info for the initialize call. Packages ORA, course, submission, and rubric data.
|
||||
"""
|
||||
|
||||
courseMetadata = CourseMetadataSerializer()
|
||||
oraMetadata = OpenResponseMetadataSerializer()
|
||||
submissions = serializers.DictField(child=SubmissionMetadataSerializer())
|
||||
|
||||
class Meta:
|
||||
fields = [
|
||||
"courseMetadata",
|
||||
"oraMetadata",
|
||||
"submissions",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class UploadedFileSerializer(serializers.Serializer):
|
||||
"""Serializer for a file uploaded as a part of a response"""
|
||||
|
||||
downloadUrl = serializers.URLField(source="download_url")
|
||||
description = serializers.CharField()
|
||||
name = serializers.CharField()
|
||||
size = serializers.IntegerField()
|
||||
|
||||
|
||||
class ResponseSerializer(serializers.Serializer):
|
||||
"""Serializer for the responseData api construct, which represents the contents of a submitted learner response"""
|
||||
|
||||
files = serializers.ListField(child=UploadedFileSerializer(), allow_empty=True)
|
||||
text = serializers.ListField(child=serializers.CharField(), allow_empty=True)
|
||||
|
||||
|
||||
class AssessmentCriteriaSerializer(serializers.Serializer):
|
||||
"""Serializer for information about a criterion, in the context of a completed assessment"""
|
||||
|
||||
name = serializers.CharField()
|
||||
feedback = serializers.CharField()
|
||||
points = serializers.IntegerField()
|
||||
selectedOption = serializers.CharField(source="option")
|
||||
|
||||
|
||||
class GradeDataSerializer(serializers.Serializer):
|
||||
"""Serializer for the `gradeData` api construct, which represents a completed staff assessment"""
|
||||
|
||||
score = ScoreField(required=False)
|
||||
overallFeedback = serializers.CharField(source="feedback", required=False)
|
||||
criteria = serializers.ListField(
|
||||
child=AssessmentCriteriaSerializer(), allow_empty=True, required=False
|
||||
)
|
||||
|
||||
|
||||
class SubmissionStatusFetchSerializer(serializers.Serializer):
|
||||
"""Serializer for the response from the submission status fetch endpoint"""
|
||||
|
||||
gradeData = GradeDataSerializer(source="assessment_info")
|
||||
gradeStatus = serializers.SerializerMethodField()
|
||||
lockStatus = LockStatusField(source="lock_info.lock_status")
|
||||
|
||||
def get_gradeStatus(self, obj):
|
||||
if not obj.get("assessment_info", {}) == {}:
|
||||
return "graded"
|
||||
else:
|
||||
return "ungraded"
|
||||
|
||||
|
||||
class SubmissionFetchSerializer(SubmissionStatusFetchSerializer):
|
||||
"""
|
||||
Serializer for the response from the submission fetch endpoint
|
||||
Same as the SubmissionStatusFetchSerializer with an added submission_info field
|
||||
"""
|
||||
|
||||
response = ResponseSerializer(source="submission_info")
|
||||
|
||||
|
||||
class LockStatusSerializer(serializers.Serializer):
|
||||
"""
|
||||
Info about the status of a submission lock, with extra metadata stripped out.
|
||||
"""
|
||||
|
||||
lockStatus = LockStatusField(source="lock_status")
|
||||
|
||||
class Meta:
|
||||
fields = ["lockStatus"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class StaffAssessSerializer(serializers.Serializer):
|
||||
"""
|
||||
Converts grade data to the format used for doing staff assessments
|
||||
|
||||
From: {
|
||||
"overallFeedback": "was pretty good",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "<criterion_name_1>",
|
||||
"feedback": (string),
|
||||
"selectedOption": <selected_option_name>
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
To: {
|
||||
'options_selected': {
|
||||
'<criterion_name_1>': <selected_option_name>,
|
||||
'<criterion_name_2>': <selected_option_name>,
|
||||
},
|
||||
'criterion_feedback': {
|
||||
'<criterion_label_1>': (string)
|
||||
},
|
||||
'overall_feedback': (string)
|
||||
'submission_uuid': (string)
|
||||
'assess_type': (string) one of ['regrade', full-grade']
|
||||
}
|
||||
"""
|
||||
|
||||
# Context should include 'submission_uuid' for serialization
|
||||
requires_context = True
|
||||
|
||||
options_selected = serializers.SerializerMethodField()
|
||||
criterion_feedback = serializers.SerializerMethodField()
|
||||
overall_feedback = serializers.CharField(source="overallFeedback", allow_null=True)
|
||||
submission_uuid = serializers.SerializerMethodField()
|
||||
assess_type = serializers.CharField(default="full-grade")
|
||||
|
||||
def get_options_selected(self, instance):
|
||||
options_selected = {}
|
||||
for criterion in instance.get("criteria"):
|
||||
options_selected[criterion["name"]] = criterion["selectedOption"]
|
||||
|
||||
return options_selected
|
||||
|
||||
def get_criterion_feedback(self, instance):
|
||||
criterion_feedback = {}
|
||||
for criterion in instance.get("criteria"):
|
||||
if criterion.get("feedback"):
|
||||
criterion_feedback[criterion["name"]] = criterion["feedback"]
|
||||
|
||||
return criterion_feedback
|
||||
|
||||
def get_submission_uuid(self, instance): # pylint: disable=unused-argument
|
||||
return self.context.get("submission_uuid")
|
||||
0
lms/djangoapps/ora_staff_grader/tests/__init__.py
Normal file
0
lms/djangoapps/ora_staff_grader/tests/__init__.py
Normal file
178
lms/djangoapps/ora_staff_grader/tests/test_data.py
Normal file
178
lms/djangoapps/ora_staff_grader/tests/test_data.py
Normal file
@@ -0,0 +1,178 @@
|
||||
""" Data shapes used for testing ESG """
|
||||
|
||||
# Options split for reuse
|
||||
example_rubric_options = [
|
||||
{
|
||||
"order_num": 0,
|
||||
"name": "troll",
|
||||
"label": "Troll",
|
||||
"explanation": "Failing grade",
|
||||
"points": 0,
|
||||
},
|
||||
{
|
||||
"order_num": 1,
|
||||
"name": "dreadful",
|
||||
"label": "Dreadful",
|
||||
"explanation": "Failing grade",
|
||||
"points": 1,
|
||||
},
|
||||
{
|
||||
"order_num": 2,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Failing grade (may repeat)",
|
||||
"points": 2,
|
||||
},
|
||||
{
|
||||
"order_num": 3,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Failing grade (may repeat)",
|
||||
"points": 3,
|
||||
},
|
||||
{
|
||||
"order_num": 4,
|
||||
"name": "acceptable",
|
||||
"label": "Acceptable",
|
||||
"explanation": "Passing grade (may continue to N.E.W.T)",
|
||||
"points": 4,
|
||||
},
|
||||
{
|
||||
"order_num": 5,
|
||||
"name": "exceeds_expectations",
|
||||
"label": "Exceeds Expectations",
|
||||
"explanation": "Passing grade (may continue to N.E.W.T)",
|
||||
"points": 5,
|
||||
},
|
||||
{
|
||||
"order_num": 6,
|
||||
"name": "outstanding",
|
||||
"label": "Outstanding",
|
||||
"explanation": "Passing grade (will continue to N.E.W.T)",
|
||||
"points": 6,
|
||||
},
|
||||
]
|
||||
|
||||
example_rubric_options_serialized = [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "troll",
|
||||
"label": "Troll",
|
||||
"explanation": "Failing grade",
|
||||
"points": 0,
|
||||
},
|
||||
{
|
||||
"orderNum": 1,
|
||||
"name": "dreadful",
|
||||
"label": "Dreadful",
|
||||
"explanation": "Failing grade",
|
||||
"points": 1,
|
||||
},
|
||||
{
|
||||
"orderNum": 2,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Failing grade (may repeat)",
|
||||
"points": 2,
|
||||
},
|
||||
{
|
||||
"orderNum": 3,
|
||||
"name": "poor",
|
||||
"label": "Poor",
|
||||
"explanation": "Failing grade (may repeat)",
|
||||
"points": 3,
|
||||
},
|
||||
{
|
||||
"orderNum": 4,
|
||||
"name": "acceptable",
|
||||
"label": "Acceptable",
|
||||
"explanation": "Passing grade (may continue to N.E.W.T)",
|
||||
"points": 4,
|
||||
},
|
||||
{
|
||||
"orderNum": 5,
|
||||
"name": "exceeds_expectations",
|
||||
"label": "Exceeds Expectations",
|
||||
"explanation": "Passing grade (may continue to N.E.W.T)",
|
||||
"points": 5,
|
||||
},
|
||||
{
|
||||
"orderNum": 6,
|
||||
"name": "outstanding",
|
||||
"label": "Outstanding",
|
||||
"explanation": "Passing grade (will continue to N.E.W.T)",
|
||||
"points": 6,
|
||||
},
|
||||
]
|
||||
|
||||
example_rubric = {
|
||||
"rubric_feedback_prompt": "How did this student do?",
|
||||
"rubric_feedback_default_text": "For the O.W.L exams, this student...",
|
||||
"rubric_criteria": [
|
||||
{
|
||||
"order_num": 0,
|
||||
"name": "potions",
|
||||
"label": "Potions",
|
||||
"prompt": "How did this student perform in the Potions exam",
|
||||
"feedback": "optional",
|
||||
"options": example_rubric_options,
|
||||
},
|
||||
{
|
||||
"order_num": 1,
|
||||
"name": "charms",
|
||||
"label": "Charms",
|
||||
"prompt": "How did this student perform in the Charms exam",
|
||||
"options": example_rubric_options,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
example_submission_list = {
|
||||
"b086331a-5c50-428a-8348-5a85e5029299": {
|
||||
"submissionUuid": "b086331a-5c50-428a-8348-5a85e5029299",
|
||||
"username": "buzz",
|
||||
"teamName": None,
|
||||
"dateSubmitted": "1969-07-16 13:32:00",
|
||||
"dateGraded": None,
|
||||
"gradedBy": None,
|
||||
"gradingStatus": "ungraded",
|
||||
"lockStatus": "unlocked",
|
||||
"score": {"pointsEarned": 0, "pointsPossible": 10},
|
||||
}
|
||||
}
|
||||
|
||||
example_submission = {
|
||||
"text": ["This is the answer"],
|
||||
"files": [
|
||||
{
|
||||
"name": "name_0",
|
||||
"description": "description_0",
|
||||
"download_url": "www.file_url.com/key_0",
|
||||
"size": 123455,
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
example_assessment = {
|
||||
"feedback": "Base Assessment Feedback",
|
||||
"score": {
|
||||
"pointsEarned": 5,
|
||||
"pointsPossible": 6,
|
||||
},
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Criterion 1",
|
||||
"option": "Three",
|
||||
"points": 3,
|
||||
"feedback": "Feedback 1",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
example_grade_data = {
|
||||
"overallFeedback": "was pretty good",
|
||||
"criteria": [
|
||||
{"name": "Ideas", "feedback": "did alright", "selectedOption": "Fair"},
|
||||
{"name": "Content", "selectedOption": "Excellent"},
|
||||
],
|
||||
}
|
||||
698
lms/djangoapps/ora_staff_grader/tests/test_serializers.py
Normal file
698
lms/djangoapps/ora_staff_grader/tests/test_serializers.py
Normal file
@@ -0,0 +1,698 @@
|
||||
"""
|
||||
Tests for ESG Serializers
|
||||
"""
|
||||
from unittest.mock import Mock, MagicMock, patch
|
||||
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.errors import ERR_UNKNOWN, ErrorSerializer
|
||||
from lms.djangoapps.ora_staff_grader.serializers import (
|
||||
AssessmentCriteriaSerializer,
|
||||
CourseMetadataSerializer,
|
||||
GradeDataSerializer,
|
||||
InitializeSerializer,
|
||||
LockStatusSerializer,
|
||||
LockStatusField,
|
||||
OpenResponseMetadataSerializer,
|
||||
ResponseSerializer,
|
||||
RubricConfigSerializer,
|
||||
ScoreField,
|
||||
ScoreSerializer,
|
||||
StaffAssessSerializer,
|
||||
SubmissionFetchSerializer,
|
||||
SubmissionStatusFetchSerializer,
|
||||
SubmissionMetadataSerializer,
|
||||
UploadedFileSerializer,
|
||||
)
|
||||
from lms.djangoapps.ora_staff_grader.tests import test_data
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import (
|
||||
CourseOverviewFactory,
|
||||
)
|
||||
|
||||
|
||||
class TestErrorSerializer(TestCase):
|
||||
"""Tests for error serialization"""
|
||||
|
||||
def test_no_error_code(self):
|
||||
# If no error code is provided, fall back to an unknown code
|
||||
input_data = {}
|
||||
data = ErrorSerializer(input_data).data
|
||||
|
||||
assert data == {"error": ERR_UNKNOWN}
|
||||
|
||||
def test_no_context(self):
|
||||
# The serializer may return just the error info
|
||||
input_data = {"error": "ERR_CODE"}
|
||||
data = ErrorSerializer(input_data).data
|
||||
|
||||
assert data == {"error": "ERR_CODE"}
|
||||
|
||||
def test_added_context(self):
|
||||
# The serializer may also add context which gets unpacked into the output
|
||||
input_data = {"error": "ERR_CODE"}
|
||||
added_context = {"a": "b", "c": {"d": ["e", "f"]}}
|
||||
data = ErrorSerializer(input_data, context=added_context).data
|
||||
|
||||
# Extra context should be added to the output
|
||||
assert data == {"error": "ERR_CODE", "a": "b", "c": {"d": ["e", "f"]}}
|
||||
|
||||
|
||||
class TestCourseMetadataSerializer(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for CourseMetadataSerializer
|
||||
"""
|
||||
|
||||
course_data = {
|
||||
"org": "Oxford",
|
||||
"display_name": "Introduction to Time Travel",
|
||||
"display_number_with_default": "TT101",
|
||||
"run": "2054",
|
||||
}
|
||||
|
||||
course_id = "course-v1:Oxford+TT101+2054"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.course_overview = CourseOverviewFactory.create(**self.course_data)
|
||||
|
||||
def test_course_serialize(self):
|
||||
data = CourseMetadataSerializer(self.course_overview).data
|
||||
|
||||
assert data == {
|
||||
"title": self.course_data["display_name"],
|
||||
"org": self.course_data["org"],
|
||||
"number": self.course_data["display_number_with_default"],
|
||||
"courseId": self.course_id,
|
||||
}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestOpenResponseMetadataSerializer(TestCase):
|
||||
"""
|
||||
Tests for OpenResponseMetadataSerializer
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.ora_data = {
|
||||
"display_name": "Week 1: Time Travel Paradoxes",
|
||||
"prompts": [
|
||||
"<p>In your own words, explain a famous time travel paradox</p>"
|
||||
],
|
||||
"teams_enabled": False,
|
||||
"text_response": None,
|
||||
"file_upload_response": None,
|
||||
**test_data.example_rubric,
|
||||
}
|
||||
|
||||
self.mock_ora_instance = Mock(name="openassessment-block", **self.ora_data)
|
||||
|
||||
def test_individual_ora(self):
|
||||
# An ORA with teams disabled should have type "individual"
|
||||
data = OpenResponseMetadataSerializer(self.mock_ora_instance).data
|
||||
|
||||
assert data == {
|
||||
"name": self.ora_data["display_name"],
|
||||
"prompts": self.ora_data["prompts"],
|
||||
"type": "individual",
|
||||
"textResponseConfig": "none",
|
||||
"fileUploadResponseConfig": "none",
|
||||
"rubricConfig": {
|
||||
"feedbackPrompt": "How did this student do?",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "potions",
|
||||
"label": "Potions",
|
||||
"prompt": "How did this student perform in the Potions exam",
|
||||
"feedback": "optional",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
{
|
||||
"orderNum": 1,
|
||||
"name": "charms",
|
||||
"label": "Charms",
|
||||
"prompt": "How did this student perform in the Charms exam",
|
||||
"feedback": "disabled",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
def test_team_ora(self):
|
||||
# An ORA with teams enabled should have type "team"
|
||||
self.mock_ora_instance.teams_enabled = True
|
||||
data = OpenResponseMetadataSerializer(self.mock_ora_instance).data
|
||||
|
||||
assert data == {
|
||||
"name": self.ora_data["display_name"],
|
||||
"prompts": self.ora_data["prompts"],
|
||||
"type": "team",
|
||||
"textResponseConfig": "none",
|
||||
"fileUploadResponseConfig": "none",
|
||||
"rubricConfig": {
|
||||
"feedbackPrompt": "How did this student do?",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "potions",
|
||||
"label": "Potions",
|
||||
"prompt": "How did this student perform in the Potions exam",
|
||||
"feedback": "optional",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
{
|
||||
"orderNum": 1,
|
||||
"name": "charms",
|
||||
"label": "Charms",
|
||||
"prompt": "How did this student perform in the Charms exam",
|
||||
"feedback": "disabled",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data(("optional", "optional"), ("required", "required"))
|
||||
def test_response_config(self, text_response, file_upload_response):
|
||||
self.mock_ora_instance.text_response = text_response
|
||||
self.mock_ora_instance.file_upload_response = file_upload_response
|
||||
|
||||
data = OpenResponseMetadataSerializer(self.mock_ora_instance).data
|
||||
|
||||
assert data["textResponseConfig"] == text_response
|
||||
assert data["fileUploadResponseConfig"] == file_upload_response
|
||||
|
||||
def test_response_config_none(self):
|
||||
self.mock_ora_instance.text_response = None
|
||||
self.mock_ora_instance.file_upload_response = None
|
||||
|
||||
data = OpenResponseMetadataSerializer(self.mock_ora_instance).data
|
||||
|
||||
assert data["textResponseConfig"] == "none"
|
||||
assert data["fileUploadResponseConfig"] == "none"
|
||||
|
||||
|
||||
class TestSubmissionMetadataSerializer(TestCase):
|
||||
"""
|
||||
Tests for SubmissionMetadataSerializer. Implicitly, this also exercises ScoreSerializer.
|
||||
SubmissionMetadata comes from the ORA list_staff_workflows XBlock.json_handler and has the shape:
|
||||
"<submission_uuid>": {
|
||||
"submissionUuid": "<submission_uuid>",
|
||||
"username": "<username/empty>",
|
||||
"teamName": "<team_name/empty>",
|
||||
"dateSubmitted": "<yyyy-mm-dd HH:MM:SS>",
|
||||
"dateGraded": "<yyyy-mm-dd HH:MM:SS/None>",
|
||||
"gradedBy": "<username/empty>",
|
||||
"gradingStatus": "<ungraded/graded>",
|
||||
"lockStatus": "<locked/unlocked/in-progress>",
|
||||
"score": {
|
||||
"pointsEarned": <num>,
|
||||
"pointsPossible": <num>
|
||||
}
|
||||
}
|
||||
Right now, this is just passed through with only one name transform
|
||||
"""
|
||||
|
||||
submission_data = {
|
||||
"a": {
|
||||
"submissionUuid": "a",
|
||||
"username": "foo",
|
||||
"teamName": "",
|
||||
"dateSubmitted": "1969-07-16 13:32:00",
|
||||
"dateGraded": "None",
|
||||
"gradedBy": "",
|
||||
"gradingStatus": "ungraded",
|
||||
"lockStatus": "unlocked",
|
||||
"score": {"pointsEarned": 0, "pointsPossible": 10},
|
||||
},
|
||||
"b": {
|
||||
"submissionUuid": "b",
|
||||
"username": "",
|
||||
"teamName": "bar",
|
||||
"dateSubmitted": "1969-07-20 20:17:40",
|
||||
"dateGraded": "None",
|
||||
"gradedBy": "",
|
||||
"gradingStatus": "ungraded",
|
||||
"lockStatus": "in-progress",
|
||||
"score": {"pointsEarned": 0, "pointsPossible": 10},
|
||||
},
|
||||
"c": {
|
||||
"submissionUuid": "c",
|
||||
"username": "baz",
|
||||
"teamName": "",
|
||||
"dateSubmitted": "1969-07-21 21:35:00",
|
||||
"dateGraded": "1969-07-24 16:44:00",
|
||||
"gradedBy": "buz",
|
||||
"gradingStatus": "graded",
|
||||
"lockStatus": "unlocked",
|
||||
"score": {"pointsEarned": 9, "pointsPossible": 10},
|
||||
},
|
||||
}
|
||||
|
||||
def test_submission_serialize(self):
|
||||
for submission_id, submission_data in self.submission_data.items():
|
||||
data = SubmissionMetadataSerializer(submission_data).data
|
||||
|
||||
# For each submission, the only transform is to change "submissionUuid" to "submissionUUID"
|
||||
# Create that "expected" object here by updating the key name
|
||||
expected_data = self.submission_data[submission_id].copy()
|
||||
expected_data["submissionUUID"] = expected_data.pop("submissionUuid")
|
||||
|
||||
assert data == expected_data
|
||||
|
||||
def test_empty_score(self):
|
||||
"""
|
||||
An empty score dict should be serialized as None
|
||||
"""
|
||||
submission = {
|
||||
"submissionUuid": "empty-score",
|
||||
"username": "WOPR",
|
||||
"dateSubmitted": "1983-06-03 00:00:00",
|
||||
"dateGraded": None,
|
||||
"gradedBy": None,
|
||||
"gradingStatus": "ungraded",
|
||||
"lockStatus": "unlocked",
|
||||
"score": {},
|
||||
}
|
||||
|
||||
expected_output = {
|
||||
"submissionUUID": "empty-score",
|
||||
"username": "WOPR",
|
||||
"teamName": None,
|
||||
"dateSubmitted": "1983-06-03 00:00:00",
|
||||
"dateGraded": None,
|
||||
"gradedBy": None,
|
||||
"gradingStatus": "ungraded",
|
||||
"lockStatus": "unlocked",
|
||||
"score": None,
|
||||
}
|
||||
|
||||
data = SubmissionMetadataSerializer(submission).data
|
||||
|
||||
assert data == expected_output
|
||||
|
||||
|
||||
class TestInitializeSerializer(TestCase):
|
||||
"""
|
||||
Tests for InitializeSerializer
|
||||
"""
|
||||
|
||||
def set_up_ora(self):
|
||||
"""Create a mock Open Repsponse Assessment for serialization"""
|
||||
ora_data = {
|
||||
"display_name": "Week 1: Time Travel Paradoxes",
|
||||
"prompts": [
|
||||
"<p>In your own words, explain a famous time travel paradox</p>"
|
||||
],
|
||||
"teams_enabled": False,
|
||||
}
|
||||
|
||||
# Add rubric data here for succinctness
|
||||
ora_data.update(test_data.example_rubric)
|
||||
return Mock(name="openassessment-block", **ora_data)
|
||||
|
||||
def set_up_course_metadata(self):
|
||||
"""Create mock course metadata for serialization"""
|
||||
course_org = "Oxford"
|
||||
course_name = "Introduction to Time Travel"
|
||||
course_number = "TT101"
|
||||
course_run = "2054"
|
||||
|
||||
return CourseOverviewFactory.create(
|
||||
org=course_org,
|
||||
display_name=course_name,
|
||||
display_number_with_default=course_number,
|
||||
run=course_run,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.mock_ora_instance = self.set_up_ora()
|
||||
self.mock_course_metadata = self.set_up_course_metadata()
|
||||
self.mock_submissions_data = test_data.example_submission_list.copy()
|
||||
|
||||
def test_serializer_output(self):
|
||||
input_data = {
|
||||
"courseMetadata": self.mock_course_metadata,
|
||||
"oraMetadata": self.mock_ora_instance,
|
||||
"submissions": self.mock_submissions_data,
|
||||
}
|
||||
|
||||
output_data = InitializeSerializer(input_data).data
|
||||
|
||||
expected_course_data = CourseMetadataSerializer(self.mock_course_metadata).data
|
||||
expected_ora_data = OpenResponseMetadataSerializer(self.mock_ora_instance).data
|
||||
expected_submissions_data = {}
|
||||
|
||||
# There's a level of unpacking that happens in the serializer, perform that here
|
||||
for submission_id, submission_data in self.mock_submissions_data.items():
|
||||
serialized_data = SubmissionMetadataSerializer(submission_data).data
|
||||
expected_submissions_data[submission_id] = serialized_data
|
||||
|
||||
# Check that each of the sub-serializers assembles data correctly
|
||||
assert output_data["courseMetadata"] == expected_course_data
|
||||
assert output_data["oraMetadata"] == expected_ora_data
|
||||
assert output_data["submissions"] == expected_submissions_data
|
||||
|
||||
|
||||
class TestRubricConfigSerializer(TestCase):
|
||||
"""Tests for RubricConfigSerializer"""
|
||||
|
||||
def basic_test_case(self):
|
||||
"""Basic test for complex rubric"""
|
||||
assert RubricConfigSerializer(test_data.example_rubric).data == {
|
||||
"feedbackPrompt": "How did this student do?",
|
||||
"criteria": [
|
||||
{
|
||||
"orderNum": 0,
|
||||
"name": "potions",
|
||||
"label": "Potions",
|
||||
"prompt": "How did this student perform in the Potions exam",
|
||||
"feedback": "optional",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
{
|
||||
"order_num": 1,
|
||||
"name": "charms",
|
||||
"label": "Charms",
|
||||
"prompt": "How did this student perform in the Charms exam",
|
||||
"feedback": "disabled",
|
||||
"options": test_data.example_rubric_options_serialized,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestScoreFieldAndSerializer(TestCase):
|
||||
"""Tests for ScoreField and ScoreSerializer"""
|
||||
|
||||
def test_field_no_values(self):
|
||||
"""An empty dict passed to the field should return None"""
|
||||
assert ScoreField().to_representation({}) is None
|
||||
|
||||
@ddt.data("pointsEarned", "pointsPossible")
|
||||
def test_field_missing(self, missing_field):
|
||||
"""Missing fields should just be ignored"""
|
||||
value = {"pointsEarned": 30, "pointsPossible": 50}
|
||||
del value[missing_field]
|
||||
|
||||
assert ScoreField().to_representation(value) == value
|
||||
|
||||
def test_field(self):
|
||||
"""Base serialization behavior for ScoreField"""
|
||||
data = {"pointsEarned": 20, "pointsPossible": 40}
|
||||
representation = ScoreField().to_representation(data)
|
||||
assert representation == data
|
||||
|
||||
def test_serializer_no_values(self):
|
||||
"""Passing the ScoreSerializer an empty dict should result in an empty serializer"""
|
||||
# pylint: disable=use-implicit-booleaness-not-comparison
|
||||
assert ScoreSerializer({}).data == {}
|
||||
|
||||
def test_serialier(self):
|
||||
"""Base serialization behavior for ScoreSerializer"""
|
||||
input_data = {"pointsEarned": 10, "pointsPossible": 200}
|
||||
data = ScoreSerializer(input_data).data
|
||||
assert data == input_data
|
||||
|
||||
@ddt.data("pointsEarned", "pointsPossible")
|
||||
def test_serializer_missing_field(self, missing_field):
|
||||
"""Missing fields should just be ignored"""
|
||||
value = {"pointsEarned": 30, "pointsPossible": 50}
|
||||
del value[missing_field]
|
||||
|
||||
assert ScoreSerializer(value).data == value
|
||||
|
||||
|
||||
class TestUploadedFileSerializer(TestCase):
|
||||
"""Tests for UploadedFileSerializer"""
|
||||
|
||||
def test_uploaded_file_serializer(self):
|
||||
"""Base serialization behavior"""
|
||||
input_data = MagicMock(size=89794)
|
||||
data = UploadedFileSerializer(input_data).data
|
||||
|
||||
expected_value = {
|
||||
"downloadUrl": str(input_data.download_url),
|
||||
"description": str(input_data.description),
|
||||
"name": str(input_data.name),
|
||||
"size": input_data.size,
|
||||
}
|
||||
assert data == expected_value
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestResponseSerializer(TestCase):
|
||||
"""Tests for ResponseSerializer"""
|
||||
|
||||
def test_response_serializer__empty(self):
|
||||
"""Empty fields should be allowed"""
|
||||
input_data = {"files": [], "text": []}
|
||||
assert ResponseSerializer(input_data).data == input_data
|
||||
|
||||
@ddt.unpack
|
||||
@ddt.data((True, True), (True, False), (False, True), (False, False))
|
||||
def test_response_serializer(self, has_text, has_files):
|
||||
"""Base serialization behavior"""
|
||||
input_data = MagicMock()
|
||||
if has_files:
|
||||
input_data.files = [Mock(size=111), Mock(size=222), Mock(size=333)]
|
||||
if has_text:
|
||||
input_data.text = [Mock(), Mock(), Mock()]
|
||||
|
||||
data = ResponseSerializer(input_data).data
|
||||
expected_value = {
|
||||
"files": [
|
||||
UploadedFileSerializer(mock_file).data for mock_file in input_data.files
|
||||
]
|
||||
if has_files
|
||||
else [],
|
||||
"text": [str(mock_text) for mock_text in input_data.text]
|
||||
if has_text
|
||||
else [],
|
||||
}
|
||||
assert data == expected_value
|
||||
|
||||
|
||||
class TestAssessmentCriteriaSerializer(TestCase):
|
||||
"""Tests for AssessmentCriteriaSerializer"""
|
||||
|
||||
def test_assessment_criteria_serializer(self):
|
||||
"""Base serialization behavior"""
|
||||
input_data = Mock(points=595)
|
||||
data = AssessmentCriteriaSerializer(input_data).data
|
||||
|
||||
expected_value = {
|
||||
"name": str(input_data.name),
|
||||
"feedback": str(input_data.feedback),
|
||||
"points": input_data.points,
|
||||
"selectedOption": str(input_data.option),
|
||||
}
|
||||
assert data == expected_value
|
||||
|
||||
def test_assessment_criteria_serializer__feedback_only(self):
|
||||
"""Test for serialization behavior of a feedback-only criterion"""
|
||||
input_data = {
|
||||
"name": "SomeCriterioOn",
|
||||
"feedback": "Pathetic Effort",
|
||||
"points": None,
|
||||
"option": None,
|
||||
}
|
||||
data = AssessmentCriteriaSerializer(input_data).data
|
||||
|
||||
expected_value = dict(input_data)
|
||||
expected_value["selectedOption"] = expected_value["option"]
|
||||
del expected_value["option"]
|
||||
|
||||
assert data == expected_value
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestGradeDataSerializer(TestCase):
|
||||
"""Tests for GradeDataSerializer"""
|
||||
|
||||
def test_grade_data_serializer__no_assessment(self):
|
||||
"""Passing an empty dict should result in an empty dict"""
|
||||
# pylint: disable=use-implicit-booleaness-not-comparison
|
||||
assert GradeDataSerializer({}).data == {}
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_grade_data_serializer__assessment(self, has_criteria):
|
||||
"""Base serialization behavior, with and without criteria"""
|
||||
input_data = MagicMock()
|
||||
if has_criteria:
|
||||
input_data.criteria = [Mock(points=123), Mock(points=11), Mock(points=22)]
|
||||
data = GradeDataSerializer(input_data).data
|
||||
|
||||
expected_value = {
|
||||
"score": ScoreField().to_representation(input_data.score),
|
||||
"overallFeedback": str(input_data.feedback),
|
||||
}
|
||||
if has_criteria:
|
||||
expected_value["criteria"] = [
|
||||
AssessmentCriteriaSerializer(criterion).data
|
||||
for criterion in input_data.criteria
|
||||
]
|
||||
else:
|
||||
expected_value["criteria"] = []
|
||||
assert data == expected_value
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestSubmissionStatusFetchSerializer(TestCase):
|
||||
"""Tests for SubmissionStatusFetchSerializer"""
|
||||
|
||||
def test_submission_status_fetch_serializer(self):
|
||||
"""Base serialization behavior"""
|
||||
input_data = MagicMock()
|
||||
serializer = SubmissionStatusFetchSerializer(input_data)
|
||||
with patch.object(serializer, "get_gradeStatus") as mock_get_grade_status:
|
||||
data = serializer.data
|
||||
|
||||
expected_value = {
|
||||
"gradeData": GradeDataSerializer(input_data.assessment_info).data,
|
||||
"gradeStatus": mock_get_grade_status.return_value,
|
||||
"lockStatus": LockStatusField().to_representation(
|
||||
input_data.lock_info.lock_status
|
||||
),
|
||||
}
|
||||
mock_get_grade_status.assert_called_once_with(input_data)
|
||||
assert data == expected_value
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_get__gradeStatus(self, has_assessment):
|
||||
"""Unit test for get_gradeStatus"""
|
||||
assessment = {"somekey": "somevalue"} if has_assessment else {}
|
||||
input_data = {"assessment_info": assessment}
|
||||
value = SubmissionStatusFetchSerializer().get_gradeStatus(input_data)
|
||||
expected = "graded" if has_assessment else "ungraded"
|
||||
assert value == expected
|
||||
|
||||
|
||||
class TestSubmissionFetchSerializer(TestCase):
|
||||
"""Tests for the SubmissionFetchSerializer"""
|
||||
|
||||
def test_submission_fetch_serializer(self):
|
||||
"""Base serialization behavior"""
|
||||
input_data = MagicMock()
|
||||
serializer = SubmissionFetchSerializer(input_data)
|
||||
with patch.object(serializer, "get_gradeStatus") as mock_get_grade_status:
|
||||
data = serializer.data
|
||||
|
||||
expected_value = {
|
||||
"gradeData": GradeDataSerializer(input_data.assessment_info).data,
|
||||
"gradeStatus": mock_get_grade_status.return_value,
|
||||
"lockStatus": LockStatusField().to_representation(
|
||||
input_data.lock_info.lock_status
|
||||
),
|
||||
"response": ResponseSerializer(input_data.submission_info).data,
|
||||
}
|
||||
mock_get_grade_status.assert_called_once_with(input_data)
|
||||
assert data == expected_value
|
||||
|
||||
|
||||
class TestLockStatusSerializer(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Tests for LockStatusSerializer
|
||||
"""
|
||||
|
||||
lock_in_progress = {
|
||||
"submission_uuid": "e34ef789-a4b1-48cf-b1bc-b3edacfd4eb2",
|
||||
"owner_id": "10ab03f1b75b4f9d9ab13a1fd1dccca1",
|
||||
"created_at": "2021-09-21T21:54:09.901221Z",
|
||||
"lock_status": "in-progress",
|
||||
}
|
||||
|
||||
lock_in_progress_expected = {"lockStatus": "in-progress"}
|
||||
|
||||
lock_owned_by_other_user = {
|
||||
"submission_uuid": "e34ef789-a4b1-48cf-b1bc-b3edacfd4eb2",
|
||||
"owner_id": "10ab03f1b75b4f9d9ab13a1fd1dccca1",
|
||||
"created_at": "2021-09-21T21:54:09.901221Z",
|
||||
"lock_status": "locked",
|
||||
}
|
||||
|
||||
lock_owned_by_other_user_expected = {"lockStatus": "locked"}
|
||||
|
||||
course_id = "course-v1:Oxford+TT101+2054"
|
||||
|
||||
def test_happy_path(self):
|
||||
"""For simple cases, lock status is passed through directly"""
|
||||
data = LockStatusSerializer(self.lock_in_progress).data
|
||||
assert data == self.lock_in_progress_expected
|
||||
|
||||
data = LockStatusSerializer(self.lock_owned_by_other_user).data
|
||||
assert data == self.lock_owned_by_other_user_expected
|
||||
|
||||
|
||||
class TestStaffAssessSerializer(TestCase):
|
||||
"""Tests for StaffAssessSerializer"""
|
||||
|
||||
grade_data = {
|
||||
"overallFeedback": "was pretty good",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "firstCriterion",
|
||||
"feedback": "did alright",
|
||||
"selectedOption": "good",
|
||||
},
|
||||
{"name": "secondCriterion", "selectedOption": "fair"},
|
||||
],
|
||||
}
|
||||
|
||||
grade_data_no_feedback = {
|
||||
"overallFeedback": "",
|
||||
"criteria": [
|
||||
{"name": "firstCriterion", "selectedOption": "good"},
|
||||
{"name": "secondCriterion", "selectedOption": "fair"},
|
||||
],
|
||||
}
|
||||
|
||||
submission_uuid = "foo"
|
||||
|
||||
def test_staff_assess_serializer(self):
|
||||
"""Base serialization behavior"""
|
||||
context = {"submission_uuid": self.submission_uuid}
|
||||
serializer = StaffAssessSerializer(self.grade_data, context=context)
|
||||
|
||||
expected_value = {
|
||||
"options_selected": {
|
||||
"firstCriterion": "good",
|
||||
"secondCriterion": "fair",
|
||||
},
|
||||
"criterion_feedback": {
|
||||
"firstCriterion": "did alright",
|
||||
},
|
||||
"overall_feedback": "was pretty good",
|
||||
"submission_uuid": self.submission_uuid,
|
||||
"assess_type": "full-grade",
|
||||
}
|
||||
|
||||
assert serializer.data == expected_value
|
||||
|
||||
def test_staff_assess_no_feedback(self):
|
||||
"""Verify that empty feedback returns a reasonable shape"""
|
||||
context = {"submission_uuid": self.submission_uuid}
|
||||
serializer = StaffAssessSerializer(self.grade_data_no_feedback, context=context)
|
||||
|
||||
expected_value = {
|
||||
"options_selected": {
|
||||
"firstCriterion": "good",
|
||||
"secondCriterion": "fair",
|
||||
},
|
||||
"criterion_feedback": {},
|
||||
"overall_feedback": "",
|
||||
"submission_uuid": self.submission_uuid,
|
||||
"assess_type": "full-grade",
|
||||
}
|
||||
|
||||
assert serializer.data == expected_value
|
||||
697
lms/djangoapps/ora_staff_grader/tests/test_views.py
Normal file
697
lms/djangoapps/ora_staff_grader/tests/test_views.py
Normal file
@@ -0,0 +1,697 @@
|
||||
"""
|
||||
Tests for ESG views
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import ddt
|
||||
from django.http import QueryDict
|
||||
from django.urls import reverse
|
||||
from rest_framework.test import APITestCase
|
||||
from xmodule.modulestore.tests.django_utils import (
|
||||
TEST_DATA_SPLIT_MODULESTORE,
|
||||
SharedModuleStoreTestCase,
|
||||
)
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.tests.factories import ItemFactory
|
||||
|
||||
from common.djangoapps.student.tests.factories import StaffFactory
|
||||
from lms.djangoapps.ora_staff_grader.constants import (
|
||||
ERR_BAD_ORA_LOCATION,
|
||||
ERR_GRADE_CONTESTED,
|
||||
ERR_INTERNAL,
|
||||
ERR_LOCK_CONTESTED,
|
||||
ERR_MISSING_PARAM,
|
||||
ERR_UNKNOWN,
|
||||
PARAM_ORA_LOCATION,
|
||||
PARAM_SUBMISSION_ID,
|
||||
)
|
||||
from lms.djangoapps.ora_staff_grader.errors import (
|
||||
LockContestedError,
|
||||
XBlockInternalError,
|
||||
)
|
||||
import lms.djangoapps.ora_staff_grader.tests.test_data as test_data
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import (
|
||||
CourseOverviewFactory,
|
||||
)
|
||||
|
||||
|
||||
class BaseViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
"""Base class for shared test utils and setup"""
|
||||
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.api_url = reverse(cls.view_name)
|
||||
|
||||
cls.course = CourseFactory.create()
|
||||
cls.course_key = cls.course.location.course_key
|
||||
|
||||
cls.ora_block = ItemFactory.create(
|
||||
category="openassessment",
|
||||
parent_location=cls.course.location,
|
||||
display_name="test",
|
||||
)
|
||||
cls.ora_usage_key = str(cls.ora_block.location)
|
||||
|
||||
cls.password = "password"
|
||||
cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password)
|
||||
|
||||
def log_in(self):
|
||||
"""Log in as staff"""
|
||||
self.client.login(username=self.staff.username, password=self.password)
|
||||
|
||||
def url_with_params(self, params):
|
||||
"""For DRF client.posts, you can't add query params easily. This helper adds it to the request URL"""
|
||||
query_dictionary = QueryDict("", mutable=True)
|
||||
query_dictionary.update(params)
|
||||
|
||||
return "{base_url}?{querystring}".format(
|
||||
base_url=reverse(self.view_name), querystring=query_dictionary.urlencode()
|
||||
)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestInitializeView(BaseViewTest):
|
||||
"""
|
||||
Tests for the /initialize view, creating setup data for ESG
|
||||
"""
|
||||
|
||||
view_name = "ora-staff-grader:initialize"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
@ddt.data({}, {PARAM_ORA_LOCATION: ""})
|
||||
def test_missing_param(self, query_params):
|
||||
"""Missing ORA location param should return 400 and error message"""
|
||||
response = self.client.get(self.api_url, query_params)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.content) == {"error": ERR_MISSING_PARAM}
|
||||
|
||||
def test_bad_ora_location(self):
|
||||
"""Bad ORA location should return a 400 and error message"""
|
||||
response = self.client.get(
|
||||
self.api_url, {PARAM_ORA_LOCATION: "not_a_real_location"}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.content) == {"error": ERR_BAD_ORA_LOCATION}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submissions")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_course_overview_or_none")
|
||||
def test_init(self, mock_get_course_overview, mock_get_submissions):
|
||||
"""Any failure to fetch info returns an unknown error response"""
|
||||
mock_course_overview = CourseOverviewFactory.create()
|
||||
mock_get_course_overview.return_value = mock_course_overview
|
||||
mock_get_submissions.return_value = test_data.example_submission_list
|
||||
|
||||
response = self.client.get(
|
||||
self.api_url, {PARAM_ORA_LOCATION: self.ora_usage_key}
|
||||
)
|
||||
|
||||
expected_keys = set(["courseMetadata", "oraMetadata", "submissions"])
|
||||
assert response.status_code == 200
|
||||
assert response.data.keys() == expected_keys
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submissions")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_course_overview_or_none")
|
||||
def test_init_xblock_exception(
|
||||
self, mock_get_course_overview, mock_get_submissions
|
||||
):
|
||||
"""If one of the XBlock handlers fails, the exception should be caught"""
|
||||
mock_course_overview = CourseOverviewFactory.create()
|
||||
mock_get_course_overview.return_value = mock_course_overview
|
||||
# Mock an error getting submissions
|
||||
mock_get_submissions.side_effect = XBlockInternalError(
|
||||
context={"handler": "list_staff_workflows"}
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
self.api_url, {PARAM_ORA_LOCATION: self.ora_usage_key}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "list_staff_workflows",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submissions")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_course_overview_or_none")
|
||||
def test_init_generic_exception(
|
||||
self, mock_get_course_overview, mock_get_submissions
|
||||
):
|
||||
"""If something else strange fails (e.g. bad data shape), an "unknown" error should be surfaced"""
|
||||
mock_course_overview = CourseOverviewFactory.create()
|
||||
mock_get_course_overview.return_value = mock_course_overview
|
||||
# Mock a bad returned data shape which would break serialization
|
||||
mock_get_submissions.return_value = {"bad": "wolf"}
|
||||
|
||||
response = self.client.get(
|
||||
self.api_url, {PARAM_ORA_LOCATION: self.ora_usage_key}
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestFetchSubmissionView(BaseViewTest):
|
||||
"""
|
||||
Tests for the submission fetch view
|
||||
"""
|
||||
|
||||
view_name = "ora-staff-grader:fetch-submission"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
@ddt.data({}, {PARAM_ORA_LOCATION: "", PARAM_SUBMISSION_ID: ""})
|
||||
def test_missing_params(self, query_params):
|
||||
"""Missing or blank params should return 400 and error message"""
|
||||
response = self.client.get(self.api_url, query_params)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.content) == {"error": ERR_MISSING_PARAM}
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submission_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission(
|
||||
self,
|
||||
has_assessment,
|
||||
mock_check_submission_lock,
|
||||
mock_get_assessment_info,
|
||||
mock_get_submission_info,
|
||||
):
|
||||
"""Successfull submission fetch status returns submission, lock, and grade data"""
|
||||
mock_get_submission_info.return_value = test_data.example_submission
|
||||
mock_get_assessment_info.return_value = (
|
||||
{} if not has_assessment else test_data.example_assessment
|
||||
)
|
||||
mock_check_submission_lock.return_value = {"lock_status": "unlocked"}
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data.keys() == set(
|
||||
["gradeData", "response", "gradeStatus", "lockStatus"]
|
||||
)
|
||||
assert response.data["response"].keys() == set(["files", "text"])
|
||||
expected_assessment_keys = (
|
||||
set(["score", "overallFeedback", "criteria"]) if has_assessment else set()
|
||||
)
|
||||
assert response.data["gradeData"].keys() == expected_assessment_keys
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submission_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission_generic_exception(
|
||||
self,
|
||||
mock_check_submission_lock,
|
||||
mock_get_assessment_info,
|
||||
mock_get_submission_info,
|
||||
):
|
||||
"""Other generic exceptions should return the "unknown" error response"""
|
||||
mock_get_submission_info.return_value = test_data.example_submission
|
||||
# Mock an error in getting the assessment info
|
||||
mock_get_assessment_info.side_effect = XBlockInternalError(
|
||||
context={"handler": "get_assessment_info"}
|
||||
)
|
||||
mock_check_submission_lock.return_value = {"lock_status": "unlocked"}
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "get_assessment_info",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_submission_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission_xblock_exception(
|
||||
self,
|
||||
mock_check_submission_lock,
|
||||
mock_get_assessment_info,
|
||||
mock_get_submission_info,
|
||||
):
|
||||
"""An exception in any XBlock handler returns an error response"""
|
||||
mock_get_submission_info.return_value = test_data.example_submission
|
||||
mock_get_assessment_info.return_value = test_data.example_assessment
|
||||
# Mock a bad data shape to break serialization
|
||||
mock_check_submission_lock.return_value = {"mad": "hatter"}
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TestFetchSubmissionStatusView(BaseViewTest):
|
||||
"""
|
||||
Tests for the submission fetch view
|
||||
"""
|
||||
|
||||
view_name = "ora-staff-grader:fetch-submission-status"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
@ddt.data(
|
||||
{},
|
||||
{PARAM_ORA_LOCATION: "", PARAM_SUBMISSION_ID: Mock()},
|
||||
{PARAM_ORA_LOCATION: Mock(), PARAM_SUBMISSION_ID: ""},
|
||||
)
|
||||
def test_missing_param(self, query_params):
|
||||
"""Missing ORA location or submission ID param should return 400 and error message"""
|
||||
response = self.client.get(self.api_url, query_params)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.content) == {"error": ERR_MISSING_PARAM}
|
||||
|
||||
@ddt.data(True, False)
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission_status(
|
||||
self,
|
||||
has_assessment,
|
||||
mock_check_submission_lock,
|
||||
mock_get_assessment_info,
|
||||
):
|
||||
"""Successful fetch submission returns submission and related lock/assessment info"""
|
||||
mock_get_assessment_info.return_value = (
|
||||
{} if not has_assessment else test_data.example_assessment
|
||||
)
|
||||
mock_check_submission_lock.return_value = {"lock_status": "in-progress"}
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
actual = response.json()
|
||||
expected = {
|
||||
"gradeStatus": "graded" if has_assessment else "ungraded",
|
||||
"lockStatus": mock_check_submission_lock.return_value["lock_status"],
|
||||
"gradeData": {}
|
||||
if not has_assessment
|
||||
else {
|
||||
"score": test_data.example_assessment["score"],
|
||||
"overallFeedback": test_data.example_assessment["feedback"],
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Criterion 1",
|
||||
"selectedOption": "Three",
|
||||
"points": 3,
|
||||
"feedback": "Feedback 1",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
assert actual == expected
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission_status_xblock_exception(
|
||||
self, mock_check_submission_lock, mock_get_assessment_info
|
||||
):
|
||||
"""Exceptions within an XBlock return an internal error response"""
|
||||
# Mock a bad data shape to throw a serializer exception
|
||||
mock_get_assessment_info.return_value = {}
|
||||
mock_check_submission_lock.side_effect = XBlockInternalError(
|
||||
context={"handler": "claim_submission_lock"}
|
||||
)
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "claim_submission_lock",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
def test_fetch_submission_status_generic_exception(
|
||||
self, mock_check_submission_lock, mock_get_assessment_info
|
||||
):
|
||||
"""Exceptions outside of an XBlock return a generic error response"""
|
||||
mock_get_assessment_info.return_value = {}
|
||||
# Mock a bad data shape to throw a serializer exception
|
||||
mock_check_submission_lock.return_value = {"jekyll", "hyde"}
|
||||
|
||||
ora_location, submission_uuid = Mock(), Mock()
|
||||
response = self.client.get(
|
||||
self.api_url,
|
||||
{PARAM_ORA_LOCATION: ora_location, PARAM_SUBMISSION_ID: submission_uuid},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
|
||||
class TestSubmissionLockView(BaseViewTest):
|
||||
"""
|
||||
Tests for the /lock view, locking or unlocking a submission for grading
|
||||
"""
|
||||
|
||||
view_name = "ora-staff-grader:lock"
|
||||
|
||||
test_submission_uuid = str(uuid4())
|
||||
test_anon_user_id = "anon-user-id"
|
||||
test_other_anon_user_id = "anon-user-id-2"
|
||||
test_timestamp = "2020-08-29T02:14:00-04:00"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Lock requests must include ORA location and submission UUID
|
||||
self.test_lock_params = {
|
||||
PARAM_ORA_LOCATION: self.ora_usage_key,
|
||||
PARAM_SUBMISSION_ID: self.test_submission_uuid,
|
||||
}
|
||||
|
||||
self.log_in()
|
||||
|
||||
def claim_lock(self, params):
|
||||
"""Wrapper for easier calling of 'claim_submission_lock'"""
|
||||
return self.client.post(self.url_with_params(params))
|
||||
|
||||
def delete_lock(self, params):
|
||||
"""Wrapper for easier calling of 'delete_submission_lock'"""
|
||||
return self.client.delete(self.url_with_params(params))
|
||||
|
||||
# Tests for claiming a lock (POST)
|
||||
|
||||
def test_claim_lock_invalid_ora(self):
|
||||
"""An invalid ORA returns a 400"""
|
||||
self.test_lock_params[PARAM_ORA_LOCATION] = "not_a_real_location"
|
||||
|
||||
response = self.claim_lock(self.test_lock_params)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert json.loads(response.content) == {"error": ERR_BAD_ORA_LOCATION}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.claim_submission_lock")
|
||||
def test_claim_lock(self, mock_claim_lock):
|
||||
"""POST tries to claim a submission lock. Success returns lock status 'in-progress'."""
|
||||
mock_return_data = {
|
||||
"submission_uuid": self.test_submission_uuid,
|
||||
"owner_id": self.test_anon_user_id,
|
||||
"created_at": self.test_timestamp,
|
||||
"lock_status": "in-progress",
|
||||
}
|
||||
mock_claim_lock.return_value = mock_return_data
|
||||
|
||||
response = self.claim_lock(self.test_lock_params)
|
||||
|
||||
expected_value = {"lockStatus": "in-progress"}
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == expected_value
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.claim_submission_lock")
|
||||
def test_claim_lock_contested(self, mock_claim_lock, mock_check_lock):
|
||||
"""Attempting to claim a lock owned by another user returns a 403 - forbidden and passes error code."""
|
||||
mock_claim_lock.side_effect = LockContestedError()
|
||||
mock_check_lock.return_value = {
|
||||
"submission_uuid": self.test_submission_uuid,
|
||||
"owner_id": self.test_other_anon_user_id,
|
||||
"created_at": self.test_timestamp,
|
||||
"lock_status": "locked",
|
||||
}
|
||||
|
||||
response = self.claim_lock(self.test_lock_params)
|
||||
|
||||
expected_value = {"error": ERR_LOCK_CONTESTED, "lockStatus": "locked"}
|
||||
assert response.status_code == 409
|
||||
assert json.loads(response.content) == expected_value
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.claim_submission_lock")
|
||||
def test_claim_lock_xblock_exception(
|
||||
self,
|
||||
mock_claim_lock,
|
||||
):
|
||||
"""In the unlikely event of an error, the exits are to your left and behind you"""
|
||||
mock_claim_lock.side_effect = XBlockInternalError(
|
||||
context={"handler": "claim_submission_lock"}
|
||||
)
|
||||
|
||||
response = self.claim_lock(self.test_lock_params)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "claim_submission_lock",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.claim_submission_lock")
|
||||
def test_claim_lock_generic_exception(
|
||||
self,
|
||||
mock_claim_lock,
|
||||
):
|
||||
"""In the even more unlikely event of an unhandled error, shrug exuberantly"""
|
||||
# Mock a bad data shape to break serialiation and raise a generic exception
|
||||
mock_claim_lock.return_value = {"android": "Rachel"}
|
||||
|
||||
response = self.claim_lock(self.test_lock_params)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
# Tests for deleting a lock (DELETE)
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.delete_submission_lock")
|
||||
def test_delete_lock(self, mock_delete_lock):
|
||||
"""DELETE indicates to clear submission lock. Success returns lock status 'unlocked'."""
|
||||
mock_delete_lock.return_value = {"lock_status": "unlocked"}
|
||||
|
||||
response = self.delete_lock(self.test_lock_params)
|
||||
|
||||
expected_value = {"lockStatus": "unlocked"}
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == expected_value
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.delete_submission_lock")
|
||||
def test_delete_lock_contested(self, mock_delete_lock, mock_check_lock):
|
||||
"""Attempting to delete a lock owned by another user returns a 403 - forbidden and passes error code."""
|
||||
mock_delete_lock.side_effect = LockContestedError()
|
||||
mock_check_lock.return_value = {
|
||||
"submission_uuid": self.test_submission_uuid,
|
||||
"owner_id": self.test_other_anon_user_id,
|
||||
"created_at": self.test_timestamp,
|
||||
"lock_status": "locked",
|
||||
}
|
||||
|
||||
response = self.delete_lock(self.test_lock_params)
|
||||
|
||||
expected_value = {"error": ERR_LOCK_CONTESTED, "lockStatus": "locked"}
|
||||
assert response.status_code == 409
|
||||
assert json.loads(response.content) == expected_value
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.delete_submission_lock")
|
||||
def test_delete_lock_xblock_exception(self, mock_delete_lock):
|
||||
"""In the unlikely event of an error, the exits are to your left and behind you"""
|
||||
mock_delete_lock.side_effect = XBlockInternalError(
|
||||
context={"handler": "delete_submission_lock"}
|
||||
)
|
||||
|
||||
response = self.delete_lock(self.test_lock_params)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "delete_submission_lock",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.delete_submission_lock")
|
||||
def test_delete_lock_generic_exception(self, mock_delete_lock):
|
||||
"""In the even more unlikely event of an unhandled error, shrug exuberantly"""
|
||||
# Mock a bad data shape to break serialiation and raise a generic exception
|
||||
mock_delete_lock.return_value = {"android": "Roy Batty"}
|
||||
|
||||
response = self.delete_lock(self.test_lock_params)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
|
||||
class TestUpdateGradeView(BaseViewTest):
|
||||
"""
|
||||
Tests for updating a grade for a submission
|
||||
"""
|
||||
|
||||
view_name = "ora-staff-grader:update-grade"
|
||||
|
||||
submission_uuid = str(uuid4())
|
||||
ora_location = Mock()
|
||||
test_anon_user_id = "anon-user-id"
|
||||
test_timestamp = "2020-08-29T02:14:00-04:00"
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.log_in()
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.submit_grade")
|
||||
def test_submit_grade_xblock_exception(self, mock_submit_grade, mock_check_lock):
|
||||
"""A handled ORA failure to submit a grade returns a server error"""
|
||||
mock_check_lock.return_value = {"lock_status": "in-progress"}
|
||||
mock_submit_grade.side_effect = XBlockInternalError(
|
||||
context={"handler": "staff_assess", "msg": "Danger, Will Robinson!"}
|
||||
)
|
||||
url = self.url_with_params(
|
||||
{
|
||||
PARAM_ORA_LOCATION: self.ora_location,
|
||||
PARAM_SUBMISSION_ID: self.submission_uuid,
|
||||
}
|
||||
)
|
||||
data = test_data.example_grade_data
|
||||
|
||||
response = self.client.post(url, data, format="json")
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_INTERNAL,
|
||||
"handler": "staff_assess",
|
||||
"msg": "Danger, Will Robinson!",
|
||||
}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.submit_grade")
|
||||
def test_submit_grade_generic_exception(self, mock_submit_grade, mock_check_lock):
|
||||
"""A fall-through failure returns an unknown error"""
|
||||
mock_check_lock.return_value = {"lock_status": "in-progress"}
|
||||
mock_submit_grade.return_value = {"error": "time paradox encountered"}
|
||||
url = self.url_with_params(
|
||||
{
|
||||
PARAM_ORA_LOCATION: self.ora_location,
|
||||
PARAM_SUBMISSION_ID: self.submission_uuid,
|
||||
}
|
||||
)
|
||||
data = test_data.example_grade_data
|
||||
|
||||
response = self.client.post(url, data, format="json")
|
||||
assert response.status_code == 500
|
||||
assert json.loads(response.content) == {"error": ERR_UNKNOWN}
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.delete_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.submit_grade")
|
||||
def test_submit_grade_success(
|
||||
self, mock_submit_grade, mock_delete_lock, mock_get_info, mock_check_lock
|
||||
):
|
||||
"""A grade update success should clear the submission lock and return submission meta"""
|
||||
mock_check_lock.side_effect = [
|
||||
{"lock_status": "in-progress"},
|
||||
{"lock_status": "unlocked"},
|
||||
]
|
||||
mock_submit_grade.return_value = {"success": True, "msg": ""}
|
||||
mock_get_info.return_value = test_data.example_assessment
|
||||
|
||||
url = self.url_with_params(
|
||||
{
|
||||
PARAM_ORA_LOCATION: self.ora_location,
|
||||
PARAM_SUBMISSION_ID: self.submission_uuid,
|
||||
}
|
||||
)
|
||||
data = test_data.example_grade_data
|
||||
|
||||
response = self.client.post(url, data, format="json")
|
||||
|
||||
expected_response = {
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "unlocked",
|
||||
"gradeData": {
|
||||
"score": test_data.example_assessment["score"],
|
||||
"overallFeedback": test_data.example_assessment["feedback"],
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Criterion 1",
|
||||
"selectedOption": "Three",
|
||||
"points": 3,
|
||||
"feedback": "Feedback 1",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.content) == expected_response
|
||||
|
||||
# Verify that clear lock was called
|
||||
mock_delete_lock.assert_called_once()
|
||||
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.check_submission_lock")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.get_assessment_info")
|
||||
@patch("lms.djangoapps.ora_staff_grader.views.submit_grade")
|
||||
def test_submit_grade_contested(
|
||||
self, mock_submit_grade, mock_get_info, mock_check_lock
|
||||
):
|
||||
"""Submitting a grade should be blocked if someone else has obtained the lock"""
|
||||
mock_check_lock.side_effect = [{"lock_status": "unlocked"}]
|
||||
mock_get_info.return_value = test_data.example_assessment
|
||||
|
||||
url = self.url_with_params(
|
||||
{
|
||||
PARAM_ORA_LOCATION: self.ora_location,
|
||||
PARAM_SUBMISSION_ID: self.submission_uuid,
|
||||
}
|
||||
)
|
||||
data = test_data.example_grade_data
|
||||
|
||||
response = self.client.post(url, data, format="json")
|
||||
|
||||
assert response.status_code == 409
|
||||
assert json.loads(response.content) == {
|
||||
"error": ERR_GRADE_CONTESTED,
|
||||
"gradeStatus": "graded",
|
||||
"lockStatus": "unlocked",
|
||||
"gradeData": {
|
||||
"score": test_data.example_assessment["score"],
|
||||
"overallFeedback": test_data.example_assessment["feedback"],
|
||||
"criteria": [
|
||||
{
|
||||
"name": "Criterion 1",
|
||||
"selectedOption": "Three",
|
||||
"points": 3,
|
||||
"feedback": "Feedback 1",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Verify that submit grade was not called
|
||||
mock_submit_grade.assert_not_called()
|
||||
31
lms/djangoapps/ora_staff_grader/urls.py
Normal file
31
lms/djangoapps/ora_staff_grader/urls.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
URLs for Enhanced Staff Grader (ESG) backend-for-frontend (BFF)
|
||||
"""
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
|
||||
|
||||
from lms.djangoapps.ora_staff_grader.views import (
|
||||
InitializeView,
|
||||
SubmissionFetchView,
|
||||
SubmissionLockView,
|
||||
SubmissionStatusFetchView,
|
||||
UpdateGradeView,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = []
|
||||
app_name = "ora-staff-grader"
|
||||
|
||||
urlpatterns += [
|
||||
path("mock/", include("lms.djangoapps.ora_staff_grader.mock.urls")),
|
||||
path("initialize", InitializeView.as_view(), name="initialize"),
|
||||
path(
|
||||
"submission/status",
|
||||
SubmissionStatusFetchView.as_view(),
|
||||
name="fetch-submission-status",
|
||||
),
|
||||
path("submission/lock", SubmissionLockView.as_view(), name="lock"),
|
||||
path("submission/grade", UpdateGradeView.as_view(), name="update-grade"),
|
||||
path("submission", SubmissionFetchView.as_view(), name="fetch-submission"),
|
||||
]
|
||||
76
lms/djangoapps/ora_staff_grader/utils.py
Normal file
76
lms/djangoapps/ora_staff_grader/utils.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
Various helpful utilities for ESG
|
||||
"""
|
||||
from functools import wraps
|
||||
import json
|
||||
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.request import clone_request
|
||||
|
||||
from lms.djangoapps.courseware.module_render import handle_xblock_callback_noauth
|
||||
from lms.djangoapps.ora_staff_grader.errors import MissingParamResponse
|
||||
|
||||
|
||||
def require_params(param_names):
|
||||
"""
|
||||
Adds the required query params to the view function. Returns 404 if param(s) missing.
|
||||
|
||||
Params:
|
||||
- param_name (string): the query param to unpack
|
||||
|
||||
Raises:
|
||||
- MissingParamResponse (HTTP 400)
|
||||
"""
|
||||
|
||||
def decorator(function):
|
||||
@wraps(function)
|
||||
def wrapped_function(
|
||||
self, request, *args, **kwargs
|
||||
): # pylint: disable=unused-argument
|
||||
passed_parameters = []
|
||||
|
||||
for param_name in param_names:
|
||||
param = request.query_params.get(param_name)
|
||||
|
||||
if not param:
|
||||
return MissingParamResponse()
|
||||
|
||||
passed_parameters.append(param)
|
||||
return function(self, request, *passed_parameters, *args, **kwargs)
|
||||
|
||||
return wrapped_function
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def call_xblock_json_handler(request, usage_id, handler_name, data):
|
||||
"""
|
||||
WARN: Tested only for use in ESG. Consult before use outside of ESG.
|
||||
|
||||
Create an internally-routed XBlock.json_handler request. The internal auth code/param unpacking requires a POST
|
||||
request with payload in the body. Ideally, we would be able to call functions on XBlocks without this sort of
|
||||
hacky request proxying but this is what we have to work with right now.
|
||||
|
||||
params:
|
||||
request (HttpRequest): Originating web request, we're going to borrow auth headers/cookies from this
|
||||
usage_id (str): Usage ID of the XBlock for running the handler
|
||||
handler_name (str): the name of the XBlock handler method
|
||||
data (dict): Data to be encoded and sent as the body of the POST request
|
||||
returns:
|
||||
response (HttpResponse): get response data with json.loads(response.content)
|
||||
"""
|
||||
# XBlock.json_handler operates through a POST request
|
||||
proxy_request = clone_request(request, "POST")
|
||||
proxy_request.META["REQUEST_METHOD"] = "POST"
|
||||
|
||||
# The body is an encoded JSON blob
|
||||
proxy_request.body = json.dumps(data).encode()
|
||||
|
||||
# Course ID can be retrieved from the usage_id
|
||||
usage_key = UsageKey.from_string(usage_id)
|
||||
course_id = str(usage_key.course_key)
|
||||
|
||||
# Send the request and return the HTTP response from the XBlock
|
||||
return handle_xblock_callback_noauth(
|
||||
proxy_request, course_id, usage_id, handler_name
|
||||
)
|
||||
398
lms/djangoapps/ora_staff_grader/views.py
Normal file
398
lms/djangoapps/ora_staff_grader/views.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""
|
||||
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
|
||||
|
||||
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 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,
|
||||
UnknownErrorResponse,
|
||||
XBlockInternalError,
|
||||
)
|
||||
from lms.djangoapps.ora_staff_grader.ora_api import (
|
||||
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 (
|
||||
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.lib.api.authentication import BearerAuthenticationAllowInactiveUser
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@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)
|
||||
|
||||
return Response(InitializeSerializer(init_data).data)
|
||||
|
||||
# Catch bad ORA location
|
||||
except (InvalidKeyError, ItemNotFoundError):
|
||||
return BadOraLocationResponse()
|
||||
|
||||
# Issues with the XBlock handlers
|
||||
except XBlockInternalError as ex:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
serializer = SubmissionFetchSerializer(
|
||||
{
|
||||
"submission_info": submission_info,
|
||||
"assessment_info": assessment_info,
|
||||
"lock_info": lock_info,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
# Issues with the XBlock handlers
|
||||
except XBlockInternalError as ex:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
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)
|
||||
|
||||
serializer = SubmissionStatusFetchSerializer(
|
||||
{
|
||||
"assessment_info": assessment_info,
|
||||
"lock_info": lock_info,
|
||||
}
|
||||
)
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
# Issues with the XBlock handlers
|
||||
except XBlockInternalError as ex:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
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
|
||||
return GradeContestedResponse(context=submission_status)
|
||||
|
||||
# Transform grade data and submit assessment, rasies 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)
|
||||
serializer = SubmissionStatusFetchSerializer(
|
||||
{
|
||||
"assessment_info": assessment_info,
|
||||
"lock_info": lock_info,
|
||||
}
|
||||
)
|
||||
return Response(serializer.data)
|
||||
|
||||
# Issues with the XBlock handlers
|
||||
except XBlockInternalError as ex:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
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)
|
||||
return Response(LockStatusSerializer(lock_info).data)
|
||||
|
||||
# Catch bad ORA location
|
||||
except (InvalidKeyError, ItemNotFoundError):
|
||||
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:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
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)
|
||||
return Response(LockStatusSerializer(lock_info).data)
|
||||
|
||||
# Catch bad ORA location
|
||||
except (InvalidKeyError, ItemNotFoundError):
|
||||
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:
|
||||
return InternalErrorResponse(context=ex.context)
|
||||
|
||||
# Blanket exception handling
|
||||
except Exception:
|
||||
return UnknownErrorResponse()
|
||||
@@ -375,6 +375,22 @@ def get_team_for_user_course_topic(user, course_id, topic_id):
|
||||
).first()
|
||||
|
||||
|
||||
def get_teams_in_teamset(course_id, topic_id):
|
||||
"""
|
||||
Given a course_id and topic_id, return all CourseTeams in the course and topic
|
||||
"""
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError as exc:
|
||||
raise ValueError("The supplied course id {course_id} is not valid.".format(
|
||||
course_id=course_id
|
||||
)) from exc
|
||||
return CourseTeam.objects.filter(
|
||||
course_id=course_key,
|
||||
topic_id=topic_id,
|
||||
).all()
|
||||
|
||||
|
||||
def anonymous_user_ids_for_team(user, team):
|
||||
""" Get the anonymous user IDs for members of a team, used in team submissions
|
||||
Requesting user must be a member of the team or course staff
|
||||
|
||||
@@ -11,6 +11,15 @@ class TeamsService:
|
||||
from . import api
|
||||
return api.get_team_for_user_course_topic(user, course_id, topic_id)
|
||||
|
||||
def get_team_names(self, course_id, topic_id):
|
||||
"""
|
||||
Given a course and topic id, return a dict mapping from team id to team name for teams in that topic
|
||||
"""
|
||||
from . import api
|
||||
teams = api.get_teams_in_teamset(course_id, topic_id)
|
||||
name_mapping = {team.team_id: team.name for team in teams}
|
||||
return name_mapping
|
||||
|
||||
def get_team_by_team_id(self, team_id):
|
||||
from . import api
|
||||
return api.get_team_by_team_id(team_id)
|
||||
|
||||
@@ -222,6 +222,32 @@ class PythonAPITests(SharedModuleStoreTestCase):
|
||||
team_anonymous_user_ids = teams_api.anonymous_user_ids_for_team(user_staff, self.team1)
|
||||
assert len(self.team1.users.all()) == len(team_anonymous_user_ids)
|
||||
|
||||
def test_get_teams_in_teamset__bad_course_id(self):
|
||||
bad_course_id = 'badcourseid'
|
||||
with self.assertRaisesMessage(ValueError, f'The supplied course id {bad_course_id} is not valid'):
|
||||
teams_api.get_teams_in_teamset(bad_course_id, 'teamset-id')
|
||||
|
||||
def test_get_teams_in_teamset_1_1(self):
|
||||
result = teams_api.get_teams_in_teamset(str(COURSE_KEY1), TOPIC1)
|
||||
assert len(result) == 2
|
||||
assert self.team1 in result
|
||||
assert self.team1a in result
|
||||
|
||||
def test_get_teams_in_teamset_1_2(self):
|
||||
result = teams_api.get_teams_in_teamset(str(COURSE_KEY1), TOPIC2)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_get_teams_in_teamset_2_2(self):
|
||||
result = teams_api.get_teams_in_teamset(str(COURSE_KEY2), TOPIC2)
|
||||
assert len(result) == 2
|
||||
assert self.team2 in result
|
||||
assert self.team2a in result
|
||||
|
||||
def test_get_teams_in_teamset_2_3(self):
|
||||
result = teams_api.get_teams_in_teamset(str(COURSE_KEY2), TOPIC3)
|
||||
assert len(result) == 1
|
||||
assert self.team3 in result
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class TeamAccessTests(SharedModuleStoreTestCase):
|
||||
|
||||
@@ -44,3 +44,28 @@ class TeamsServiceTests(ModuleStoreTestCase):
|
||||
split_url = team_detail_url.split('/')
|
||||
assert split_url[1:] ==\
|
||||
['courses', str(self.course_run['key']), 'teams', '#teams', self.team.topic_id, self.team.team_id]
|
||||
|
||||
def test_get_team_names(self):
|
||||
"""
|
||||
get_team_names will return a dict mapping the team id to the team name for all teams in the given teamset
|
||||
"""
|
||||
additional_teams = [
|
||||
CourseTeamFactory.create(course_id=self.course_key, topic_id=self.team.topic_id)
|
||||
for _ in range(3)
|
||||
]
|
||||
|
||||
result = self.service.get_team_names(self.course_key, self.team.topic_id)
|
||||
|
||||
assert result == {
|
||||
self.team.team_id: self.team.name,
|
||||
additional_teams[0].team_id: additional_teams[0].name,
|
||||
additional_teams[1].team_id: additional_teams[1].name,
|
||||
additional_teams[2].team_id: additional_teams[2].name,
|
||||
}
|
||||
|
||||
def test_get_team_names__none(self):
|
||||
""" If there are no teams in the teamset, the function will return an empty list"""
|
||||
course_run = CourseRunFactory.create()
|
||||
course_key = course_run['key']
|
||||
result = self.service.get_team_names(course_key, "some-topic-id")
|
||||
assert result == {}
|
||||
|
||||
@@ -3895,6 +3895,7 @@ OPTIONAL_APPS = [
|
||||
('openassessment', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.assessment', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.fileupload', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.staffgrader', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.workflow', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
('openassessment.xblock', 'openedx.core.djangoapps.content.course_overviews.apps.CourseOverviewsConfig'),
|
||||
|
||||
@@ -4731,6 +4732,13 @@ PROGRAM_CONSOLE_MICROFRONTEND_URL = None
|
||||
# .. setting_default: None
|
||||
# .. setting_description: Base URL of the micro-frontend-based courseware page.
|
||||
LEARNING_MICROFRONTEND_URL = None
|
||||
# .. setting_name: ORA_GRADING_MICROFRONTEND_URL
|
||||
# .. setting_default: None
|
||||
# .. setting_description: Base URL of the micro-frontend-based openassessment grading page.
|
||||
# This is will be show in the open response tab list data.
|
||||
# .. setting_warning: Also set site's openresponseassessment.enhanced_staff_grader
|
||||
# waffle flag.
|
||||
ORA_GRADING_MICROFRONTEND_URL = None
|
||||
# .. setting_name: DISCUSSIONS_MICROFRONTEND_URL
|
||||
# .. setting_default: None
|
||||
# .. setting_description: Base URL of the micro-frontend-based discussions page.
|
||||
|
||||
@@ -277,6 +277,7 @@ LOGIN_REDIRECT_WHITELIST.extend([
|
||||
'localhost:2001', # frontend-app-course-authoring
|
||||
'localhost:3001', # frontend-app-library-authoring
|
||||
'localhost:18400', # frontend-app-publisher
|
||||
'localhost:1993', # frontend-app-ora-grading
|
||||
ENTERPRISE_LEARNER_PORTAL_NETLOC, # frontend-app-learner-portal-enterprise
|
||||
ENTERPRISE_ADMIN_PORTAL_NETLOC, # frontend-app-admin-portal
|
||||
])
|
||||
|
||||
@@ -1026,3 +1026,8 @@ if settings.ENABLE_SAVE_FOR_LATER:
|
||||
urlpatterns += [
|
||||
path('', include('lms.djangoapps.save_for_later.urls')),
|
||||
]
|
||||
|
||||
# Enhanced Staff Grader (ESG) URLs
|
||||
urlpatterns += [
|
||||
path('api/ora_staff_grader/', include('lms.djangoapps.ora_staff_grader.urls', 'ora-staff-grader')),
|
||||
]
|
||||
|
||||
@@ -20,6 +20,7 @@ class CourseOverviewFactory(DjangoModelFactory): # lint-amnesty, pylint: disabl
|
||||
version = CourseOverview.VERSION
|
||||
pre_requisite_courses = []
|
||||
org = 'edX'
|
||||
display_number_with_default = 'toy'
|
||||
run = factory.Sequence('2012_Fall_{}'.format)
|
||||
|
||||
@factory.lazy_attribute
|
||||
@@ -32,7 +33,11 @@ class CourseOverviewFactory(DjangoModelFactory): # lint-amnesty, pylint: disabl
|
||||
|
||||
@factory.lazy_attribute
|
||||
def id(self):
|
||||
return CourseLocator(self.org, 'toy', self.run)
|
||||
return CourseLocator(self.org, self.display_number_with_default, self.run)
|
||||
|
||||
@factory.lazy_attribute
|
||||
def display_org_with_default(self):
|
||||
return self.org
|
||||
|
||||
@factory.lazy_attribute
|
||||
def display_name(self):
|
||||
|
||||
@@ -704,7 +704,7 @@ openedx-events==0.7.1
|
||||
# via -r requirements/edx/base.in
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/base.in
|
||||
ora2==3.7.8
|
||||
ora2==3.8.1
|
||||
# via -r requirements/edx/base.in
|
||||
packaging==21.3
|
||||
# via
|
||||
|
||||
@@ -939,7 +939,7 @@ openedx-events==0.7.1
|
||||
# via -r requirements/edx/testing.txt
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/testing.txt
|
||||
ora2==3.7.8
|
||||
ora2==3.8.1
|
||||
# via -r requirements/edx/testing.txt
|
||||
packaging==21.3
|
||||
# via
|
||||
|
||||
@@ -889,7 +889,7 @@ openedx-events==0.7.1
|
||||
# via -r requirements/edx/base.txt
|
||||
openedx-filters==0.4.3
|
||||
# via -r requirements/edx/base.txt
|
||||
ora2==3.7.8
|
||||
ora2==3.8.1
|
||||
# via -r requirements/edx/base.txt
|
||||
packaging==21.3
|
||||
# via
|
||||
|
||||
Reference in New Issue
Block a user