diff --git a/cs17_portal/api.py b/cs17_portal/api.py index ff4a700..709db9a 100644 --- a/cs17_portal/api.py +++ b/cs17_portal/api.py @@ -221,6 +221,13 @@ def require_current_student() -> str: return validate_membership("Student") +def require_current_profile() -> str: + name = frappe.db.get_value("CS17 Profile", {"user": frappe.session.user}, "name") + if not name: + frappe.throw(_("No profile found for current user"), frappe.PermissionError) + return name + + def with_current_student(fn): """Inject the caller's Student profile name as a `student` kwarg; 403 if they aren't a student.""" @@ -233,9 +240,9 @@ def wrapper(*args, **kwargs): def require_owned_project(project: str) -> "frappe.model.document.Document": - student = require_current_student() + profile = require_current_profile() project_doc = frappe.get_doc("CS17 Project", project) - if project_doc.student != student: + if project_doc.profile != profile: frappe.throw(_("Not permitted"), frappe.PermissionError) return project_doc @@ -312,24 +319,71 @@ def replace_project_file( @frappe.whitelist() -def create_project(project_title: str) -> dict: - student = require_current_student() +def create_project(project_title: str, assignment: str | None = None) -> dict: + profile = require_current_profile() project_doc = frappe.new_doc("CS17 Project") - project_doc.project_title = project_title - project_doc.student = student + project_doc.project_title = _validate_project_title(project_title) + project_doc.profile = profile + project_doc.assignment = assignment project_doc.insert() return {"name": project_doc.name, "project_title": project_doc.project_title} @frappe.whitelist() def list_my_projects() -> list: - student = require_current_student() - return frappe.get_all( + profile = require_current_profile() + projects = frappe.get_all( "CS17 Project", - filters={"student": student}, + filters={"profile": profile}, fields=["name", "project_title", "thumbnail", "last_saved_at"], order_by="creation desc", ) + _attach_submitted_flags(projects) + return projects + + +def _attach_submitted_flags(projects: list) -> None: + names = [project["name"] for project in projects] + if not names: + return + submitted = set( + frappe.get_all( + ASSIGNMENT_SUBMISSION, + filters=[["project", "in", names]], + pluck="project", + ignore_permissions=True, + ) + ) + for project in projects: + project["is_submitted"] = project["name"] in submitted + + +@frappe.whitelist(methods=["POST"]) +def rename_project(project: str, project_title: str) -> dict: + project_doc = require_owned_project(project) + _require_unsubmitted_project(project) + project_doc.project_title = _validate_project_title(project_title) + project_doc.save() + return {"name": project_doc.name, "project_title": project_doc.project_title} + + +@frappe.whitelist(methods=["POST"]) +def delete_project(project: str) -> None: + require_owned_project(project) + _require_unsubmitted_project(project) + frappe.delete_doc("CS17 Project", project, ignore_permissions=True) + + +def _require_unsubmitted_project(project: str) -> None: + if frappe.db.exists(ASSIGNMENT_SUBMISSION, {"project": project}): + frappe.throw(_("This project is submitted to an assignment, so it cannot be renamed or deleted.")) + + +def _validate_project_title(project_title: str) -> str: + title = (project_title or "").strip() + if not title: + frappe.throw(_("Give the project a name.")) + return title @frappe.whitelist() @@ -360,13 +414,17 @@ def save_project( @frappe.whitelist() @rate_limit(key="project", limit=30, seconds=60, methods=["POST"], ip_based=False) def submit_scratch_project(assignment: str, project: str) -> dict: + student = require_current_student() project_doc = require_owned_project(project) if not project_doc.sb3_file: frappe.throw(_("Save the project before submitting it.")) source_file = frappe.get_doc("File", {"file_url": project_doc.sb3_file, "attached_to_name": project}) - submission = _get_or_new_submission(assignment, project_doc.student) + if project_doc.assignment != assignment: + project_doc.db_set("assignment", assignment) + + submission = _get_or_new_submission(assignment, student) submission.flags.ignore_permissions = True submission.project = project submission.submitted_at = frappe.utils.now_datetime() @@ -682,11 +740,18 @@ def get_assignment(assignment: str) -> dict | None: @frappe.whitelist(methods=["POST"]) def delete_assignment(assignment: str) -> None: validate_membership("Faculty") - if frappe.db.exists("CS17 Assignment Submission", {"assignment": assignment}): + if frappe.db.exists(ASSIGNMENT_SUBMISSION, {"assignment": assignment}): frappe.throw(_("Cannot delete an assignment that already has submissions")) + _unlink_projects_from_assignment(assignment) frappe.delete_doc("CS17 Assignment", assignment, ignore_permissions=True) +def _unlink_projects_from_assignment(assignment: str) -> None: + projects = frappe.get_all("CS17 Project", filters={"assignment": assignment}, pluck="name") + for project in projects: + frappe.db.set_value("CS17 Project", project, "assignment", None, update_modified=False) + + @frappe.whitelist(methods=["POST"]) def publish_assignment(assignment: str, publish: str = "now", publish_on: str | None = None) -> None: validate_membership("Faculty") diff --git a/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.json b/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.json index 21090d6..3b5c1f4 100644 --- a/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.json +++ b/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.json @@ -1,13 +1,14 @@ { "actions": [], "allow_rename": 1, - "autoname": "PROJ-.{student}.-.####", + "autoname": "PROJ-.{profile}.-.####", "creation": "2026-07-07 00:00:00.000000", "doctype": "DocType", "engine": "InnoDB", "field_order": [ "project_title", - "student", + "profile", + "assignment", "column_break_main", "last_saved_at", "section_break_files", @@ -23,14 +24,22 @@ "reqd": 1 }, { - "fieldname": "student", + "fieldname": "profile", "fieldtype": "Link", "in_list_view": 1, - "label": "Student", + "label": "Profile", "options": "CS17 Profile", "read_only": 1, "reqd": 1 }, + { + "description": "Set when the project is started from a Scratch assignment. Empty for standalone projects.", + "fieldname": "assignment", + "fieldtype": "Link", + "label": "Assignment", + "options": "CS17 Assignment", + "read_only": 1 + }, { "fieldname": "column_break_main", "fieldtype": "Column Break" diff --git a/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.py b/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.py index 7978f59..3ef66da 100644 --- a/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.py +++ b/cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.py @@ -14,10 +14,11 @@ class CS17Project(Document): if TYPE_CHECKING: from frappe.types import DF + assignment: DF.Link | None last_saved_at: DF.Datetime | None + profile: DF.Link project_title: DF.Data sb3_file: DF.Attach | None - student: DF.Link thumbnail: DF.AttachImage | None # end: auto-generated types @@ -39,8 +40,8 @@ def get_permission_query_conditions(user: str | None = None) -> str: return "" profile = get_owner_profile(user) - if profile and profile.profile_type == "Student": - return f"`tabCS17 Project`.student = {frappe.db.escape(profile.name)}" + if profile: + return f"`tabCS17 Project`.profile = {frappe.db.escape(profile.name)}" return "1 = 0" @@ -50,4 +51,4 @@ def has_permission(doc: Document, ptype: str | None = None, user: str | None = N return True profile = get_owner_profile(user) - return bool(profile and profile.profile_type == "Student" and doc.student == profile.name) + return bool(profile and doc.profile == profile.name) 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 109062c..4e56576 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 @@ -60,7 +60,7 @@ def cleanup_fixtures(self): else [] ) projects = ( - frappe.get_all("CS17 Project", filters={"student": ["in", profiles]}, pluck="name") + frappe.get_all("CS17 Project", filters={"profile": ["in", profiles]}, pluck="name") if profiles else [] ) diff --git a/cs17_portal/patches.txt b/cs17_portal/patches.txt index 5e3021f..4ad93d6 100644 --- a/cs17_portal/patches.txt +++ b/cs17_portal/patches.txt @@ -6,4 +6,6 @@ # 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 -cs17_portal.patches.v1_0.reset_submitted_submissions_to_draft \ No newline at end of file +cs17_portal.patches.v1_0.reset_submitted_submissions_to_draft +cs17_portal.patches.v1_0.rename_project_student_to_profile +cs17_portal.patches.v1_0.link_projects_to_submitted_assignment diff --git a/cs17_portal/patches/v1_0/link_projects_to_submitted_assignment.py b/cs17_portal/patches/v1_0/link_projects_to_submitted_assignment.py new file mode 100644 index 0000000..4749c15 --- /dev/null +++ b/cs17_portal/patches/v1_0/link_projects_to_submitted_assignment.py @@ -0,0 +1,18 @@ +import frappe + + +def execute(): + submissions = frappe.get_all( + "CS17 Assignment Submission", + filters={"project": ["is", "set"]}, + fields=["project", "assignment"], + ) + for submission in submissions: + if frappe.db.exists("CS17 Project", submission.project): + frappe.db.set_value( + "CS17 Project", + submission.project, + "assignment", + submission.assignment, + update_modified=False, + ) diff --git a/cs17_portal/patches/v1_0/rename_project_student_to_profile.py b/cs17_portal/patches/v1_0/rename_project_student_to_profile.py new file mode 100644 index 0000000..baa11f8 --- /dev/null +++ b/cs17_portal/patches/v1_0/rename_project_student_to_profile.py @@ -0,0 +1,8 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + + +def execute(): + if not frappe.db.has_column("CS17 Project", "student"): + return + rename_field("CS17 Project", "student", "profile") diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 94953c6..9db3c5f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -38,6 +38,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/dashboard/src/components/ui/DeleteProjectDialog.tsx b/dashboard/src/components/ui/DeleteProjectDialog.tsx new file mode 100644 index 0000000..8ad6777 --- /dev/null +++ b/dashboard/src/components/ui/DeleteProjectDialog.tsx @@ -0,0 +1,70 @@ +import { useEffect, useState } from "react"; +import { useFrappePostCall } from "frappe-react-sdk"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { frappeErrorMessage } from "@/lib/frappeError"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + project: { name: string; project_title: string } | null; + onSuccess: () => void; +} + +export default function DeleteProjectDialog({ + open, + onOpenChange, + project, + onSuccess, +}: Props) { + const [error, setError] = useState(null); + const { call, loading } = useFrappePostCall("cs17_portal.api.delete_project"); + + useEffect(() => { + setError(null); + }, [project]); + + async function handleDelete(event: React.MouseEvent) { + event.preventDefault(); + setError(null); + try { + await call({ project: project!.name }); + onSuccess(); + } catch (err) { + setError(frappeErrorMessage(err, "Could not delete the project.")); + } + } + + return ( + + + + Delete "{project?.project_title}"? + + This permanently removes the project and its saved blocks. This cannot be + undone. + + + {error &&

{error}

} + + Cancel + + {loading ? "Deleting…" : "Delete"} + + +
+
+ ); +} diff --git a/dashboard/src/components/ui/TopBar.tsx b/dashboard/src/components/ui/TopBar.tsx index 4609a91..f06410e 100644 --- a/dashboard/src/components/ui/TopBar.tsx +++ b/dashboard/src/components/ui/TopBar.tsx @@ -9,6 +9,7 @@ import { useLocation, Link } from "react-router-dom"; const routeLabels: Record = { "/": "Dashboard", "/assignments": "Assignments", + "/projects": "Projects", "/announcements": "Announcements", "/settings": "Settings", }; diff --git a/dashboard/src/faculty/FacultySidebar.tsx b/dashboard/src/faculty/FacultySidebar.tsx index bab413f..7826095 100644 --- a/dashboard/src/faculty/FacultySidebar.tsx +++ b/dashboard/src/faculty/FacultySidebar.tsx @@ -3,6 +3,7 @@ import { Link, useLocation } from "react-router-dom"; import { LayoutDashboard, ClipboardList, + Blocks, BookOpen, GraduationCap, Inbox, @@ -30,6 +31,7 @@ const navSections = [ items: [ { icon: LayoutDashboard, label: "Dashboard", to: "/faculty/" }, { icon: ClipboardList, label: "Assignments", to: "/faculty/assignments" }, + { icon: Blocks, label: "Projects", to: "/faculty/projects" }, ] as NavItem[], }, { diff --git a/dashboard/src/faculty/FacultyTopBar.tsx b/dashboard/src/faculty/FacultyTopBar.tsx index 1d778cd..707f4dd 100644 --- a/dashboard/src/faculty/FacultyTopBar.tsx +++ b/dashboard/src/faculty/FacultyTopBar.tsx @@ -9,6 +9,7 @@ const routeLabels: Record = { "/faculty/": "Dashboard", "/faculty": "Dashboard", "/faculty/assignments": "Submissions", + "/faculty/projects": "Projects", "/faculty/announcements": "Announcements", "/faculty/settings": "Settings", }; diff --git a/dashboard/src/hooks/useProjectsPortal.ts b/dashboard/src/hooks/useProjectsPortal.ts new file mode 100644 index 0000000..d3a2df8 --- /dev/null +++ b/dashboard/src/hooks/useProjectsPortal.ts @@ -0,0 +1,11 @@ +import { useCurrentProfile } from "@/hooks/useCurrentProfile"; + +export function useProjectsPortal() { + const { profile } = useCurrentProfile(); + const isFaculty = profile?.profile_type === "Faculty"; + + return { + isFaculty, + projectsPath: isFaculty ? "/faculty/projects" : "/projects", + }; +} diff --git a/dashboard/src/hooks/useScratchEditor.ts b/dashboard/src/hooks/useScratchEditor.ts index d6b5135..bb548f2 100644 --- a/dashboard/src/hooks/useScratchEditor.ts +++ b/dashboard/src/hooks/useScratchEditor.ts @@ -23,10 +23,11 @@ export function useScratchEditor() { const navigate = useNavigate(); const { call: createProject } = useFrappePostCall("cs17_portal.api.create_project"); - async function createNewProject(title?: string): Promise { + async function createNewProject(assignment: ScratchAssignment): Promise { try { const { message } = await createProject({ - project_title: title || "Scratch project", + project_title: assignment.title || "Scratch project", + assignment: assignment.name, }); return message?.name ?? null; } catch { @@ -40,7 +41,7 @@ export function useScratchEditor() { submission?: ScratchSubmission | null, options: { readOnly?: boolean } = {}, ) { - const project = submission?.project ?? (await createNewProject(assignment.title)); + const project = submission?.project ?? (await createNewProject(assignment)); if (project) { navigate(scratchEditorPath(project, assignment.name, options.readOnly)); } diff --git a/dashboard/src/pages/ProjectEditorPage.tsx b/dashboard/src/pages/ProjectEditorPage.tsx index d9e4373..f061bac 100644 --- a/dashboard/src/pages/ProjectEditorPage.tsx +++ b/dashboard/src/pages/ProjectEditorPage.tsx @@ -19,6 +19,7 @@ import { } from "@/components/ui/dialog"; import { Skeleton } from "@/components/ui/skeleton"; import { useCurrentStudent } from "@/hooks/useCurrentStudent"; +import { useProjectsPortal } from "@/hooks/useProjectsPortal"; import { frappeErrorMessage } from "@/lib/frappeError"; import { useZenOnMount } from "@/context/ZenModeContext"; import { @@ -40,6 +41,7 @@ interface ProjectDoc { project_title: string; sb3_file: string | null; last_saved_at: string | null; + assignment: string | null; } interface ScratchAssignment { @@ -51,8 +53,9 @@ export default function ProjectEditorPage() { const { id: projectId } = useParams<{ id: string }>(); const [searchParams] = useSearchParams(); const navigate = useNavigate(); - const presetAssignment = searchParams.get("assignment"); + const assignmentParam = searchParams.get("assignment"); const { student } = useCurrentStudent(); + const { isFaculty, projectsPath } = useProjectsPortal(); useZenOnMount(); useLayoutEffect(() => applyScratchDefaults(), []); @@ -64,10 +67,11 @@ export default function ProjectEditorPage() { const { data: closed } = useFrappeGetCall<{ message: boolean }>( "cs17_portal.api.is_assignment_closed", - { assignment: presetAssignment }, - presetAssignment ? undefined : null, + { assignment: assignmentParam }, + assignmentParam ? undefined : null, ); const readOnly = searchParams.get("readonly") === "1" || closed?.message === true; + const submitAssignment = assignmentParam ?? project?.assignment ?? null; const { call: saveProject } = useFrappePostCall("cs17_portal.api.save_project"); const { call: submitScratchProject, loading: submitting } = useFrappePostCall( @@ -167,7 +171,7 @@ export default function ProjectEditorPage() {
- + {!isFaculty && ( + + )} )}
@@ -201,17 +207,19 @@ export default function ProjectEditorPage() { className="flex-1 w-full border-0" /> - - submitScratchProject({ assignment, project: projectId }) - } - onGoToDashboard={() => navigate("/")} - /> + {!isFaculty && ( + + submitScratchProject({ assignment, project: projectId }) + } + onGoToDashboard={() => navigate("/")} + /> + )}
); } diff --git a/dashboard/src/pages/ProjectsPage.tsx b/dashboard/src/pages/ProjectsPage.tsx index 2dd6bdc..bac20eb 100644 --- a/dashboard/src/pages/ProjectsPage.tsx +++ b/dashboard/src/pages/ProjectsPage.tsx @@ -1,21 +1,35 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk"; -import { Blocks, Plus } from "lucide-react"; +import { Blocks, Pencil, Plus, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import DeleteProjectDialog from "@/components/ui/DeleteProjectDialog"; +import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; +import { useProjectsPortal } from "@/hooks/useProjectsPortal"; import { formatDateTime } from "@/lib/dayjs"; +import { frappeErrorMessage } from "@/lib/frappeError"; interface Project { name: string; project_title: string; thumbnail: string | null; last_saved_at: string | null; + is_submitted: boolean; } export default function ProjectsPage() { const navigate = useNavigate(); + const { projectsPath } = useProjectsPortal(); const { data, @@ -26,24 +40,27 @@ export default function ProjectsPage() { const { call: createProject, loading: creating } = useFrappePostCall( "cs17_portal.api.create_project", ); + const { call: renameProject, loading: renaming } = useFrappePostCall( + "cs17_portal.api.rename_project", + ); - const [error, setError] = useState(null); + const [newProjectOpen, setNewProjectOpen] = useState(false); + const [projectToRename, setProjectToRename] = useState(null); + const [projectToDelete, setProjectToDelete] = useState(null); - async function handleNewProject() { - const projectTitle = window.prompt("Name your project")?.trim(); - if (!projectTitle) return; - setError(null); - try { - const response = await createProject({ project_title: projectTitle }); - // Refresh the list in the background; we navigate straight to the editor and don't await it. - mutate(); - navigate(`/projects/${response.message.name}/edit`); - } catch (error) { - setError( - (error as { message?: string })?.message ?? - "Could not create the project.", - ); - } + async function handleNewProject(projectTitle: string) { + const response = await createProject({ project_title: projectTitle }); + mutate(); + navigate(`${projectsPath}/${response.message.name}/edit`); + } + + async function handleRename(projectTitle: string) { + await renameProject({ + project: projectToRename!.name, + project_title: projectTitle, + }); + await mutate(); + setProjectToRename(null); } const projects = data?.message ?? []; @@ -57,13 +74,42 @@ export default function ProjectsPage() { {projects.length} project{projects.length === 1 ? "" : "s"}

- - {error &&

{error}

} + + + !open && setProjectToRename(null)} + title="Rename project" + description="Choose a new name for this project." + action="Rename" + initialTitle={projectToRename?.project_title ?? ""} + busy={renaming} + onSubmit={handleRename} + /> + + !open && setProjectToDelete(null)} + project={projectToDelete} + onSuccess={() => { + mutate(); + setProjectToDelete(null); + }} + /> {isLoading ? (
@@ -84,7 +130,7 @@ export default function ProjectsPage() { navigate(`/projects/${project.name}/edit`)} + onClick={() => navigate(`${projectsPath}/${project.name}/edit`)} >
{project.thumbnail ? ( @@ -97,13 +143,20 @@ export default function ProjectsPage() { )}
- -

{project.project_title}

-

- {project.last_saved_at - ? `Saved ${formatDateTime(project.last_saved_at)}` - : "Not saved yet"} -

+ +
+

{project.project_title}

+

+ {project.last_saved_at + ? `Saved ${formatDateTime(project.last_saved_at)}` + : "Not saved yet"} +

+
+ setProjectToRename(project)} + onDelete={() => setProjectToDelete(project)} + />
))} @@ -112,3 +165,134 @@ export default function ProjectsPage() {
); } + +function ProjectActions({ + project, + onRename, + onDelete, +}: { + project: Project; + onRename: () => void; + onDelete: () => void; +}) { + function act(run: () => void) { + return (event: React.MouseEvent) => { + event.stopPropagation(); + run(); + }; + } + + return ( + + + + + ); +} + +interface ProjectNameDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description: string; + action: string; + initialTitle?: string; + busy: boolean; + onSubmit: (projectTitle: string) => Promise; +} + +function ProjectNameDialog({ + open, + onOpenChange, + title, + description, + action, + initialTitle = "", + busy, + onSubmit, +}: ProjectNameDialogProps) { + const [value, setValue] = useState(initialTitle); + const [error, setError] = useState(null); + + const [wasOpen, setWasOpen] = useState(open); + if (open !== wasOpen) { + setWasOpen(open); + if (open) { + setValue(initialTitle); + setError(null); + } + } + + const projectTitle = value.trim(); + + async function submit() { + if (!projectTitle) return; + setError(null); + try { + await onSubmit(projectTitle); + } catch (err) { + setError(frappeErrorMessage(err, `Could not ${action.toLowerCase()} the project.`)); + } + } + + return ( + + + + {title} + {description} + +
{ + event.preventDefault(); + submit(); + }} + > + setValue(event.target.value)} + placeholder="Project name" + aria-label="Project name" + aria-invalid={error ? true : undefined} + /> + {error &&

{error}

} +
+ + + + +
+
+ ); +} diff --git a/e2e/pages/login.page.ts b/e2e/pages/login.page.ts index e8de39d..d6b572b 100644 --- a/e2e/pages/login.page.ts +++ b/e2e/pages/login.page.ts @@ -11,9 +11,12 @@ export class LoginPage { this.page = page; this.emailInput = page.locator("#login_email"); this.passwordInput = page.locator("#login_password"); - this.submitButton = page.locator( - "button.btn-login:not(.btn-login-with-email-link)", - ); + // Match the credentials submit button across Frappe versions: older builds + // render "Login" (.btn-login), newer ones render "Continue". Anchored to + // avoid matching "Login with Email Link" / "Send login link". + this.submitButton = page + .getByRole("button", { name: /^(continue|log ?in|sign in)$/i }) + .first(); this.errorMessage = page.locator(".msgprint, .alert-danger").first(); } diff --git a/e2e/tests/faculty-assignments.spec.ts b/e2e/tests/faculty-assignments.spec.ts index 7b4da98..b08ce83 100644 --- a/e2e/tests/faculty-assignments.spec.ts +++ b/e2e/tests/faculty-assignments.spec.ts @@ -17,6 +17,7 @@ import { import { callGetMethod, callMethod, + createDoc, deleteDoc, docExists, getDoc, @@ -211,6 +212,28 @@ test.describe("Faculty assignment management", () => { expect(await docExists(request, "CS17 Assignment", name)).toBe(false); }); + test("deletes an assignment that a student only started a project for", async ({ + request, + }) => { + const assignment = await createAssignment(request, { publish: "now" }); + const project = await createDoc<{ name: string }>(request, "CS17 Project", { + project_title: `${TEST_ASSIGNMENT_PREFIX} Started ${Date.now()}`, + profile: student.name, + assignment, + }); + + await callMethod(request, DELETE, { assignment }); + expect(await docExists(request, "CS17 Assignment", assignment)).toBe(false); + + const kept = await getDoc<{ assignment: string | null }>( + request, + "CS17 Project", + project.name, + ); + expect(kept.assignment).toBeFalsy(); + await deleteDoc(request, "CS17 Project", project.name); + }); + test("refuses to delete an assignment that has submissions", async ({ request }) => { const assignment = await createAssignment(request, { publish: "now" }); await createTestSubmission(request, { assignment, student: student.name }); diff --git a/e2e/tests/faculty-projects.spec.ts b/e2e/tests/faculty-projects.spec.ts new file mode 100644 index 0000000..411f440 --- /dev/null +++ b/e2e/tests/faculty-projects.spec.ts @@ -0,0 +1,226 @@ +import { test, expect, Page } from "@playwright/test"; +import { + CS17Assignment, + CS17Cohort, + CS17Profile, + cleanupTestAssignments, + createTestAssignment, + createTestCohort, + createTestProfile, + deleteTestProfile, + ensureSessionFaculty, +} from "../helpers/cs17"; +import { createDoc, deleteDoc, getList } from "../helpers/frappe"; + +const PROJECT_TITLE_PREFIX = "E2E Faculty Project"; + +interface CallResult { + ok: boolean; + status: number; + body: { message?: any; exc_type?: string; _server_messages?: string }; +} + +async function callAsFaculty( + page: Page, + method: string, + body: unknown, +): Promise { + await page.waitForFunction( + () => + (window as any).csrf_token !== undefined || + (window as any).frappe?.csrf_token !== undefined, + { timeout: 15000 }, + ); + return page.evaluate( + async ({ method, body }) => { + const token = (window as any).csrf_token ?? (window as any).frappe?.csrf_token; + const resp = await fetch(`/api/method/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": token, + }, + body: JSON.stringify(body), + }); + return { ok: resp.ok, status: resp.status, body: await resp.json() }; + }, + { method, body }, + ); +} + +async function createProjectAsFaculty(page: Page, title: string): Promise { + const created = await callAsFaculty(page, "cs17_portal.api.create_project", { + project_title: title, + }); + expect(created.ok).toBe(true); + await callAsFaculty(page, "cs17_portal.api.save_project", { + project: created.body.message.name, + filename: "p.sb3", + content: btoa("PKtest"), + }); + return created.body.message.name; +} + +test.describe("Faculty Scratch projects", () => { + let cohort: CS17Cohort; + let scratch: CS17Assignment; + let student: CS17Profile; + + test.beforeAll(async ({ request }) => { + await ensureSessionFaculty(request); + cohort = await createTestCohort(request); + student = await createTestProfile(request, { + profileType: "Student", + cohort: cohort.name, + }); + scratch = await createTestAssignment(request, { + cohort: cohort.name, + submissionType: "Scratch", + }); + }); + + test.afterAll(async ({ request }) => { + const projects = await getList<{ name: string }>(request, "CS17 Project", { + fields: ["name"], + filters: { project_title: ["like", `${PROJECT_TITLE_PREFIX}%`] }, + limit: 200, + }); + for (const project of projects) { + try { + await deleteDoc(request, "CS17 Project", project.name); + } catch (error) { + console.warn(`Failed to delete project ${project.name}:`, error); + } + } + await cleanupTestAssignments(request); + await deleteTestProfile(request, student.name); + await deleteDoc(request, "CS17 Cohort", cohort.name); + }); + + test("shows Projects in the sidebar and opens the faculty projects page", async ({ + page, + }) => { + await page.goto("/dashboard/faculty"); + await page.getByRole("link", { name: "Projects" }).click(); + + await expect(page).toHaveURL(/\/dashboard\/faculty\/projects$/); + await expect(page.getByRole("heading", { name: "Projects" })).toBeVisible(); + await expect(page.getByText("Workspace / Dashboard")).toHaveCount(0); + }); + + test("creates an arbitrary project and opens it in the editor", async ({ page }) => { + const title = `${PROJECT_TITLE_PREFIX} UI ${Date.now()}`; + + await page.goto("/dashboard/faculty/projects"); + await page.getByRole("button", { name: "New Project" }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "New project" })).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Create" })).toBeDisabled(); + + await dialog.getByLabel("Project name").fill(title); + await dialog.getByRole("button", { name: "Create" }).click(); + + await expect(page).toHaveURL(/\/dashboard\/faculty\/projects\/PROJ-.+\/edit$/); + await expect(page.getByRole("heading", { name: title })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); + }); + + test("renames a project from the projects page", async ({ page }) => { + const title = `${PROJECT_TITLE_PREFIX} Rename ${Date.now()}`; + const renamed = `${title} Renamed`; + await page.goto("/dashboard/faculty/projects"); + await createProjectAsFaculty(page, title); + + await page.reload(); + await page.getByRole("button", { name: `Rename ${title}` }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByRole("heading", { name: "Rename project" })).toBeVisible(); + await expect(dialog.getByLabel("Project name")).toHaveValue(title); + + await dialog.getByLabel("Project name").fill(renamed); + await dialog.getByRole("button", { name: "Rename" }).click(); + + await expect(dialog).toBeHidden(); + await expect(page.getByText(renamed)).toBeVisible(); + }); + + test("deletes a project that is not submitted", async ({ page }) => { + const title = `${PROJECT_TITLE_PREFIX} Delete ${Date.now()}`; + await page.goto("/dashboard/faculty/projects"); + await createProjectAsFaculty(page, title); + + await page.reload(); + await page.getByRole("button", { name: `Delete ${title}` }).click(); + + const confirm = page.getByRole("alertdialog"); + await expect(confirm.getByRole("heading", { name: `Delete "${title}"?` })).toBeVisible(); + await confirm.getByRole("button", { name: "Delete" }).click(); + + await expect(confirm).toBeHidden(); + await expect(page.getByText(title)).toHaveCount(0); + }); + + test("does not offer Submit to faculty in the editor", async ({ page }) => { + await page.goto("/dashboard/faculty/projects"); + const project = await createProjectAsFaculty( + page, + `${PROJECT_TITLE_PREFIX} NoSubmit ${Date.now()}`, + ); + + await page.goto(`/dashboard/faculty/projects/${project}/edit`); + await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Submit" })).toHaveCount(0); + }); + + test("lists the faculty's own projects", async ({ page }) => { + await page.goto("/dashboard/faculty/projects"); + const title = `${PROJECT_TITLE_PREFIX} List ${Date.now()}`; + const project = await createProjectAsFaculty(page, title); + + const listed = await callAsFaculty(page, "cs17_portal.api.list_my_projects", {}); + expect(listed.ok).toBe(true); + const names = listed.body.message.map((p: { name: string }) => p.name); + expect(names).toContain(project); + }); + + test("rejects a faculty submitting a project to an assignment", async ({ page }) => { + await page.goto("/dashboard/faculty/projects"); + const project = await createProjectAsFaculty( + page, + `${PROJECT_TITLE_PREFIX} Submit ${Date.now()}`, + ); + + const submitted = await callAsFaculty( + page, + "cs17_portal.api.submit_scratch_project", + { assignment: scratch.name, project }, + ); + expect(submitted.ok).toBe(false); + expect(submitted.body.exc_type).toBe("PermissionError"); + }); + + test("keeps another profile's project out of the faculty's list", async ({ + page, + request, + }) => { + const foreign = await createDoc<{ name: string }>(request, "CS17 Project", { + project_title: `${PROJECT_TITLE_PREFIX} Foreign ${Date.now()}`, + profile: student.name, + }); + + await page.goto("/dashboard/faculty/projects"); + const listed = await callAsFaculty(page, "cs17_portal.api.list_my_projects", {}); + const names = listed.body.message.map((p: { name: string }) => p.name); + expect(names).not.toContain(foreign.name); + + const saved = await callAsFaculty(page, "cs17_portal.api.save_project", { + project: foreign.name, + filename: "p.sb3", + content: btoa("PKtest"), + }); + expect(saved.ok).toBe(false); + expect(saved.body.exc_type).toBe("PermissionError"); + }); +}); diff --git a/e2e/tests/student-submission.spec.ts b/e2e/tests/student-submission.spec.ts index 7a22315..6ff6fe1 100644 --- a/e2e/tests/student-submission.spec.ts +++ b/e2e/tests/student-submission.spec.ts @@ -76,6 +76,50 @@ async function saveProjectAsStudent(page: Page, project: string) { ); } +async function createAssignmentProjectAsStudent( + page: Page, + assignment: string, + title: string, +): Promise { + await page.goto("/dashboard/projects"); + await page.waitForFunction( + () => + (window as any).csrf_token !== undefined || + (window as any).frappe?.csrf_token !== undefined, + { timeout: 15000 }, + ); + return page.evaluate( + async ({ assignment, title }) => { + const token = + (window as any).csrf_token ?? (window as any).frappe?.csrf_token; + const call = async (method: string, body: unknown) => { + const resp = await fetch(`/api/method/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": token, + }, + body: JSON.stringify(body), + }); + return resp.json(); + }; + const project = ( + await call("cs17_portal.api.create_project", { + project_title: title, + assignment, + }) + ).message.name; + await call("cs17_portal.api.save_project", { + project, + filename: "p.sb3", + content: btoa("PKtest"), + }); + return project as string; + }, + { assignment, title }, + ); +} + async function submitScratchAsStudent(page: Page, assignment: string, title: string) { await page.goto("/dashboard"); await page.waitForFunction( @@ -158,7 +202,6 @@ test.describe("Student submission types", () => { test.afterAll(async ({ request }) => { await cleanupTestGrades(request); await cleanupTestSubmissions(request); - await cleanupTestAssignments(request); const projects = await getList<{ name: string }>(request, "CS17 Project", { fields: ["name"], filters: { project_title: ["like", `${TEST_ASSIGNMENT_PREFIX}%`] }, @@ -167,6 +210,7 @@ test.describe("Student submission types", () => { for (const project of projects) { await deleteDoc(request, "CS17 Project", project.name); } + await cleanupTestAssignments(request); }); test("rejects a non-PDF and accepts a PDF for a PDF assignment", async ({ page }) => { @@ -258,6 +302,24 @@ test.describe("Student submission types", () => { ); }); + test("reopening an assignment project submits to it without the picker", async ({ + page, + }) => { + const projectId = await createAssignmentProjectAsStudent( + page, + scratch.name, + `${TEST_ASSIGNMENT_PREFIX} Reopen ${Date.now()}`, + ); + + await page.goto(`/dashboard/projects/${projectId}/edit`); + await page.getByRole("button", { name: "Submit", exact: true }).click(); + + const dialog = page.getByRole("dialog"); + await expect(dialog.getByText("Submit this project?")).toBeVisible(); + await expect(dialog.getByText(scratch.title)).toBeVisible(); + await expect(dialog.getByText("Pick a Scratch assignment")).toHaveCount(0); + }); + test("previewing a submitted scratch assignment opens its project in the editor", async ({ page, }) => { @@ -273,6 +335,51 @@ test.describe("Student submission types", () => { await expect(page.getByTitle("Scratch editor")).toBeVisible(); }); + test("cannot rename or delete a project submitted to an assignment", async ({ page }) => { + const project = await submitScratchAsStudent(page, scratch.name, scratch.title); + await page.goto("/dashboard/projects"); + + const call = (method: string, body: unknown) => + page.evaluate( + async ({ method, body }) => { + const token = + (window as any).csrf_token ?? (window as any).frappe?.csrf_token; + const resp = await fetch(`/api/method/${method}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Frappe-CSRF-Token": token, + }, + body: JSON.stringify(body), + }); + return { ok: resp.ok, body: await resp.json() }; + }, + { method, body }, + ); + + const renamed = await call("cs17_portal.api.rename_project", { + project, + project_title: "Sneaky rename", + }); + expect(renamed.ok).toBe(false); + expect(renamed.body._server_messages).toContain("cannot be renamed or deleted"); + + const deleted = await call("cs17_portal.api.delete_project", { project }); + expect(deleted.ok).toBe(false); + expect(deleted.body._server_messages).toContain("cannot be renamed or deleted"); + + await page.reload(); + const card = page.locator("[data-slot='card']", { hasText: scratch.title }).first(); + await expect(card.getByRole("button", { name: /^Rename / })).toBeDisabled(); + await expect(card.getByRole("button", { name: /^Delete / })).toBeDisabled(); + + const reason = card.locator("span[title]").first(); + await expect(reason).toHaveAttribute("title", /cannot be renamed or deleted/); + expect( + await reason.evaluate((el) => getComputedStyle(el).pointerEvents), + ).not.toBe("none"); + }); + test("a graded scratch assignment opens read-only from preview and direct link", async ({ page, request, diff --git a/playwright.config.ts b/playwright.config.ts index 895dfeb..6c67668 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -4,8 +4,8 @@ import path from "path"; const authFile = path.join(__dirname, "e2e", ".auth", "user.json"); const studentAuthFile = path.join(__dirname, "e2e", ".auth", "student.json"); const facultyAuthFile = path.join(__dirname, "e2e", ".auth", "faculty.json"); +const facultySpecs = /(faculty-assignments-ui|faculty-projects)\.spec\.ts/; const studentSpecs = /(student-submission|student-theme)\.spec\.ts/; -const facultySpecs = /faculty-assignments-ui\.spec\.ts/; const SITE_HOST = process.env.SITE_HOST || "cs17.portal:8000"; const SITE_DOMAIN = SITE_HOST.split(":")[0];