Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: ORA staff grader endpoints upgrade #10

Draft
wants to merge 3 commits into
base: FG/ORA_staff_grader_initialize_upgrade
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 143 additions & 33 deletions openassessment/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from django.utils.translation import gettext as _
import requests

from submissions.models import Submission
from submissions import api as sub_api
from submissions.errors import SubmissionNotFoundError
from openassessment.runtime_imports.classes import import_block_structure_transformers, import_external_id
Expand All @@ -27,6 +28,7 @@
from openassessment.assessment.models import Assessment, AssessmentFeedback, AssessmentPart
from openassessment.fileupload.api import get_download_url
from openassessment.workflow.models import AssessmentWorkflow, TeamAssessmentWorkflow
from openassessment.assessment.score_type_constants import PEER_TYPE, SELF_TYPE, STAFF_TYPE

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -95,48 +97,34 @@ def map_anonymized_ids_to_usernames(anonymized_ids):

return anonymous_id_to_username_mapping

def map_anonymized_ids_to_emails(anonymized_ids):
def map_anonymized_ids_to_user_data(anonymized_ids):
"""
Args:
anonymized_ids - list of anonymized user ids.
Returns:
dictionary, that contains mapping between anonymized user ids and
actual user emails.
"""
User = get_user_model()

users = _use_read_replica(
User.objects.filter(anonymoususerid__anonymous_user_id__in=anonymized_ids)
.annotate(anonymous_id=F("anonymoususerid__anonymous_user_id"))
.values("email", "anonymous_id")
)
anonymous_id_to_email_mapping = {
user["anonymous_id"]: user["email"] for user in users
}
return anonymous_id_to_email_mapping


def map_anonymized_ids_to_fullname(anonymized_ids):
"""
Args:
anonymized_ids - list of anonymized user ids.
Returns:
dictionary, that contains mapping between anonymized user ids and
actual user fullname.
"""
dict {
<anonymous_user_id> : {
'email': (str) <user.email>
'username': (str) <user.username>
'fullname': (str) <user.profile.name>
}
}
"""
User = get_user_model()

users = _use_read_replica(
User.objects.filter(anonymoususerid__anonymous_user_id__in=anonymized_ids)
.select_related("profile")
.select_related("profile") # Optional, based on performance testing
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
.annotate(anonymous_id=F("anonymoususerid__anonymous_user_id"))
.values("profile__name", "anonymous_id")
)

anonymous_id_to_fullname_mapping = {
user["anonymous_id"]: user["profile__name"] for user in users
).values("username", "email", "profile__name", "anonymous_id")

anonymous_id_to_user_info_mapping = {
user["anonymous_id"]: {
"username": user["username"],
"email": user["email"],
"fullname": user["profile__name"]
} for user in users
}
return anonymous_id_to_fullname_mapping
return anonymous_id_to_user_info_mapping

class CsvWriter:
"""
Expand Down Expand Up @@ -1577,3 +1565,125 @@ def get_file_uploads(self, missing_blank=False):
files.append(file_upload)
self.file_uploads = files
return self.file_uploads


def score_type_to_string(score_type):
"""
Converts the given score type into its string representation.
"""
SCORE_TYPE_MAP = {
PEER_TYPE: "Peer",
SELF_TYPE: "Self",
STAFF_TYPE: "Staff",
}
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
return SCORE_TYPE_MAP.get(score_type, "Unknown")

def parts_summary(assessment_obj):
"""
Retrieves a summary of the parts from a given assessment object.
"""
return [
{
'criterion_name': part.criterion.name,
'score_earned': part.points_earned,
'score_type': part.option.name if part.option else "None",
}
for part in assessment_obj.parts.all()
]

def get_scorer_data(anonymous_scorer_id, user_data_mapping):
"""
Retrieves the scorer's data (full name, username, and email) based on their anonymous ID.
"""
scorer_data = user_data_mapping.get(anonymous_scorer_id, {})
scorer_name = scorer_data.get('fullname', "Unknown")
scorer_username = scorer_data.get('username', "Unknown")
scorer_email = scorer_data.get('email', "Unknown")
return scorer_name, scorer_username, scorer_email
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved

def generate_assessment_data(assessment_list, user_data_mapping):
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
results = []
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
for assessment in assessment_list:

scorer_name, scorer_username, scorer_email = get_scorer_data(assessment.scorer_id, user_data_mapping)

assessment_data = {
"id_assessment": str(assessment.id),
"scorer_name": scorer_name,
"scorer_username": scorer_username,
"scorer_email": scorer_email,
"assesment_date": assessment.scored_at.strftime('%d-%m-%Y'),
"assesment_scores": parts_summary(assessment),
"problem_step": score_type_to_string(assessment.score_type),
"feedback": assessment.feedback or ''
}

results.append(assessment_data)
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
return results

def generate_received_assessment_data(submission_uuid=None):
"""
Generates a list of received assessments data based on the submission UUID.

Args:
submission_uuid (str, optional): The UUID of the submission. Defaults to None.

Returns:
list[dict]: A list containing assessment data dictionaries.
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
"""

results = []
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved

submission = None
if submission_uuid:
submission = sub_api.get_submission_and_student(submission_uuid)

if not submission:
return results

assessments = _use_read_replica(
Assessment.objects.prefetch_related('parts').
prefetch_related('rubric').
filter(
submission_uuid=submission['uuid']
)
)
user_data_mapping = map_anonymized_ids_to_user_data([assessment.scorer_id for assessment in assessments])
return generate_assessment_data(assessments, user_data_mapping)


def generate_given_assessment_data(item_id=None, submission_uuid=None):
"""
Generates a list of given assessments data based on the submission UUID as scorer.

Args:
submission_uuid (str, optional): The UUID of the submission. Defaults to None.

Returns:
list[dict]: A list containing assessment data dictionaries.
"""
results = []
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
# Getting the scorer student id
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
scorer_submission = sub_api.get_submission_and_student(submission_uuid)

if not scorer_submission:
return results

scorer_id = scorer_submission['student_item']['student_id']
submissions = None
if item_id:
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
submissions = Submission.objects.filter(student_item__item_id=item_id).values('uuid')
submission_uuids = [sub['uuid'] for sub in submissions]

if not submission_uuids or not submissions:
return results

# Now fetch all assessments made by this student for these submissions
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
assessments_made_by_student = _use_read_replica(
Assessment.objects.prefetch_related('parts')
.prefetch_related('rubric')
.filter(scorer_id=scorer_id, submission_uuid__in=submission_uuids)
)

user_data_mapping = map_anonymized_ids_to_user_data([assessment.scorer_id for assessment in assessments_made_by_student])
return generate_assessment_data(assessments_made_by_student, user_data_mapping)
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
84 changes: 52 additions & 32 deletions openassessment/staffgrader/staff_grader_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@

from openassessment.assessment.models.base import Assessment, AssessmentPart
from openassessment.assessment.models.staff import StaffWorkflow, TeamStaffWorkflow
from openassessment.data import (OraSubmissionAnswerFactory, VersionNotFoundException, map_anonymized_ids_to_emails,
map_anonymized_ids_to_fullname, map_anonymized_ids_to_usernames)
from openassessment.data import (
OraSubmissionAnswerFactory,
VersionNotFoundException,
map_anonymized_ids_to_user_data,
generate_received_assessment_data,
generate_given_assessment_data
)
from openassessment.staffgrader.errors.submission_lock import SubmissionLockContestedError
from openassessment.staffgrader.models.submission_lock import SubmissionGradingLock
from openassessment.staffgrader.serializers import (
Expand Down Expand Up @@ -194,6 +199,39 @@ def list_staff_workflows(self, data, suffix=''): # pylint: disable=unused-argum
log.exception("Failed to serialize workflow %d: %s", staff_workflow.id, str(e), exc_info=True)
return result



@XBlock.json_handler
@require_course_staff("STUDENT_GRADE")
def list_assessments(self, data, suffix=''): # pylint: disable=unused-argument
"""
List the assessments' grades based on the type (received or given) for a specific submission.

Args:
data (dict): Contains the necessary information to fetch the assessments.
- 'item_id': The ID of the xblock/item.
- 'submission_uuid': The UUID of the submission.
- 'assessment_type': A string, either "received" or any other value
to determine the type of assessments to retrieve.

Returns:
list[dict]: A list of dictionaries, each representing an assessment's data.

Note:
- If 'assessment_type' is "received", the function fetches assessments received
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved
for the given 'submission_uuid'.
- For any other value of 'assessment_type', the function fetches assessments
given by the owner of the 'submission_uuid' for other submissions in the same item.
"""
item_id = data['item_id']
subission_uuid = data['submission_uuid']

if data['assessment_type'] == "received":
return generate_received_assessment_data(subission_uuid)
else:
return generate_given_assessment_data(item_id, subission_uuid)
Copy link
Member

@mariajgrimaldi mariajgrimaldi Oct 18, 2023

Choose a reason for hiding this comment

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

is this given and received as the assessment type present elsewhere? can we elaborate on the meaning of each?

Copy link
Member

Choose a reason for hiding this comment

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

I believe the default should be list all assessments for the submission uuid, so received.

Copy link
Member

Choose a reason for hiding this comment

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

why is a single handler better than separating concerns? list_assessments_received or list_assessments_given are more explicit than list_assessments..., but I don't have other strong arguments

Copy link
Author

Choose a reason for hiding this comment

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

In the MFE, it's just a table with a filter button; the data output has the same format. At the backend level, it's just a design decision that, compared to having two handlers, neither adds nor subtracts anything; it's pretty much the same.

Copy link
Member

@mariajgrimaldi mariajgrimaldi Oct 23, 2023

Choose a reason for hiding this comment

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

It's not the same. When using a single handler with a variable assessment_type, which defaults to given, you are making a decision there, you're saying the defaults assessments returned are given by students on the submission. When using two handlers, the client can actively decide which one to call without guessing the system's default. Don't you think?

Copy link
Author

Choose a reason for hiding this comment

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

Yeah! that's true... but it does not exists a request without assessment_type (now called assessment_filter), it will always be given or received...

image

In fact, it is a required param in the AssessmentFeedbackView of edx-platform and declared in lms/djangoapps/ora_staff_grader/constants.py so, list_assessments() would be never called without assessment_filter...

jeje, but now tha you are saying this... I think that in form, this will look even better 😃

        filter_value = data['assessment_filter']

        if filter_value == "received":
            return generate_received_assessment_data(subission_uuid)
        elif filter_value == "given":
            return generate_given_assessment_data(item_id, subission_uuid)
        else:
            raise ValueError("Invalid assessment_filter value")



def _get_list_workflows_serializer_context(self, staff_workflows, is_team_assignment=False):
"""
Fetch additional required data and models to serialize the response
Expand All @@ -206,53 +244,35 @@ def _get_list_workflows_serializer_context(self, staff_workflows, is_team_assign
workflow_scorer_ids.add(workflow.scorer_id)
course_id = self.get_student_item_dict()['course_id']

# Fetch user identifier mappings
context = {}

if is_team_assignment:
# Look up the team IDs for submissions so we can later map to team names
team_submission_uuid_to_team_id = get_team_ids_by_team_submission_uuid(submission_uuids)

# Look up names for teams
topic_id = self.selected_teamset_id
team_id_to_team_name = self.teams_service.get_team_names(course_id, topic_id)

# Do bulk lookup for scorer anonymous ids (submitting team name is a separate lookup)
anonymous_id_to_username = map_anonymized_ids_to_usernames(
set(workflow_scorer_ids)
)
anonymous_id_to_email = map_anonymized_ids_to_emails(
set(workflow_scorer_ids)
)
anonymous_id_to_fullname = map_anonymized_ids_to_fullname(
set(workflow_scorer_ids)
)

context = {
context.update({
'team_submission_uuid_to_team_id': team_submission_uuid_to_team_id,
'team_id_to_team_name': team_id_to_team_name,
}
})
else:
# When we look up usernames we want to include all connected learner student ids
submission_uuid_to_student_id = get_student_ids_by_submission_uuid(
course_id,
submission_uuids,
)
context['submission_uuid_to_student_id'] = submission_uuid_to_student_id

# Do bulk lookup for all anonymous ids (submitters and scoreres). This is used for the
# `gradedBy` and `username` fields
anonymous_id_to_username = map_anonymized_ids_to_usernames(
set(submission_uuid_to_student_id.values()) | workflow_scorer_ids
)
anonymous_id_to_email = map_anonymized_ids_to_emails(
set(submission_uuid_to_student_id.values()) | workflow_scorer_ids
)

anonymous_id_to_fullname = map_anonymized_ids_to_fullname(
set(submission_uuid_to_student_id.values()) | workflow_scorer_ids
)
all_anonymous_ids = set(workflow_scorer_ids)
if not is_team_assignment:
all_anonymous_ids |= set(context['submission_uuid_to_student_id'].values())

context = {
'submission_uuid_to_student_id': submission_uuid_to_student_id,
}
anonymous_id_to_user_data = map_anonymized_ids_to_user_data(all_anonymous_ids)
anonymous_id_to_username = {key: val["username"] for key, val in anonymous_id_to_user_data.items()}
anonymous_id_to_email = {key: val["email"] for key, val in anonymous_id_to_user_data.items()}
anonymous_id_to_fullname = {key: val["fullname"] for key, val in anonymous_id_to_user_data.items()}
nandodev-net marked this conversation as resolved.
Show resolved Hide resolved

# Do a bulk fetch of the assessments linked to the workflows, including all connected
# Rubric, Criteria, and Option models
Expand Down
Loading