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

Sync grades back to the LMS for auto grading #6672

Merged
merged 3 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions lms/js_config_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ class DashboardRoutes(TypedDict):
students: str
"""Paginated endpoint to fetch students"""

assignment_grades_sync: str
"""Sync grades for a given assignment"""


class User(TypedDict):
is_staff: bool
Expand All @@ -127,3 +130,6 @@ class User(TypedDict):
class DashboardConfig(TypedDict):
user: User
routes: DashboardRoutes

auto_grading_sync_enabled: bool
"""Whether or nor the opotion to sync grades back to the LMS is enabled."""
1 change: 1 addition & 0 deletions lms/models/application_instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class ApplicationSettings(JSONSettings):
JSONSetting("hypothesis", "instructor_email_digests_enabled", asbool),
JSONSetting("hypothesis", "lti_13_sourcedid_for_grading", asbool),
JSONSetting("hypothesis", "auto_grading_enabled", asbool),
JSONSetting("hypothesis", "auto_grading_sync_enabled", asbool),
)


Expand Down
7 changes: 7 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ def enable_dashboard_mode(self, token_lifetime_seconds: int) -> None:
"api.dashboard.assignments"
),
students=self._to_frontend_template("api.dashboard.students"),
assignment_grades_sync=self._to_frontend_template(
"api.dashboard.assignments.grading.sync"
),
),
auto_grading_sync_enabled=self._lti_user
and self._application_instance.settings.get(
"hypothesis", "auto_grading_sync_enabled", False
),
),
}
Expand Down
4 changes: 4 additions & 0 deletions lms/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@ def includeme(config): # noqa: PLR0915
config.add_route(
"api.dashboard.assignment", r"/api/dashboard/assignments/{assignment_id}"
)
config.add_route(
Copy link
Member Author

Choose a reason for hiding this comment

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

New API endpoint

"api.dashboard.assignments.grading.sync",
"/api/dashboard/assignments/{assignment_id}/grading/sync",
)
config.add_route("api.dashboard.assignments", "/api/dashboard/assignments")
config.add_route(
"api.dashboard.course.assignments.metrics",
Expand Down
4 changes: 2 additions & 2 deletions lms/services/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from pyramid.httpexceptions import HTTPNotFound, HTTPUnauthorized
from sqlalchemy import select

from lms.models import Assignment, Organization
Copy link
Member Author

Choose a reason for hiding this comment

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

Adding some type annos around here to get autocompletion.

from lms.models.dashboard_admin import DashboardAdmin
from lms.models.organization import Organization
from lms.security import Permissions
from lms.services.organization import OrganizationService

Expand All @@ -21,7 +21,7 @@ def __init__(
self._course_service = course_service
self._organization_service = organization_service

def get_request_assignment(self, request):
def get_request_assignment(self, request) -> Assignment:
"""Get and authorize an assignment for the given request."""
assigment_id = request.matchdict.get(
"assignment_id"
Expand Down
52 changes: 44 additions & 8 deletions lms/services/lti_grading/_v13.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,38 @@ def read_result(self, grading_id) -> GradingResult:
def get_score_maximum(self, resource_link_id) -> float | None:
return self._read_grading_configuration(resource_link_id).get("scoreMaximum")

def sync_grade( # noqa: PLR0913
Copy link
Member Author

Choose a reason for hiding this comment

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

New method to sync an individual grade.

We don't use the existing record_result because:

  • We'd need to backport now any changes here for the LTI1.1 as the share a common interface.

  • That method is more complex as it deals with comments (that might need to be formatted before), "pre_record_hook", to change the shape of the payload...

I reckon the simple method is the best solution right now.

self,
lti_registration: LTIRegistration,
lis_outcome_service_url: str,
grade_timestamp: datetime,
user_grading_id: str,
score: float,
):
"""
Send a grade to the LMS.

This is very similar to `record_result` but not scoped to the request context,
taking all the necessary information as parameters.
"""
payload = self._record_score_payload(
score, user_grading_id, grade_timestamp.isoformat()
)
return self._ltia_service.request(
lti_registration,
"POST",
self._service_url(lis_outcome_service_url, "/scores"),
scopes=self.LTIA_SCOPES,
json=payload,
headers={"Content-Type": "application/vnd.ims.lis.v1.score+json"},
)

def record_result(self, grading_id, score=None, pre_record_hook=None, comment=None):
payload = {
"scoreMaximum": 1,
"scoreGiven": score,
"userId": grading_id,
"timestamp": datetime.now(timezone.utc).isoformat(),
"activityProgress": "Completed",
"gradingProgress": "FullyGraded",
}
payload = self._record_score_payload(
score,
grading_id,
datetime.now(timezone.utc).isoformat(),
)
if comment:
payload["comment"] = self._misc_plugin.format_grading_comment_for_lms(
comment
Expand Down Expand Up @@ -183,6 +206,19 @@ def _read_grading_configuration(self, resource_link_id) -> dict:

return {}

@staticmethod
def _record_score_payload(
score: float | None, user_grading_id: str, timestamp: str
):
return {
"scoreMaximum": 1,
"scoreGiven": score,
"userId": user_grading_id,
"timestamp": timestamp,
"activityProgress": "Completed",
"gradingProgress": "FullyGraded",
}

@staticmethod
def _service_url(base_url, endpoint):
"""
Expand Down
1 change: 1 addition & 0 deletions lms/templates/admin/application_instance/show.html.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
{{ settings_checkbox('Enable instructor email digests', 'hypothesis', 'instructor_email_digests_enabled') }}
{{ settings_checkbox("Use alternative parameter for LTI1.3 grading", "hypothesis", "lti_13_sourcedid_for_grading", default=False) }}
{{ settings_checkbox("Enable auto-grading", "hypothesis", "auto_grading_enabled", default=False) }}
{{ settings_checkbox("Enable auto-grading grade sync", "hypothesis", "auto_grading_sync_enabled", default=False) }}
</fieldset>
<fieldset class="box">
<legend class="label has-text-centered">Canvas settings</legend>
Expand Down
86 changes: 86 additions & 0 deletions lms/views/dashboard/api/grading.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import logging
from datetime import datetime

from marshmallow import Schema, fields
from pyramid.view import view_config
from sqlalchemy import select

from lms.models import (
ApplicationInstance,
Grouping,
LMSUser,
LTIRegistration,
)
from lms.security import Permissions
from lms.services import LTIAHTTPService
from lms.services.dashboard import DashboardService
from lms.services.lti_grading.factory import LTI13GradingService
from lms.validation._base import JSONPyramidRequestSchema

LOG = logging.getLogger(__name__)


class _GradeSchema(Schema):
h_userid = fields.Str(required=True)
grade = fields.Float(required=True)


class AutoGradeSyncSchema(JSONPyramidRequestSchema):
grades = fields.List(fields.Nested(_GradeSchema), required=True)
Copy link
Member Author

Choose a reason for hiding this comment

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

The endpoint takes the grades from the frontend. I reckon this makes sense because:

  • It saves the BE to doing double work of calculating the grades again.
  • It guarantees that what you see in the dashboard is what we are going to send to the LMS

Copy link
Contributor

Choose a reason for hiding this comment

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

I was thinking about this as well, and I also think it makes sense.

It would even allow to eventually let instructors adjust the pre-calculated grade before syncing, right from the dashboard, if at some point we want to allow that.



class DashboardGradingViews:
def __init__(self, request) -> None:
self.request = request
self.db = request.db
self.dashboard_service: DashboardService = request.find_service(
name="dashboard"
)

@view_config(
route_name="api.dashboard.assignments.grading.sync",
request_method="POST",
renderer="json",
permission=Permissions.GRADE_ASSIGNMENT,
schema=AutoGradeSyncSchema,
)
def auto_grading_sync(self):
Copy link
Member Author

Choose a reason for hiding this comment

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

This is an endpoint that would need many changes before is ready for the MVP:

  • Don't start a sync if there's already one running.
  • Run the actual sync of grades asynchronously on a celery task.

I also image we'll need a GET endpoint to fetch progress (state of the last one?) of any running sync.

assignment = self.dashboard_service.get_request_assignment(self.request)
assert assignment.lis_outcome_service_url, "Assignment without grading URL"
lti_registration = self.db.scalars(
select(LTIRegistration)
.join(ApplicationInstance)
.join(Grouping)
.where(Grouping.id == assignment.course_id)
.order_by(LTIRegistration.updated.desc())
).first()
assert lti_registration, "No LTI registraion for LTI1.3 assignment"

sync_h_user_ids = [g["h_userid"] for g in self.request.parsed_params["grades"]]

sync_lms_users = self.db.execute(
select(LMSUser.h_userid, LMSUser.lti_v13_user_id).where(
LMSUser.h_userid.in_(sync_h_user_ids)
)
).all()
# Organize the data in a dict h_userid -> lti_user_id for easier access
sync_lms_users_by_h_userid = {r[0]: r[1] for r in sync_lms_users}

grading_service = LTI13GradingService(
ltia_service=self.request.find_service(LTIAHTTPService),
line_item_url=None,
line_item_container_url=None,
product_family=None, # type: ignore
Copy link
Member Author

Choose a reason for hiding this comment

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

This shouldn't be None in the grading service but to refactor that we have to refactor the v11 version as well as they share an interface. I'll have to do that once we have the MVP.

Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps it makes sense to optionally inject a grading service in the constructor, and fall back to this if not provided. That way you can still use this at runtime, but pass a mock in tests.

Copy link
Member Author

Choose a reason for hiding this comment

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

Oh I see, but views are "built" by the framework itself.

This sticks out like a shore thumb so I reckon the right fix is to refactor the service to take this as parameters in v11 and v13.

Crated a note on the main issue to keep track of that.

Copy link
Contributor

Choose a reason for hiding this comment

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

but views are "built" by the framework itself.

Oh, I didn't think about that. Let's keep it as is for now and apply your suggested improvements afterwards.

misc_plugin=None, # type: ignore
lti_registration=None, # type: ignore
)
# Use the same timestamp for all grades of the same sync
grade_sync_time_stamp = datetime.now()
for grade in self.request.parsed_params["grades"]:
grading_service.sync_grade(
lti_registration,
assignment.lis_outcome_service_url,
grade_sync_time_stamp,
sync_lms_users_by_h_userid[grade["h_userid"]],
grade["grade"],
)
17 changes: 16 additions & 1 deletion tests/unit/lms/resources/_js_config/__init___test.py
Original file line number Diff line number Diff line change
Expand Up @@ -713,7 +713,18 @@ def LTIEvent(self, patch):


class TestEnableDashboardMode:
def test_it(self, js_config, lti_user, bearer_token_schema):
@pytest.mark.parametrize("auto_grading_sync_setting", [True, False])
def test_it(
self,
js_config,
lti_user,
bearer_token_schema,
auto_grading_sync_setting,
application_instance,
):
application_instance.settings.set(
"hypothesis", "auto_grading_sync_enabled", auto_grading_sync_setting
)
js_config.enable_dashboard_mode(token_lifetime_seconds=100)
config = js_config.asdict()

Expand All @@ -735,7 +746,9 @@ def test_it(self, js_config, lti_user, bearer_token_schema):
"courses": "/api/dashboard/courses",
"assignments": "/api/dashboard/assignments",
"students": "/api/dashboard/students",
"assignment_grades_sync": "/api/dashboard/assignments/:assignment_id/grading/sync",
},
"auto_grading_sync_enabled": auto_grading_sync_setting,
}

def test_user_when_staff(self, js_config, pyramid_request_staff_member, context):
Expand All @@ -747,6 +760,8 @@ def test_user_when_staff(self, js_config, pyramid_request_staff_member, context)
"is_staff": True,
"display_name": "[email protected]",
}
# Grade syncing always disabled for staff
assert not config["dashboard"]["auto_grading_sync_enabled"]

@pytest.fixture
def pyramid_request_staff_member(self, pyramid_config, pyramid_request):
Expand Down
28 changes: 28 additions & 0 deletions tests/unit/lms/services/lti_grading/_v13_test.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from datetime import datetime
from unittest.mock import Mock, sentinel

import pytest
Expand Down Expand Up @@ -136,6 +137,33 @@ def test_get_score_maximum_no_line_item(self, svc, ltia_http_service):

assert not svc.get_score_maximum(sentinel.resource_link_id)

def test_sync_grade(self, svc, ltia_http_service, lti_registration):
response = svc.sync_grade(
lti_registration,
"LIS_OUTCOME_SERVICE_URL",
datetime(2022, 4, 4),
sentinel.user_id,
sentinel.grade,
)

payload = {
"scoreMaximum": 1,
"scoreGiven": sentinel.grade,
"userId": sentinel.user_id,
"timestamp": "2022-04-04T00:00:00",
"activityProgress": "Completed",
"gradingProgress": "FullyGraded",
}
ltia_http_service.request.assert_called_once_with(
lti_registration,
"POST",
"LIS_OUTCOME_SERVICE_URL/scores",
scopes=svc.LTIA_SCOPES,
json=payload,
headers={"Content-Type": "application/vnd.ims.lis.v1.score+json"},
)
assert response == ltia_http_service.request.return_value

@freeze_time("2022-04-04")
@pytest.mark.parametrize("comment", [sentinel.comment, None])
def test_record_result(
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/lms/views/dashboard/api/grading_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from datetime import datetime

import pytest
from freezegun import freeze_time

from lms.views.dashboard.api.grading import DashboardGradingViews
from tests import factories


@pytest.mark.usefixtures("dashboard_service", "ltia_http_service")
class TestDashboardGradingViews:
@freeze_time("2022-06-21 12:00:00")
def test_auto_grading_sync(
self,
lti_v13_application_instance,
pyramid_request,
views,
DashboardGradingView,
ltia_http_service,
dashboard_service,
assignment,
):
dashboard_service.get_request_assignment.return_value = assignment

views.auto_grading_sync()

dashboard_service.get_request_assignment.assert_called_once_with(
pyramid_request
)
DashboardGradingView.assert_called_once_with(
ltia_service=ltia_http_service,
line_item_url=None,
line_item_container_url=None,
product_family=None,
misc_plugin=None,
lti_registration=None,
)
DashboardGradingView.return_value.sync_grade.assert_called_once_with(
lti_v13_application_instance.lti_registration,
"LIS_OUTCOME_SERVICE_URL",
datetime(2022, 6, 21, 12, 0, 0),
"LTI_V13_USER_ID",
1,
)

@pytest.fixture
def assignment(self, lti_v13_application_instance, db_session):
course = factories.Course(application_instance=lti_v13_application_instance)
assignment = factories.Assignment(
lis_outcome_service_url="LIS_OUTCOME_SERVICE_URL", course=course
)
db_session.flush()
return assignment

@pytest.fixture
def lms_user(self):
return factories.LMSUser(lti_v13_user_id="LTI_V13_USER_ID")

@pytest.fixture
def pyramid_request(self, pyramid_request, lms_user):
pyramid_request.parsed_params = {
"grades": [{"h_userid": lms_user.h_userid, "grade": 1}]
}
return pyramid_request

@pytest.fixture
def views(self, pyramid_request):
return DashboardGradingViews(pyramid_request)

@pytest.fixture
def DashboardGradingView(self, patch):
return patch("lms.views.dashboard.api.grading.LTI13GradingService")
Loading