From 82a46394736da2b66622b24328ff73634ca1c85c Mon Sep 17 00:00:00 2001 From: Isha Dewangan Date: Thu, 16 Jul 2026 20:21:53 +0530 Subject: [PATCH 01/10] feat: add Scratch submission type and enhance project submission flow - Implemented Scratch as a new submission type for assignments. - Updated the project submission process to allow for revising existing submissions. - Added functionality to prevent resubmission after grades are published. - Created a new ScratchSubmissionPlayer component for faculty to preview student submissions in a read-only mode. - Enhanced the AssignmentTable and SubmissionPreviewDialog components to support Scratch submissions. - Updated the CreateAssignmentSheet to include Scratch as a submission option. - Modified FacultyAssignmentDetailPage and FacultyGradingPage to handle Scratch submissions. - Added hooks for starting Scratch submissions directly from the dashboard. - Implemented tests for Scratch submission functionality, including UI interactions and submission confirmations. - Updated e2e tests to cover Scratch assignment creation and submission scenarios. --- cs17_portal/api.py | 29 +++- .../cs17_assignment_submission.py | 15 +- .../doctype/cs17_project/test_cs17_project.py | 32 ++++ cs17_portal/patches.txt | 3 +- .../reset_submitted_submissions_to_draft.py | 18 ++ .../src/components/ui/AssignmentTable.tsx | 51 ++++-- .../components/ui/ScratchSubmissionPlayer.tsx | 105 ++++++++++++ .../components/ui/SubmissionPreviewDialog.tsx | 22 ++- .../src/faculty/CreateAssignmentSheet.tsx | 2 +- .../faculty/FacultyAssignmentDetailPage.tsx | 1 + dashboard/src/faculty/FacultyGradingPage.tsx | 71 +------- .../src/hooks/useStartScratchSubmission.ts | 38 +++++ dashboard/src/lib/scratch.ts | 2 - dashboard/src/pages/AssignmentDetailPage.tsx | 20 ++- dashboard/src/pages/AssignmentsPage.tsx | 9 +- dashboard/src/pages/DashboardPage.tsx | 10 +- dashboard/src/pages/ProjectEditorPage.tsx | 158 +++++++++++++----- e2e/helpers/frappe.ts | 38 +++++ e2e/tests/faculty-assignments-ui.spec.ts | 105 +++++++++++- e2e/tests/student-submission.spec.ts | 136 ++++++++++++++- 20 files changed, 715 insertions(+), 150 deletions(-) create mode 100644 cs17_portal/patches/v1_0/reset_submitted_submissions_to_draft.py create mode 100644 dashboard/src/components/ui/ScratchSubmissionPlayer.tsx create mode 100644 dashboard/src/hooks/useStartScratchSubmission.ts diff --git a/cs17_portal/api.py b/cs17_portal/api.py index 7930cb5..01f3bd0 100644 --- a/cs17_portal/api.py +++ b/cs17_portal/api.py @@ -243,7 +243,9 @@ def get_current_faculty() -> "frappe._dict": def require_faculty_for_assignment(assignment: str) -> None: faculty = get_current_faculty() assignment_cohort = frappe.db.get_value("CS17 Assignment", assignment, "cohort") - if not faculty.cohort or faculty.cohort != assignment_cohort: + # A faculty assigned to a cohort is limited to it; one with no cohort set is an + # unrestricted reviewer (any cohort). + if faculty.cohort and faculty.cohort != assignment_cohort: frappe.throw(_("Not permitted"), frappe.PermissionError) @@ -354,12 +356,14 @@ def submit_scratch_project(assignment: str, project: str) -> dict: source_file = frappe.get_doc("File", {"file_url": project_doc.sb3_file, "attached_to_name": project}) - submission = frappe.new_doc("CS17 Assignment Submission") - submission.student = project_doc.student - submission.assignment = assignment + # One submission per student per assignment — resubmitting revises it in place. + # The doctype's validate() blocks the revision once the deadline passes or the + # grade is published. + submission = _get_or_new_submission(assignment, project_doc.student) + submission.flags.ignore_permissions = True submission.project = project submission.submitted_at = frappe.utils.now_datetime() - submission.insert(ignore_permissions=True) + submission.save() snapshot = attach_private_file( "CS17 Assignment Submission", @@ -369,11 +373,22 @@ def submit_scratch_project(assignment: str, project: str) -> dict: source_file.get_content(), ) submission.submission_document = snapshot.file_url - submission.flags.ignore_permissions = True - submission.submit() + submission.save() return {"name": submission.name, "submission_document": submission.submission_document} +def _get_or_new_submission(assignment: str, student: str) -> "frappe.model.document.Document": + name = frappe.db.get_value( + "CS17 Assignment Submission", {"assignment": assignment, "student": student}, "name" + ) + if name: + return frappe.get_doc("CS17 Assignment Submission", name) + submission = frappe.new_doc("CS17 Assignment Submission") + submission.student = student + submission.assignment = assignment + return submission + + @frappe.whitelist() def get_recent_submissions(limit: int = 5) -> list: faculty = get_current_faculty() diff --git a/cs17_portal/cs17_portal/doctype/cs17_assignment_submission/cs17_assignment_submission.py b/cs17_portal/cs17_portal/doctype/cs17_assignment_submission/cs17_assignment_submission.py index 16785e6..575a335 100644 --- a/cs17_portal/cs17_portal/doctype/cs17_assignment_submission/cs17_assignment_submission.py +++ b/cs17_portal/cs17_portal/doctype/cs17_assignment_submission/cs17_assignment_submission.py @@ -34,16 +34,27 @@ class CS17AssignmentSubmission(Document): def validate(self): self.validate_deadline() + self.validate_not_graded() self.validate_scratch_acceptance() def validate_deadline(self): - student_user = frappe.db.get_value("CS17 Profile", self.student, "user") - if frappe.session.user != student_user: + if not self._edited_by_owner(): return due_date = frappe.db.get_value("CS17 Assignment", self.assignment, "due_date") if due_date and frappe.utils.now_datetime() > due_date: frappe.throw(_("The deadline for this assignment has passed.")) + def validate_not_graded(self): + # A student can revise their submission until it's graded and published. + if self.is_new() or not self._edited_by_owner(): + return + if frappe.db.exists("CS17 Assignment Grade", {"submission": self.name, "is_published": 1}): + frappe.throw(_("This submission has been graded and can no longer be edited.")) + + def _edited_by_owner(self) -> bool: + student_user = frappe.db.get_value("CS17 Profile", self.student, "user") + return frappe.session.user == student_user + def validate_scratch_acceptance(self): if not self.project: return diff --git a/cs17_portal/cs17_portal/doctype/cs17_project/test_cs17_project.py b/cs17_portal/cs17_portal/doctype/cs17_project/test_cs17_project.py index d063ae0..2784ad7 100644 --- a/cs17_portal/cs17_portal/doctype/cs17_project/test_cs17_project.py +++ b/cs17_portal/cs17_portal/doctype/cs17_project/test_cs17_project.py @@ -289,3 +289,35 @@ def test_grade_on_not_graded_assignment_rejected(self): submission = api.submit_scratch_project(not_graded, project)["name"] frappe.set_user(FACULTY_IN_USER) self.assertRaises(frappe.ValidationError, api.save_grade, submission, 50) + + def test_resubmit_revises_the_same_submission(self): + frappe.set_user(STUDENT1_USER) + project = api.create_project("Revise Project")["name"] + api.save_project(project, "project.sb3", b64(b"PK\x03\x04v1")) + first = api.submit_scratch_project(self.assignment, project)["name"] + api.save_project(project, "project.sb3", b64(b"PK\x03\x04v2")) + second = api.submit_scratch_project(self.assignment, project)["name"] + + self.assertEqual(first, second) + self.assertEqual( + frappe.db.count( + "CS17 Assignment Submission", + {"assignment": self.assignment, "student": self.student1}, + ), + 1, + ) + + def test_cannot_resubmit_after_grade_published(self): + frappe.set_user(STUDENT1_USER) + project = api.create_project("Graded Project")["name"] + api.save_project(project, "project.sb3", b64(b"PK\x03\x04v1")) + submission = api.submit_scratch_project(self.assignment, project)["name"] + + frappe.set_user(FACULTY_IN_USER) + api.save_grade(submission, marks_obtained=80) + + frappe.set_user(STUDENT1_USER) + api.save_project(project, "project.sb3", b64(b"PK\x03\x04v2")) + self.assertRaises( + frappe.ValidationError, api.submit_scratch_project, self.assignment, project + ) diff --git a/cs17_portal/patches.txt b/cs17_portal/patches.txt index c2667c8..5e3021f 100644 --- a/cs17_portal/patches.txt +++ b/cs17_portal/patches.txt @@ -5,4 +5,5 @@ [post_model_sync] # Patches added in this section will be executed after doctypes are migrated cs17_portal.patches.v1_0.migrate_student_faculty_to_profile -cs17_portal.patches.v1_0.rename_literal_cohort_in_assignment_names \ No newline at end of file +cs17_portal.patches.v1_0.rename_literal_cohort_in_assignment_names +cs17_portal.patches.v1_0.reset_submitted_submissions_to_draft \ No newline at end of file diff --git a/cs17_portal/patches/v1_0/reset_submitted_submissions_to_draft.py b/cs17_portal/patches/v1_0/reset_submitted_submissions_to_draft.py new file mode 100644 index 0000000..8ef30fc --- /dev/null +++ b/cs17_portal/patches/v1_0/reset_submitted_submissions_to_draft.py @@ -0,0 +1,18 @@ +import frappe + +# Scratch submissions used to be submitted (docstatus 1) by submit_scratch_project. +# The flow now revises a single submission in place as a draft, like every other +# submission type, so re-submitting a legacy row failed with UpdateAfterSubmitError. +# Reset every submitted submission back to a draft — there are no submit side +# effects to unwind (the immutable snapshot lives in its own file, not the +# docstatus). + + +def execute(): + submitted = frappe.get_all( + "CS17 Assignment Submission", filters={"docstatus": 1}, pluck="name" + ) + for name in submitted: + frappe.db.set_value( + "CS17 Assignment Submission", name, "docstatus", 0, update_modified=False + ) diff --git a/dashboard/src/components/ui/AssignmentTable.tsx b/dashboard/src/components/ui/AssignmentTable.tsx index d9efe63..ee09b39 100644 --- a/dashboard/src/components/ui/AssignmentTable.tsx +++ b/dashboard/src/components/ui/AssignmentTable.tsx @@ -6,6 +6,10 @@ import SubmitAssignmentDialog from "@/components/ui/SubmitAssignmentDialog"; import SubmissionPreviewDialog from "@/components/ui/SubmissionPreviewDialog"; import { formatDateTime } from "@/lib/dayjs"; import { useNavigate } from "react-router-dom"; +import { + useStartScratchSubmission, + scratchEditorPath, +} from "@/hooks/useStartScratchSubmission"; interface Assignment { name: string; @@ -22,6 +26,7 @@ interface Submission { submitted_at: string; submission_document?: string; submission_url?: string; + project?: string; } interface Props { @@ -61,6 +66,32 @@ export default function AssignmentTable({ null, ); const navigate = useNavigate(); + const { start: startScratch } = useStartScratchSubmission(); + + function openSubmit(assignment: Assignment, existing: Submission | null) { + if (assignment.submission_type === "Scratch") { + // Editing reopens the submitted project; a first submission makes a new one. + if (existing?.project) { + navigate(scratchEditorPath(existing.project, assignment.name)); + } else { + startScratch({ name: assignment.name, title: assignment.title }); + } + return; + } + setDialogAssignment(assignment); + setEditSubmission(existing); + } + + function openPreview(assignment: Assignment, submission: Submission) { + // A Scratch submission opens the student's project in the editor, not a + // .sb3 download — they can review and revise it there. + if (assignment.submission_type === "Scratch" && submission.project) { + navigate(scratchEditorPath(submission.project, assignment.name)); + return; + } + setPreviewAssignment(assignment); + setPreviewSubmission(submission); + } const columns: Column[] = [ { @@ -112,10 +143,7 @@ export default function AssignmentTable({ @@ -124,25 +152,16 @@ export default function AssignmentTable({ - ) : status === "submitted" ? ( + ) : status === "submitted" && a.submission_type !== "Scratch" ? ( ) : status === "pending" ? ( - ) : null} diff --git a/dashboard/src/components/ui/ScratchSubmissionPlayer.tsx b/dashboard/src/components/ui/ScratchSubmissionPlayer.tsx new file mode 100644 index 0000000..c0f6696 --- /dev/null +++ b/dashboard/src/components/ui/ScratchSubmissionPlayer.tsx @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef } from "react"; +import { useFrappeGetCall } from "frappe-react-sdk"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + SCRATCH_EDITOR_URL, + SCRATCH_MESSAGE, + SCRATCH_TARGET_ORIGIN, + base64ToArrayBuffer, +} from "@/lib/scratch"; + +// Strips the editor down to a review view: only the code (blocks canvas) and +// the stage where the project runs stay visible. Everything else is hidden — +// menu bar, Code/Backdrops/Sounds tabs, Find bar, the sprite/stage panel (which +// is also how sprites/costumes get deleted), Backpack, the extension button, +// zoom, and scrollbars. Blocks are locked (no drag/edit) but the green flag, +// stop, and stage stay live so the project runs. Selectors use Blockly's stable +// global classes, scratch-gui's readable module prefixes (only the trailing hash +// varies per build), and the TurboWarp addon's stable `sa-find-bar` class. +const READONLY_CSS = ` + .blocklyToolboxDiv, .blocklyFlyout, .blocklyFlyoutButton, + .blocklyZoom, .blocklyScrollbarVertical, .blocklyScrollbarHorizontal, + [class*="menu-bar_menu-bar_"], + [class*="gui_tab-list_"], + [class*="target-pane_target-pane_"], + [class*="backpack_backpack-container_"], + [class*="extension-button-container_"], + .sa-find-bar { display: none !important; } + .blocklyBlockCanvas { pointer-events: none !important; } +`; + +// Loads a submitted .sb3 snapshot into the full Scratch editor so faculty see +// the student's blocks/code, not just the stage. The snapshot bytes come from +// the faculty-gated get_submission_project API, never a raw file download. +export default function ScratchSubmissionPlayer({ submission }: { submission: string }) { + const iframeRef = useRef(null); + const readyRef = useRef(false); + const loadedRef = useRef(false); + + const { data, isLoading, error } = useFrappeGetCall<{ message: { content: string } }>( + "cs17_portal.api.get_submission_project", + { submission }, + `grading-project-${submission}`, + ); + const sb3Content = data?.message?.content ?? null; + + const loadIntoPlayer = useCallback(() => { + if (loadedRef.current || !readyRef.current || !sb3Content) return; + if (!iframeRef.current?.contentWindow) return; + const sb3 = base64ToArrayBuffer(sb3Content); + loadedRef.current = true; + iframeRef.current.contentWindow.postMessage( + { type: SCRATCH_MESSAGE.loadProject, sb3 }, + SCRATCH_TARGET_ORIGIN, + [sb3], + ); + }, [sb3Content]); + + useEffect(() => { + loadIntoPlayer(); + }, [loadIntoPlayer]); + + const applyReadOnly = useCallback(() => { + const doc = iframeRef.current?.contentDocument; + if (!doc || doc.getElementById("cs17-readonly-style")) return; + const style = doc.createElement("style"); + style.id = "cs17-readonly-style"; + style.textContent = READONLY_CSS; + doc.head.appendChild(style); + // Block the workspace right-click menu (delete / clean up blocks). + doc.addEventListener("contextmenu", (event) => event.preventDefault(), true); + }, []); + + useEffect(() => { + function handleMessage(event: MessageEvent) { + if (event.data?.type === SCRATCH_MESSAGE.ready) { + readyRef.current = true; + loadIntoPlayer(); + applyReadOnly(); + } + } + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, [loadIntoPlayer, applyReadOnly]); + + if (error) { + return ( +
+ Could not load the submitted project. +
+ ); + } + + return ( +
+ {isLoading && } +