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:
Nathan Sprenkle
2022-01-31 11:09:41 -05:00
committed by GitHub
parent 65f0a2fd3a
commit 1212e3550c
41 changed files with 6885 additions and 15 deletions

View File

@@ -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

View File

@@ -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/",

View File

@@ -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'),

View File

@@ -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}')

View File

@@ -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']
)

View File

@@ -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)

View 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`

View File

@@ -0,0 +1,3 @@
"""
App for Enhanced Staff Grader (ESG) backend-for-frontend (BFF)
"""

View 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"

View 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

View 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": []
}
]
}

View 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)

View File

@@ -0,0 +1,3 @@
"""
Mock tooling for ESG
"""

View File

@@ -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"
}
}

View 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"
}
}

View 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": []
}
}

View 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"
}
}
}

View 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"),
]

View 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
)

View 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,
}
)

View 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

View 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")

View 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"},
],
}

View 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

View 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()

View 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"),
]

View 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
)

View 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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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):

View File

@@ -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 == {}

View File

@@ -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.

View File

@@ -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
])

View File

@@ -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')),
]

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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