Skip to content

Commit

Permalink
Merge pull request #78 from helxplatform/manual-grading
Browse files Browse the repository at this point in the history
Manual grading
  • Loading branch information
Hoid authored Sep 13, 2024
2 parents 9dcb664 + c13ed17 commit 16f86e5
Show file tree
Hide file tree
Showing 17 changed files with 338 additions and 99 deletions.
28 changes: 28 additions & 0 deletions alembic/versions/bdf5e21a88df_add_manual_grading_field.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""add manual grading field
Revision ID: bdf5e21a88df
Revises: 81b093623398
Create Date: 2024-08-26 17:50:41.774644+00:00
"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = 'bdf5e21a88df'
down_revision = '81b093623398'
branch_labels = None
depends_on = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('assignment', sa.Column('manual_grading', sa.Boolean(), server_default='f', nullable=False))
# ### end Alembic commands ###


def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('assignment', 'manual_grading')
# ### end Alembic commands ###
55 changes: 48 additions & 7 deletions app/api/api_v1/endpoints/assignment_router.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
from pydantic import BaseModel, PositiveInt
from datetime import datetime
from typing import List, Union
from typing import List, Union, Optional
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.models import AssignmentModel, StudentModel, InstructorModel
from app.schemas import InstructorAssignmentSchema, StudentAssignmentSchema, AssignmentSchema, UpdateAssignmentSchema, GradeReportSchema
from app.schemas import (
InstructorAssignmentSchema, StudentAssignmentSchema, AssignmentSchema,
UpdateAssignmentSchema, GradeReportSchema, IdentifiableSubmissionGradeSchema
)
from app.schemas._unset import UNSET
from app.services import (
AssignmentService, InstructorAssignmentService, StudentAssignmentService,
UserService, LmsSyncService, GradingService
UserService, LmsSyncService, GradingService, SubmissionService
)
from app.core.dependencies import get_db, PermissionDependency, RequireLoginPermission, AssignmentModifyPermission, UserIsInstructorPermission
from app.services.course_service import CourseService
Expand All @@ -26,11 +29,21 @@ class UpdateAssignmentBody(BaseModel):
available_date: datetime | None
due_date: datetime | None
is_published: bool = UNSET
manual_grading: bool = UNSET

class GradingBody(BaseModel):
class OtterGradingBody(BaseModel):
master_notebook_content: str
otter_config_content: str

class ManualGrade(BaseModel):
submission_id: int
# Between [0,1]
grade_proportion: float
comments: Optional[str]

class ManualGradingBody(BaseModel):
grade_data: list[ManualGrade]

@router.patch("/assignments/{assignment_name}", response_model=AssignmentSchema)
async def update_assignment_fields(
*,
Expand Down Expand Up @@ -89,13 +102,41 @@ async def grade_assignment(
request: Request,
db: Session = Depends(get_db),
assignment_name: str,
grading_body: GradingBody,
grading_body: OtterGradingBody,
perm: None = Depends(PermissionDependency(UserIsInstructorPermission))
):
assignment = await AssignmentService(db).get_assignment_by_name(assignment_name)
grade_report = await GradingService(db).grade_assignment(
return await GradingService(db).grade_assignment(
assignment,
grading_body.master_notebook_content,
grading_body.otter_config_content
)
return grade_report

@router.post(
"/assignments/{assignment_name}/grade_manual",
response_model=GradeReportSchema
)
async def grade_assignment_manual(
*,
request: Request,
db: Session = Depends(get_db),
assignment_name: str,
grading_body: ManualGradingBody,
perm: None = Depends(PermissionDependency(UserIsInstructorPermission))
):
assignment = await AssignmentService(db).get_assignment_by_name(assignment_name)

grade_submissions = []
for manual_grade in grading_body.grade_data:
submission = await SubmissionService(db).get_submission_by_id(manual_grade.submission_id)
grade_submissions.append(IdentifiableSubmissionGradeSchema(
score=manual_grade.grade_proportion * 100,
total_points=100,
comments=manual_grade.comments,
submission_already_graded=submission.graded,
submission_id=submission.id
))
return await GradingService(db).grade_assignment_manually(
assignment,
grade_submissions
)
14 changes: 10 additions & 4 deletions app/api/api_v1/endpoints/submission_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from fastapi.responses import FileResponse, StreamingResponse
from sqlalchemy.orm import Session
from app.schemas import SubmissionSchema
from app.services import SubmissionService, StudentService, AssignmentService, GiteaService, CourseService
from app.services import SubmissionService, StudentService, AssignmentService, GiteaService, CourseService, LmsSyncService
from app.models import SubmissionModel
from app.core.dependencies import get_db, PermissionDependency, UserIsStudentPermission, SubmissionCreatePermission, SubmissionListPermission, SubmissionDownloadPermission

Expand All @@ -13,24 +13,30 @@
class SubmissionBody(BaseModel):
assignment_id: int
commit_id: str
student_notebook_content: str

@router.post("/submissions", response_model=SubmissionSchema)
async def create_submission(
*,
request: Request,
db: Session = Depends(get_db),
perm: None = Depends(PermissionDependency(UserIsStudentPermission, SubmissionCreatePermission)),
submission: SubmissionBody
submission_body: SubmissionBody
):
onyen = request.user.onyen

submission_service = SubmissionService(db)
student = await StudentService(db).get_user_by_onyen(onyen)
assignment = await AssignmentService(db).get_assignment_by_id(submission.assignment_id)
assignment = await AssignmentService(db).get_assignment_by_id(submission_body.assignment_id)
submission = await submission_service.create_submission(
student,
assignment,
commit_id=submission.commit_id
commit_id=submission_body.commit_id
)

await LmsSyncService(db).upsync_submission(
submission,
submission_body.student_notebook_content.encode()
)

return await submission_service.get_submission_schema(submission)
Expand Down
2 changes: 2 additions & 0 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ class Settings(BaseSettings):
API_V1_STR: str = "/api/v1"
DEV_PHASE: DevPhase = DevPhase.PROD
DISABLE_AUTHENTICATION: bool = False
# For dev work, this thing is super super annoying. May cause instability.
DISABLE_LOGGER: bool = False
IMPERSONATE_USER: Optional[str] = None
DOCUMENTATION_URL: Optional[str] = None

Expand Down
17 changes: 16 additions & 1 deletion app/core/exceptions/grading.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,19 @@
class OtterConfigViolationException(CustomException):
code = 403
error_code = "GRADING__OTTER_CONFIG_VIOLATION"
message = "otterconfig is invalid or cannot be generated for assignment"
message = "otterconfig is invalid or cannot be generated for the assignment"

class AutogradingDisabledException(CustomException):
code = 400
error_code = "GRADING__AUTOGRADING_DISABLED"
message = "autograding has been disabled for the assignment"

class StudentGradedMultipleTimesException(CustomException):
code = 400
error_code = "GRADING__STUDENT_GRADED_MULTIPLE_TIMES"
message = "only 1 submission per student is permitted during grading"

class SubmissionMismatchException(CustomException):
code = 400
error_code = "GRADING__SUBMISSION_MISMATCH"
message = "submission is not associated with the assignment being graded"
13 changes: 13 additions & 0 deletions app/core/utils/mime_helper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from pathlib import Path
from typing import IO
from mimetypes import guess_type

def guess_mimetype(path: str | Path) -> str | None:
ext = Path(path).suffix.lstrip(".").lower()
if ext == "ipynb":
return "application/x-ipynb+json"
if ext == "r" or ext == "rmd":
# R does not define MIME types for their file extensions.
# This is reiterated by extramime from Yihui Xie's R mime package.
return "text/plain"
return guess_type(path)[0]
7 changes: 4 additions & 3 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@ def create_app() -> FastAPI:
openapi_url=f"{ settings.API_V1_STR }/openapi.json",
middleware=make_middleware()
)
logger = CustomizeLogger.make_logger(config_path)
app.logger = logger
app.add_middleware(LogMiddleware)
if not settings.DISABLE_LOGGER:
logger = CustomizeLogger.make_logger(config_path)
app.logger = logger
app.add_middleware(LogMiddleware)
init_monkeypatch()
init_routers(app)
init_listeners(app)
Expand Down
5 changes: 5 additions & 0 deletions app/models/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ class AssignmentModel(Base):
due_date = Column(DateTime(timezone=True))
last_modified_date = Column(DateTime(timezone=True), server_default=func.current_timestamp())
is_published = Column(Boolean, server_default='f', nullable=False)
manual_grading = Column(Boolean, server_default='f', nullable=False)

@hybrid_property
def student_notebook_path(self) -> str:
p = Path(self.master_notebook_path)
if self.manual_grading:
# If the assignment is manually graded, there is no distinct "student" version.
# They just share the same notebook.
return str(p)
return str(p.parents[0] / (p.stem + "-student.ipynb"))
2 changes: 2 additions & 0 deletions app/schemas/assignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class AssignmentSchema(BaseModel):
due_date: datetime | None
last_modified_date: datetime
is_published: bool
manual_grading: bool

class Config:
orm_mode = True
Expand All @@ -33,6 +34,7 @@ class UpdateAssignmentSchema(BaseModel):
available_date: datetime | None
due_date: datetime | None
is_published: bool = UNSET
manual_grading: bool = UNSET

# Adds in fields relevant for JLP (tailored to the professor)
class InstructorAssignmentSchema(AssignmentSchema):
Expand Down
10 changes: 9 additions & 1 deletion app/schemas/grade_report.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from typing import Optional
from pydantic import BaseModel
from datetime import datetime
from app.models import SubmissionModel

class GradeReportSchema(BaseModel):
id: int
Expand Down Expand Up @@ -29,4 +30,11 @@ class SubmissionGradeSchema(BaseModel):
score: float
total_points: float
comments: Optional[str]
submission_already_graded: bool = False
submission_already_graded: bool = False

""" By identifying the submission, the associated student and assignment can also be identified. """
class IdentifiableSubmissionGradeSchema(SubmissionGradeSchema):
submission: SubmissionModel

class Config:
arbitrary_types_allowed = True
17 changes: 8 additions & 9 deletions app/services/assignment_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ async def update_assignment(
raise AssignmentCannotBeUnpublished("Canvas is not allowing this assignment to be unpublished")
assignment.is_published = update_fields["is_published"]

if "manual_grading" in update_fields:
assignment.manual_grading = update_fields["manual_grading"]

if assignment.available_date is not None and assignment.due_date is not None and assignment.available_date >= assignment.due_date:
raise AssignmentDueBeforeOpenException()

Expand Down Expand Up @@ -259,14 +262,17 @@ async def get_gitignore_content(self, assignment: AssignmentModel) -> str:
NOTE: File paths are relative to `assignment.directory_path`.
"""
async def get_protected_files(self, assignment: AssignmentModel) -> list[str]:
return [
files = [
"*grades.csv",
"*grading_config.json",
assignment.master_notebook_path,
f"{ assignment.name }-dist",
"**/.ssh",
"prof-scripts"
]
# In a manually graded assignment, the notebook is shared among all users.
if not assignment.manual_grading: files.append(assignment.master_notebook_path)

return files

"""
NOTE: File paths are not necessarily real files and may instead be globs.
Expand All @@ -280,13 +286,6 @@ async def get_overwritable_files(self, assignment: AssignmentModel) -> list[str]
"instruction*.txt",
".gitignore",
]

async def get_master_notebook_name(self, assignment: AssignmentModel) -> str:
return self._compute_master_notebook_name(assignment.name)

@staticmethod
def _compute_master_notebook_name(assignment_name: str) -> str:
return f"{ assignment_name }-prof.ipynb"

class InstructorAssignmentService(AssignmentService):
def __init__(self, session: Session, instructor_model: InstructorModel, assignment_model: AssignmentModel, course_model: CourseModel):
Expand Down
Loading

0 comments on commit 16f86e5

Please sign in to comment.