Skip to content

Commit

Permalink
feat: BFF Assessments Data (#2063)
Browse files Browse the repository at this point in the history
* refactor: move reused base serializer classes

We use a few convenience serializers across our MFE. Currently
CharListField, a list of strings, and IsRequiredField, a field
converting from text-based "required" string to boolean.

This moves them into a base utils file for reuse across our serializers.

* feat: add assessment serializers

* refactor: move assessment load to page context

This lets us load the response for assessment only once and pass that
context down to the assessment serializer. This also has the effect of
simplifying the assessment serializer.

* feat: allow jumping back to peer step
  • Loading branch information
nsprenkle authored Oct 6, 2023
1 parent 8a91508 commit 12cc525
Show file tree
Hide file tree
Showing 7 changed files with 593 additions and 17 deletions.
116 changes: 116 additions & 0 deletions openassessment/xblock/ui_mixins/mfe/assessment_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,129 @@
"""
# pylint: disable=abstract-method

from rest_framework.fields import (
CharField,
IntegerField,
SerializerMethodField,
URLField,
)
from rest_framework.serializers import Serializer

from openassessment.xblock.ui_mixins.mfe.serializer_utils import NullField


class SubmissionFileSerializer(Serializer):
fileUrl = URLField(source="file_key")
fileDescription = CharField(source="file_description")
fileName = CharField(source="file_name")
fileSize = IntegerField(source="file_size")
fileIndex = IntegerField(source="file_index")


class SubmittedResponseSerializer(Serializer):
"""
Data for a submitted response
Returns:
{
textResponses: (Array [String])
[
(String) Matched with prompts
],
uploaded_files: (Array [Object])
[
{
fileUrl: (URL) S3 location
fileDescription: (String)
fileName: (String)
fileSize: (Bytes?)
fileIndex: (Integer, positive)
}
]
}
"""

textResponses = SerializerMethodField()
uploadedFiles = SerializerMethodField()

def get_textResponses(self, instance):
# An empty response has a different format from a saved response
# Return empty single text part if not yet saved.
answer_text_parts = instance["answer"].get("parts", [])
return [part["text"] for part in answer_text_parts]

def get_uploadedFiles(self, instance):
# coerce to a similar shape for easier serialization
files = []

if not instance["answer"].get("file_keys"):
return None

for i, file_key in enumerate(instance["answer"]["file_keys"]):
file_data = {
"file_key": file_key,
"file_description": instance["answer"]["files_descriptions"][i],
"file_name": instance["answer"]["files_names"][i],
"file_size": instance["answer"]["files_sizes"][i],
"file_index": i,
}

# Don't serialize deleted / missing files
if not file_data["file_name"] and not file_data["file_description"]:
continue

files.append(file_data)

return [SubmissionFileSerializer(file).data for file in files]


class AssessmentResponseSerializer(Serializer):
"""
Given we want to load an assessment response,
gather the appropriate response and serialize.
Data same shape as Submission, but coming from different sources.
Returns:
{
// Null for Assessments
hasSubmitted: None
hasCancelled: None
hasReceivedGrade: None
teamInfo: None
// The actual response to view
response: (Object)
{
textResponses: (Array [String])
[
(String) Matched with prompts
],
uploadedFiles: (Array [Object])
[
{
fileUrl: (URL) S3 location
fileDescription: (String)
fileName: (String)
fileSize: (Bytes?)
fileIndex: (Integer, positive)
}
]
}
}
"""

hasSubmitted = NullField(source="*")
hasCancelled = NullField(source="*")
hasReceivedGrade = NullField(source="*")
teamInfo = NullField(source="*")

response = SerializerMethodField()

def get_response(self, instance): # pylint: disable=unused-argument
# Response is passed in through context, so we don't have to fetch it
# in multiple locations.
response = self.context.get("response")
if not response:
return {}
return SubmittedResponseSerializer(response).data
8 changes: 8 additions & 0 deletions openassessment/xblock/ui_mixins/mfe/mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,13 @@ def get_block_learner_submission_data(self, data, suffix=""): # pylint: disable
@XBlock.json_handler
def get_block_learner_assessment_data(self, data, suffix=""): # pylint: disable=unused-argument
serializer_context = {"view": "assessment"}

# Allow jumping to a specific step, within our allowed steps
# NOTE should probably also verify this step is in our assessment steps
# though the serializer also covers for this currently
jumpable_steps = "peer"
if suffix in jumpable_steps:
serializer_context.update({"jump_to_step": suffix})

page_context = PageDataSerializer(self, context=serializer_context)
return page_context.data
17 changes: 4 additions & 13 deletions openassessment/xblock/ui_mixins/mfe/ora_config_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,13 @@
IntegerField,
Serializer,
CharField,
ListField,
SerializerMethodField,
)


class CharListField(ListField):
child = CharField()


class IsRequiredField(BooleanField):
"""
Utility for checking if a field is "required" to reduce repeated code.
"""

def to_representation(self, value):
return value == "required"
from openassessment.xblock.ui_mixins.mfe.serializer_utils import (
CharListField,
IsRequiredField,
)


class TextResponseConfigSerializer(Serializer):
Expand Down
52 changes: 48 additions & 4 deletions openassessment/xblock/ui_mixins/mfe/page_context_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ def to_representation(self, instance):
return TrainingStepInfoSerializer(instance.student_training_data).data
elif active_step == "peer":
return PeerStepInfoSerializer(instance.peer_assessment_data()).data
elif active_step in ("submission", "done"):
elif active_step in ("submission", "waiting", "done"):
return {}
else:
raise Exception(f"Bad step name: {active_step}") # pylint: disable=broad-exception-raised
Expand Down Expand Up @@ -199,21 +199,65 @@ class PageDataSerializer(Serializer):
def to_representation(self, instance):
# Loading workflow status causes a workflow refresh
# ... limit this to one refresh per page call
active_step = instance.workflow_data.status or "submission"
workflow_step = instance.workflow_data.status or "submission"

self.context.update({"step": active_step})
self.context.update({"step": workflow_step})
return super().to_representation(instance)

def _can_jump_to_step(self, workflow_step, workflow_data, step_name):
"""
Helper to determine if a student can jump to a specific step:
1) Student is on that step.
2) Student has completed that step.
NOTE that this should probably happen at the handler level, but for
added safety, check here as well.
"""
if step_name == workflow_step:
return True
step_status = workflow_data.status_details.get(step_name, {})
return step_status.get("complete", False)

def get_submission(self, instance):
"""
Has the following different use-cases:
1) In the "submission" view, we get the user's draft / complete submission.
2) In the "assessment" view, we get an assessment for the current assessment step.
"""
# pylint: disable=broad-exception-raised

# Submission Views
if self.context.get("view") == "submission":
return SubmissionSerializer(instance.submission_data).data

# Assessment Views
elif self.context.get("view") == "assessment":
# Can't view assessments without completing submission
if self.context["step"] == "submission":
raise Exception("Cannot view assessments without having completed submission.")

# If the student is trying to jump to a step, verify they can
jump_to_step = self.context.get("jump_to_step")
workflow_step = self.context["step"]
if jump_to_step and not self._can_jump_to_step(
workflow_step, instance.workflow_data, jump_to_step
):
raise Exception(f"Can't jump to {jump_to_step} step before completion")

# Go to the current step, or jump to the selected step
active_step = jump_to_step or workflow_step

if active_step == "training":
response = instance.student_training_data.example
elif active_step == "peer":
response = instance.peer_assessment_data().get_peer_submission()
elif active_step in ("staff", "waiting", "done"):
response = None
else:
raise Exception(f"Bad step name: {active_step}")

self.context["response"] = response

return AssessmentResponseSerializer(instance.api_data, context=self.context).data
else:
raise Exception("Missing view context for page") # pylint: disable=broad-exception-raised
raise Exception("Missing view context for page")
31 changes: 31 additions & 0 deletions openassessment/xblock/ui_mixins/mfe/serializer_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
Some custom serializer types and utils we use across our MFE
"""

from rest_framework.fields import BooleanField, CharField, ListField


class CharListField(ListField):
"""
Shorthand for serializing a list of strings (CharFields)
"""

child = CharField()


class IsRequiredField(BooleanField):
"""
Utility for checking if a field is "required" to reduce repeated code.
"""

def to_representation(self, value):
return value == "required"


class NullField(CharField):
"""
A field which returns a Null/None value
"""

def to_representation(self, value):
return None
Loading

0 comments on commit 12cc525

Please sign in to comment.