Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 76 additions & 11 deletions cs17_portal/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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")
Expand Down
17 changes: 13 additions & 4 deletions cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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"
Expand Down
9 changes: 5 additions & 4 deletions cs17_portal/cs17_portal/doctype/cs17_project/cs17_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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"


Expand All @@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
)
Expand Down
4 changes: 3 additions & 1 deletion cs17_portal/patches.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
18 changes: 18 additions & 0 deletions cs17_portal/patches/v1_0/link_projects_to_submitted_assignment.py
Original file line number Diff line number Diff line change
@@ -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,
)
8 changes: 8 additions & 0 deletions cs17_portal/patches/v1_0/rename_project_student_to_profile.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ function App() {
<Route index element={<FacultyDashboardPage />} />
<Route path="assignments" element={<FacultyAssignmentsPage />} />
<Route path="assignments/:assignmentId" element={<FacultyAssignmentDetailPage />} />
<Route path="projects" element={<ProjectsPage />} />
<Route path="projects/:id/edit" element={<ProjectEditorPage />} />
<Route path="announcements" element={<FacultyAnnouncementsPage />} />
<Route path="submissions" element={<FacultySubmissionsPage />} />
<Route path="submissions/:submissionId" element={<FacultyGradingPage />} />
Expand Down
70 changes: 70 additions & 0 deletions dashboard/src/components/ui/DeleteProjectDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<string | null>(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 (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete "{project?.project_title}"?</AlertDialogTitle>
<AlertDialogDescription>
This permanently removes the project and its saved blocks. This cannot be
undone.
</AlertDialogDescription>
</AlertDialogHeader>
{error && <p className="text-sm text-destructive">{error}</p>}
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={loading}
className="bg-destructive/10 text-destructive hover:bg-destructive/20"
>
{loading ? "Deleting…" : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
1 change: 1 addition & 0 deletions dashboard/src/components/ui/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { useLocation, Link } from "react-router-dom";
const routeLabels: Record<string, string> = {
"/": "Dashboard",
"/assignments": "Assignments",
"/projects": "Projects",
"/announcements": "Announcements",
"/settings": "Settings",
};
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/faculty/FacultySidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Link, useLocation } from "react-router-dom";
import {
LayoutDashboard,
ClipboardList,
Blocks,
BookOpen,
GraduationCap,
Inbox,
Expand Down Expand Up @@ -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[],
},
{
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/faculty/FacultyTopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const routeLabels: Record<string, string> = {
"/faculty/": "Dashboard",
"/faculty": "Dashboard",
"/faculty/assignments": "Submissions",
"/faculty/projects": "Projects",
"/faculty/announcements": "Announcements",
"/faculty/settings": "Settings",
};
Expand Down
11 changes: 11 additions & 0 deletions dashboard/src/hooks/useProjectsPortal.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
Loading
Loading