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

PoC grades sync #6681

Closed
wants to merge 3 commits into from
Closed
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
3 changes: 3 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 Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Migration for grading_sync tables

Revision ID: f68aacfc62c7
Revises: adc83819c8d8
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

revision = 'f68aacfc62c7'
down_revision = 'adc83819c8d8'


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('grading_sync',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('scheduled', 'in_progress', 'finished', 'failed', name='autogradingsyncstatus', native_enum=False, length=64), nullable=False),
sa.Column('created', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['assignment_id'], ['assignment.id'], name=op.f('fk__grading_sync__assignment_id__assignment')),
sa.PrimaryKeyConstraint('id', name=op.f('pk__grading_sync'))
)
op.create_index(op.f('ix__grading_sync_assignment_id'), 'grading_sync', ['assignment_id'], unique=False)
op.create_table('grading_sync_grade',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('grading_sync_id', sa.Integer(), nullable=False),
sa.Column('lms_user_id', sa.Integer(), nullable=False),
sa.Column('grade', sa.Float(), nullable=True),
Copy link
Contributor

Choose a reason for hiding this comment

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

Why is grade nullable? Is there some case in which we want to sync with no grade?

Copy link
Member Author

@marcospri marcospri Sep 13, 2024

Choose a reason for hiding this comment

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

Grade is null when we "schedule" the grade sync.

Having the rows in the DB simplifies the queries to know if we are already done, we can just query this table instead of having to store the expected result in the GradeSync table.

Copy link
Contributor

@acelaya acelaya Sep 13, 2024

Choose a reason for hiding this comment

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

Oh, I thought grade would hold the value that is supposed to be synced when the corresponding task runs. But if I'm getting what you say, this value is set later, once the actual sync with the LMS has succeeded?

If that is the case, how does the celery task know what is the grade that needs to be sent to the LMS?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, you are right, have done a few iterations of this locally and I was describing the previous one.

Grade should not be nullable here

sa.Column('extra', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False),
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the extra field for?

Copy link
Member Author

Choose a reason for hiding this comment

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

Right now it just to store info about the error for failed syncs, we could just name it "error_details" or simlar.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I would personally call it error_details, or at least result_details, that way it's more clear it's not supposed to be set until the sync has finished, and holds additional details of how it finished and why the status is the one it is.

Copy link
Member Author

Choose a reason for hiding this comment

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

Went for error_details.

sa.Column('success', sa.Boolean(), nullable=True),
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it make sense to track a status here, like in the parent table? Would we need to know if it's in progress, or just of it succeeded or not?

Copy link
Member Author

@marcospri marcospri Sep 13, 2024

Choose a reason for hiding this comment

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

Yeah we could use status instead of this, in this PR the status is:

Null grade & Null success, scheduled
Null grade & success = False, failed
grade & success = True, succeeded

This is actually simpler:

Null success, scheduled
success = true, succeed
success = false, failed

Copy link
Contributor

Choose a reason for hiding this comment

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

I don't have a strong opinion here. If you think using a boolean we can infer all required information, fine by me.

sa.Column('created', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.Column('updated', sa.DateTime(), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['grading_sync_id'], ['grading_sync.id'], name=op.f('fk__grading_sync_grade__grading_sync_id__grading_sync')),
sa.ForeignKeyConstraint(['lms_user_id'], ['lms_user.id'], name=op.f('fk__grading_sync_grade__lms_user_id__lms_user')),
sa.PrimaryKeyConstraint('id', name=op.f('pk__grading_sync_grade'))
)
op.create_index(op.f('ix__grading_sync_grade_grading_sync_id'), 'grading_sync_grade', ['grading_sync_id'], unique=False)
op.create_index(op.f('ix__grading_sync_grade_lms_user_id'), 'grading_sync_grade', ['lms_user_id'], unique=False)
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix__grading_sync_grade_lms_user_id'), table_name='grading_sync_grade')
op.drop_index(op.f('ix__grading_sync_grade_grading_sync_id'), table_name='grading_sync_grade')
op.drop_table('grading_sync_grade')
op.drop_index(op.f('ix__grading_sync_assignment_id'), table_name='grading_sync')
op.drop_table('grading_sync')
# ### end Alembic commands ###
1 change: 1 addition & 0 deletions lms/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from lms.models.dashboard_admin import DashboardAdmin
from lms.models.event import Event, EventData, EventType, EventUser
from lms.models.exceptions import ReusedConsumerKey
from lms.models.grading_sync import GradingSync, GradingSyncGrade
from lms.models.file import File
from lms.models.grading_info import GradingInfo
from lms.models.group_info import GroupInfo
Expand Down
49 changes: 49 additions & 0 deletions lms/models/grading_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from sqlalchemy.orm import Mapped, mapped_column, relationship
from lms.models._mixins import CreatedUpdatedMixin

from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import ForeignKey, text
from enum import StrEnum
from lms.db import Base, varchar_enum
from lms.models import Assignment


class AutoGradingSyncStatus(StrEnum):
SCHEDULED = "scheduled"
IN_PROGRESS = "in_progress"
FINISHED = "finished"
FAILED = "failed"


class GradingSync(CreatedUpdatedMixin, Base):
Copy link
Member Author

Choose a reason for hiding this comment

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

Represents a "sync"

__tablename__ = "grading_sync"

id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)

assignment_id: Mapped[int] = mapped_column(ForeignKey("assignment.id"), index=True)
assignment: Mapped[Assignment] = relationship()

status: Mapped[str | None] = varchar_enum(AutoGradingSyncStatus)


class GradingSyncGrade(CreatedUpdatedMixin, Base):
Copy link
Member Author

Choose a reason for hiding this comment

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

Represents each of the individual grades of a sync

__tablename__ = "grading_sync_grade"

id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)

grading_sync_id: Mapped[int] = mapped_column(
ForeignKey("grading_sync.id"), index=True
)
grading_sync: Mapped[GradingSync] = relationship(backref="grades")

lms_user_id: Mapped[int] = mapped_column(ForeignKey("lms_user.id"), index=True)
lms_user: Mapped["LMSUser"] = relationship()

grade: Mapped[float | None] = mapped_column()

extra: Mapped[JSONB] = mapped_column(
JSONB(),
server_default=text("'{}'::jsonb"),
nullable=False,
)
Comment on lines +44 to +48
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you add a comment of what is extra for? I think the rest are self-explanatory.

success: Mapped[bool | None] = mapped_column()
3 changes: 3 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,9 @@ 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"
),
),
),
}
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(
"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
2 changes: 1 addition & 1 deletion lms/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ def get_lti_user(request) -> LTIUser | None:
if lti_user:
# Make a record of the user for analytics so we can map from the
# LTI users and the corresponding user in H
request.find_service(UserService).upsert_user(lti_user, request.lti_params)
request.find_service(UserService).upsert_user(lti_user)
marcospri marked this conversation as resolved.
Show resolved Hide resolved

# Attach useful information to sentry in case we get an exception further down the line
sentry_sdk.set_tag("application_instance_id", lti_user.application_instance_id)
Expand Down
21 changes: 20 additions & 1 deletion lms/services/auto_grading.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from lms.js_config_types import AnnotationMetrics
from lms.models import AutoGradingConfig
from sqlalchemy import select
from lms.models import AutoGradingConfig, GradingSync, GradingSyncGrade, LMSUser


def calculate_grade(
Expand Down Expand Up @@ -52,3 +53,21 @@ def calculate_grade(

grade = min(100, grade) # Proportional grades are capped at 100%
return round(grade, 2)


def get_last_synced_grade_for_h_userids(db, h_userids: list[str], assignment_id):
Copy link
Member Author

Choose a reason for hiding this comment

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

Query all the previous grades from a list of students. Useful to display it on metrics endpoint.

result = db.execute(
select(LMSUser.h_userid, GradingSyncGrade.grade)
.distinct(LMSUser.h_userid)
.join(GradingSync)
.join(LMSUser)
.where(
GradingSync.assignment_id == assignment_id,
GradingSyncGrade.success == True,
LMSUser.h_userid.in_(h_userids),
)
.order_by(LMSUser.h_userid, GradingSyncGrade.updated.desc())
).all()
print(result)

return {r[0]: r[1] for r in result}
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
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
50 changes: 42 additions & 8 deletions lms/services/lti_grading/_v13.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,36 @@ 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
self,
lti_registration: LTIRegistration,
lis_outcome_service_url: str,
grade_timestamp: str,
Copy link
Member Author

Choose a reason for hiding this comment

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

Take the string here directly

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)
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 +204,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
11 changes: 6 additions & 5 deletions lms/services/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ def __init__(self, db, h_authority: str):
self._db = db
self._h_authority = h_authority

def upsert_user(self, lti_user: LTIUser, lti_params: LTIParams) -> User:
def upsert_user(self, lti_user: LTIUser) -> User:
"""Store a record of having seen a particular user."""

# Note! - Storing a user in our DB currently has an implication for
Expand Down Expand Up @@ -65,12 +65,14 @@ def upsert_user(self, lti_user: LTIUser, lti_params: LTIParams) -> User:
# We are only storing emails for teachers now.
user.email = lti_user.email

self._upsert_lms_user(user, lti_params)
return user

def _upsert_lms_user(self, user: User, lti_params: LTIParams) -> LMSUser:
def upsert_lms_user(self, user: User, lti_params: LTIParams) -> LMSUser:
marcospri marked this conversation as resolved.
Show resolved Hide resolved
"""Upsert LMSUser based on a User object."""
self._db.flush() # Make sure User has hit the DB on the current transaction
print(user)
marcospri marked this conversation as resolved.
Show resolved Hide resolved
print(lti_params.v13.get("sub"))
print(lti_params)

lms_user = bulk_upsert(
self._db,
Expand Down Expand Up @@ -195,8 +197,7 @@ def get_users( # noqa: PLR0913, PLR0917
query = query.where(User.h_userid.in_(h_userids))

return (
select(User)
.where(User.id.in_(query))
select(User).where(User.id.in_(query))
# We can sort these again without affecting deduplication
.order_by(User.display_name, User.id)
)
Expand Down
Loading
Loading