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"}
-