-
Notifications
You must be signed in to change notification settings - Fork 14
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
||
|
@@ -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" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
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 | ||
|
@@ -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): | ||
""" | ||
|
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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"], | ||
) |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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() | ||
|
||
|
@@ -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): | ||
|
@@ -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): | ||
|
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") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New API endpoint