-
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
PoC grades sync #6681
PoC grades sync #6681
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 |
---|---|---|
@@ -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), | ||
sa.Column('extra', postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), | ||
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. What is the 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. Right now it just to store info about the error for failed syncs, we could just name it "error_details" or simlar. 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. Yeah, I would personally call it 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. Went for |
||
sa.Column('success', sa.Boolean(), nullable=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. Would it make sense to track a 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. Yeah we could use status instead of this, in this PR the status is:
This is actually simpler: Null success, scheduled 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 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 ### |
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): | ||
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. 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): | ||
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. 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
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. Could you add a comment of what is extra for? I think the rest are self-explanatory. |
||
success: Mapped[bool | None] = mapped_column() |
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( | ||
|
@@ -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): | ||
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. 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} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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, | ||
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. 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 | ||
|
@@ -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): | ||
""" | ||
|
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.
Why is grade nullable? Is there some case in which we want to sync with no grade?
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.
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.
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.
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?
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.
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