From 2d30e72c4785a6893cd7880e62c2ecbbeb61c95e Mon Sep 17 00:00:00 2001 From: William Stein Date: Sat, 28 Sep 2024 18:29:25 +0000 Subject: [PATCH 1/6] course assignment: added dialog for configuring individual, group and exam work - all the real work remains --- .../frontend/course/assignments/actions.ts | 5 + .../course/assignments/assignment.tsx | 19 +- .../frontend/course/assignments/location.tsx | 183 ++++++++++++++++++ src/packages/frontend/course/store.ts | 8 + src/packages/frontend/course/types.ts | 8 +- 5 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 src/packages/frontend/course/assignments/location.tsx diff --git a/src/packages/frontend/course/assignments/actions.ts b/src/packages/frontend/course/assignments/actions.ts index c28dbe77f2..ccc5a22499 100644 --- a/src/packages/frontend/course/assignments/actions.ts +++ b/src/packages/frontend/course/assignments/actions.ts @@ -47,6 +47,7 @@ import { CourseStore, get_nbgrader_score, NBgraderRunInfo, + AssignmentLocation, } from "../store"; import { AssignmentCopyType, @@ -2297,4 +2298,8 @@ ${details} set_activity({ id }); } }; + + setLocation = (assignment_id: string, location: AssignmentLocation) => { + this.course_actions.set({ table: "assignments", assignment_id, location }); + }; } diff --git a/src/packages/frontend/course/assignments/assignment.tsx b/src/packages/frontend/course/assignments/assignment.tsx index f581fcff70..79bf12d730 100644 --- a/src/packages/frontend/course/assignments/assignment.tsx +++ b/src/packages/frontend/course/assignments/assignment.tsx @@ -16,7 +16,7 @@ import { capitalize, trunc_middle } from "@cocalc/util/misc"; import { Alert, Button, Card, Col, Input, Popconfirm, Row, Space } from "antd"; import { ReactElement, useState } from "react"; import { DebounceInput } from "react-debounce-input"; -import { CourseActions } from "../actions"; +import type { CourseActions } from "../actions"; import { BigTime, Progress } from "../common"; import { NbgraderButton } from "../nbgrader/nbgrader-button"; import { @@ -39,6 +39,7 @@ import { STUDENT_SUBDIR } from "./consts"; import { StudentListForAssignment } from "./assignment-student-list"; import { ConfigurePeerGrading } from "./configure-peer"; import { SkipCopy } from "./skip"; +import Location from "./location"; interface AssignmentProps { active_feedback_edits: IsGradingMap; @@ -268,7 +269,12 @@ export function Assignment({ }; v.push( - {render_open_button()} + + + {render_open_button()} + + + @@ -431,10 +437,10 @@ export function Assignment({ Open Folder } - tip="Open the directory in the current project that contains the original files for this assignment. Edit files in this folder to create the content that your students will see when they receive an assignment." + tip="Open the folder in the current project that contains the original files for this assignment. Edit files in this folder to create the content that your students will see when they receive an assignment." > ); @@ -451,10 +457,7 @@ export function Assignment({ const last_assignment = assignment.get("last_assignment"); // Primary if it hasn't been assigned before or if it hasn't started assigning. let type; - if ( - !last_assignment || - !(last_assignment.get("time") || last_assignment.get("start")) - ) { + if (!last_assignment) { type = "primary"; } else { type = "default"; diff --git a/src/packages/frontend/course/assignments/location.tsx b/src/packages/frontend/course/assignments/location.tsx new file mode 100644 index 0000000000..82bc6e3972 --- /dev/null +++ b/src/packages/frontend/course/assignments/location.tsx @@ -0,0 +1,183 @@ +/* +Configure the location of this assignment. + +The location is one of these: + +- 'individual': Student's personal project (the default) +- 'exam': Student's exam project \- they only have access **during the exam** +- 'group': Group project \- need nice ui to divide students into groups and let instructor customize + +The location can't be changed once any assignments have been assigned. + +This component is responsible for: + +- Displaying the selected location +- Changing the location +- Editing the groups in case of 'group' +*/ + +import { useState } from "react"; +import type { AssignmentLocation, AssignmentRecord } from "../store"; +import type { CourseActions } from "../actions"; +import { Alert, Button, Divider, Modal, Radio, Tooltip } from "antd"; +import type { CheckboxOptionType } from "antd"; +import { Icon } from "@cocalc/frontend/components/icon"; + +const LOCATIONS = { + individual: { + color: "#006ab5", + icon: "user", + label: "Individual", + desc: "their own personal course project", + }, + exam: { + color: "darkgreen", + icon: "graduation-cap", + label: "Exam", + desc: "an exam-specific project that they have access to only during the exam", + }, + group: { + color: "#8b0000", + icon: "users", + label: "Group", + desc: "an assignment-specific project with a configurable group of other students", + }, +}; + +export default function Location({ + assignment, + actions, +}: { + assignment: AssignmentRecord; + actions: CourseActions; +}) { + const [open, setOpen] = useState(false); + const location = getLocation(assignment); + const { icon, label, desc, color } = LOCATIONS[location] ?? { + label: "Bug", + icon: "bug", + }; + return ( + <> + {open && ( + + )} + + Students work on their copy of '{assignment.get("path")}' in {desc}. + + } + > + + + + ); +} + +function EditLocation({ assignment, actions, setOpen }) { + const last_assignment = assignment.get("last_assignment"); + let disabled = false; + if (last_assignment != null) { + const store = actions.get_store(); + const status = store?.get_assignment_status( + assignment.get("assignment_id"), + ); + if ((status?.assignment ?? 0) > 0) { + disabled = true; + } + } + const options: CheckboxOptionType[] = []; + const curLocation = getLocation(assignment); + for (const location in LOCATIONS) { + const { icon, label, desc, color } = LOCATIONS[location]; + options.push({ + label: ( +
+ + {label}{" "} + {" "} + - students work in {desc} +
+ ), + value: location, + disabled: disabled && location != curLocation, + }); + } + return ( + + Location Where Students Work on ' + {assignment.get("path")}' + + } + onCancel={() => setOpen(false)} + onOk={() => setOpen(false)} + cancelButtonProps={{ style: { display: "none" } }} + okText="Close" + > + { + actions.assignments.setLocation( + assignment.get("assignment_id"), + e.target.value, + ); + }} + /> + {disabled && ( + + )} + {curLocation == "group" && ( + + )} + + ); +} + +function getLocation(assignment): AssignmentLocation { + const location = assignment.get("location") ?? "individual"; + if (location == "individual" || location == "exam" || location == "group") { + return location; + } + return "individual"; +} + +function getGroups(assignment) { + const groups = assignment.get("groups")?.toJS(); + if (groups == null || typeof groups != "object") { + return {}; + } + return groups; +} + +function GroupConfiguration({ assignment, actions, disabled }) { + const groups = getGroups(assignment); + console.log({ assignment, actions, disabled }); + return ( +
+ Group Configuration + TODO: Group configuration for assignment: {JSON.stringify(groups)} +
+ ); +} diff --git a/src/packages/frontend/course/store.ts b/src/packages/frontend/course/store.ts index 618532c20d..9daebbbb77 100644 --- a/src/packages/frontend/course/store.ts +++ b/src/packages/frontend/course/store.ts @@ -82,6 +82,8 @@ export type LastCopyInfo = { start?: number; }; +export type AssignmentLocation = "individual" | "exam" | "group"; + export type AssignmentRecord = TypedMap<{ assignment_id: string; deleted: boolean; @@ -92,6 +94,12 @@ export type AssignmentRecord = TypedMap<{ due_date: number; map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student }; + location?: AssignmentLocation; + groups?: { + // Map student to the group they are in for this group assignment. + // This is only used when AssignmentLocation is 'group'. + [student_id: string]: string; + }; note: string; last_assignment?: { [student_id: string]: LastCopyInfo }; diff --git a/src/packages/frontend/course/types.ts b/src/packages/frontend/course/types.ts index 6603ad9c2f..4b9e8897dd 100644 --- a/src/packages/frontend/course/types.ts +++ b/src/packages/frontend/course/types.ts @@ -8,7 +8,11 @@ import { NotebookScores } from "../jupyter/nbgrader/autograde"; import { Datastore, EnvVars } from "../projects/actions"; import { StudentProjectFunctionality } from "./configuration/customize-student-project-functionality"; import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types"; -import type { CopyConfigurationOptions, CopyConfigurationTargets } from "./configuration/configuration-copying"; +import type { + CopyConfigurationOptions, + CopyConfigurationTargets, +} from "./configuration/configuration-copying"; +import type { AssignmentLocation } from "./store"; export interface SyncDBRecordBase { table: string; @@ -58,6 +62,8 @@ export interface SyncDBRecordAssignment { nbgrader?: boolean; // Very likely to be using nbgrader for this assignment (heuristic: existence of foo.ipynb and student/foo.ipynb) description?: string; title?: string; + location?: AssignmentLocation; + groups?: { [student_id: string]: string }; grades?: { [student_id: string]: string }; comments?: { [student_id: string]: string }; nbgrader_scores?: { From db5788de0effac70545aaa47e36dde1f1981c633 Mon Sep 17 00:00:00 2001 From: William Stein Date: Sat, 28 Sep 2024 20:35:28 +0000 Subject: [PATCH 2/6] course: refactoring project creation code, and automatically creating exam project --- docs/STYLE.md | 6 +- .../frontend/course/assignments/actions.ts | 132 ++++++++++++------ .../frontend/course/assignments/location.tsx | 2 +- .../common/student-assignment-info-header.tsx | 30 ++-- src/packages/frontend/course/store.ts | 4 + .../course/student-projects/actions.ts | 67 ++++++--- src/packages/frontend/course/types.ts | 3 + .../code-editor/codemirror-editor.tsx | 2 - 8 files changed, 170 insertions(+), 76 deletions(-) diff --git a/docs/STYLE.md b/docs/STYLE.md index fac6846437..a95d92e61d 100644 --- a/docs/STYLE.md +++ b/docs/STYLE.md @@ -14,6 +14,11 @@ - NOTE: there's a lot of Javascript code in cocalc that uses Python conventions. Long ago Nicholas R. argued "by using Python conventions we can easily distinguish our code from other code"; in retrospect, this was a bad argument, and only serves to make Javascript devs less comfortable in our codebase, and make our code look weird compared to most Javascript code. Rewrite it. +- Abbreviations: Do not use obscure abbreviations for variable names. + + - Good code is read much more than it is written, so make it easy to read. + - E.g., do not use "dflt" since: (1) it barely saves any characters over "default", and (2) if you do a Google search for "dflt" you will see it's not even a common abbreviation for default. + - Javascript Methods: Prefer arrow functions for methods of classes. - it's standard @@ -79,4 +84,3 @@ const MyButton: React.FC = (props) => { - Bootstrap: - CoCalc used to use jquery + bootstrap (way before react even existed!) for everything, and that's still in use for some things today (e.g., Sage Worksheets). Rewrite or delete all this. - CoCalc also used to use react-bootstrap, and sadly still does. Get rid of this. - diff --git a/src/packages/frontend/course/assignments/actions.ts b/src/packages/frontend/course/assignments/actions.ts index ccc5a22499..7438a1b895 100644 --- a/src/packages/frontend/course/assignments/actions.ts +++ b/src/packages/frontend/course/assignments/actions.ts @@ -37,7 +37,7 @@ import { } from "@cocalc/util/misc"; import { delay, map } from "awaiting"; import { debounce } from "lodash"; -import { Map } from "immutable"; +import { Map as iMap } from "immutable"; import { CourseActions } from "../actions"; import { export_assignment } from "../export/export-assignment"; import { export_student_file_use_times } from "../export/file-use-times"; @@ -73,6 +73,7 @@ import { DUE_DATE_FILENAME, } from "./consts"; import { COPY_TIMEOUT_MS } from "../consts"; +import { getLocation } from "./location"; const UPDATE_DUE_DATE_FILENAME_DEBOUNCE_MS = 3000; @@ -215,7 +216,7 @@ export class AssignmentsActions { } // Annoying that we have to convert to JS here and cast, // but the set below seems to require it. - let grades = assignment.get("grades", Map()).toJS() as { + let grades = assignment.get("grades", iMap()).toJS() as { [student_id: string]: string; }; grades[student_id] = grade; @@ -244,7 +245,7 @@ export class AssignmentsActions { } // Annoying that we have to convert to JS here and cast, // but the set below seems to require it. - let comments = assignment.get("comments", Map()).toJS() as { + let comments = assignment.get("comments", iMap()).toJS() as { [student_id: string]: string; }; comments[student_id] = comment; @@ -357,14 +358,11 @@ export class AssignmentsActions { }); if (!student || !assignment) return; const content = this.dueDateFileContent(assignment_id); - const project_id = student.get("project_id"); - if (!project_id) return; + const project_id = this.getProjectId({ assignment, student }); + if (!project_id) { + return; + } const path = join(assignment.get("target_path"), DUE_DATE_FILENAME); - console.log({ - project_id, - path, - content, - }); await webapp_client.project_client.write_text_file({ project_id, path, @@ -442,12 +440,6 @@ export class AssignmentsActions { }); if (!student || !assignment) return; const student_name = store.get_student_name(student_id); - const student_project_id = student.get("project_id"); - if (student_project_id == null) { - // nothing to do - this.course_actions.clear_activity(id); - return; - } const target_path = join( assignment.get("collect_path"), student.get("student_id"), @@ -457,6 +449,15 @@ export class AssignmentsActions { desc: `Copying assignment from ${student_name}`, }); try { + const student_project_id = this.getProjectId({ + assignment, + student, + }); + if (student_project_id == null) { + // nothing to do + this.course_actions.clear_activity(id); + return; + } await webapp_client.project_client.copy_path_between_projects({ src_project_id: student_project_id, src_path: assignment.get("target_path"), @@ -513,7 +514,7 @@ export class AssignmentsActions { const grade = store.get_grade(assignment_id, student_id); const comments = store.get_comments(assignment_id, student_id); const student_name = store.get_student_name(student_id); - const student_project_id = student.get("project_id"); + const student_project_id = this.getProjectId({ assignment, student }); // if skip_grading is true, this means there *might* no be a "grade" given, // but instead some grading inside the files or an external tool is used. @@ -829,22 +830,12 @@ ${details} id, desc: `Copying assignment to ${student_name}`, }); - let student_project_id: string | undefined = student.get("project_id"); const src_path = this.assignment_src_path(assignment); try { - if (student_project_id == null) { - this.course_actions.set_activity({ - id, - desc: `${student_name}'s project doesn't exist, so creating it.`, - }); - student_project_id = - await this.course_actions.student_projects.create_student_project( - student_id, - ); - if (!student_project_id) { - throw Error("failed to create project"); - } - } + const student_project_id = await this.getOrCreateProjectId({ + assignment, + student, + }); if (create_due_date_file) { await this.copy_assignment_create_due_date_file(assignment_id); } @@ -1092,10 +1083,10 @@ ${details} const id = this.course_actions.set_activity({ desc: "Parsing peer grading", }); - const allGrades = assignment.get("grades", Map()).toJS() as { + const allGrades = assignment.get("grades", iMap()).toJS() as { [student_id: string]: string; }; - const allComments = assignment.get("comments", Map()).toJS() as { + const allComments = assignment.get("comments", iMap()).toJS() as { [student_id: string]: string; }; // compute missing grades @@ -1329,7 +1320,10 @@ ${details} return; } - const student_project_id = student.get("project_id"); + const student_project_id = this.getProjectId({ + assignment, + student, + }); if (!student_project_id) { finish(); return; @@ -1500,7 +1494,7 @@ ${details} student_id, }); if (assignment == null || student == null) return; - const student_project_id = student.get("project_id"); + const student_project_id = this.getProjectId({ assignment, student }); if (student_project_id == null) { this.course_actions.set_error( "open_assignment: student project not yet created", @@ -1801,7 +1795,7 @@ ${details} } const scores: any = assignment - .getIn(["nbgrader_scores", student_id], Map()) + .getIn(["nbgrader_scores", student_id], iMap()) .toJS(); let x: any = scores[filename]; if (x == null) { @@ -1897,7 +1891,7 @@ ${details} ]); const course_project_id = store.get("course_project_id"); - const student_project_id = student.get("project_id"); + const student_project_id = this.getProjectId({ assignment, student }); let grade_project_id: string; let student_path: string; @@ -2202,7 +2196,7 @@ ${details} const store = this.get_store(); let nbgrader_run_info: NBgraderRunInfo = store.get( "nbgrader_run_info", - Map(), + iMap(), ); const key = student_id ? `${assignment_id}-${student_id}` : assignment_id; nbgrader_run_info = nbgrader_run_info.set(key, webapp_client.server_time()); @@ -2216,7 +2210,7 @@ ${details} const store = this.get_store(); let nbgrader_run_info: NBgraderRunInfo = store.get( "nbgrader_run_info", - Map(), + iMap(), ); const key = student_id ? `${assignment_id}-${student_id}` : assignment_id; nbgrader_run_info = nbgrader_run_info.delete(key); @@ -2302,4 +2296,64 @@ ${details} setLocation = (assignment_id: string, location: AssignmentLocation) => { this.course_actions.set({ table: "assignments", assignment_id, location }); }; + + private getProjectId = ({ + assignment, + student, + }: { + assignment; + student; + }): string | null | undefined => { + const location = getLocation(assignment); + if (location == "group") { + const group = assignment.getIn(["groups", student.get("student_id")]); + if (group != null) { + return assignment.getIn(["group_projects", group]); + } + return null; + } else if (location == "exam") { + return assignment.getIn(["exam_projects", student.get("student_id")]); + } else { + return student.get("project_id"); + } + }; + + private getOrCreateProjectId = async ({ + assignment, + student, + }: { + assignment; + student; + create?: boolean; + }): Promise => { + let student_project_id = this.getProjectId({ assignment, student }); + if (student_project_id != null) { + return student_project_id; + } + const location = getLocation(assignment); + const student_id = student.get("student_id"); + const assignment_id = assignment.get("assignment_id"); + let project_id; + if (location == "individual") { + project_id = + await this.course_actions.student_projects.create_student_project( + student_id, + ); + } else if (location == "exam") { + project_id = + await this.course_actions.student_projects.createProjectForStudentUse( + student_id, + ); + const exam_projects = assignment.get("exam_projects") ?? iMap({}); + this.set_assignment_field( + assignment_id, + "exam_projects", + exam_projects.set(student_id, project_id), + ); + } + if (!project_id) { + throw Error("failed to create project"); + } + return project_id; + }; } diff --git a/src/packages/frontend/course/assignments/location.tsx b/src/packages/frontend/course/assignments/location.tsx index 82bc6e3972..8caeeb3a98 100644 --- a/src/packages/frontend/course/assignments/location.tsx +++ b/src/packages/frontend/course/assignments/location.tsx @@ -155,7 +155,7 @@ function EditLocation({ assignment, actions, setOpen }) { ); } -function getLocation(assignment): AssignmentLocation { +export function getLocation(assignment): AssignmentLocation { const location = assignment.get("location") ?? "individual"; if (location == "individual" || location == "exam" || location == "group") { return location; diff --git a/src/packages/frontend/course/common/student-assignment-info-header.tsx b/src/packages/frontend/course/common/student-assignment-info-header.tsx index bedec5f8c1..b735fb9416 100644 --- a/src/packages/frontend/course/common/student-assignment-info-header.tsx +++ b/src/packages/frontend/course/common/student-assignment-info-header.tsx @@ -7,6 +7,7 @@ import { Tip } from "@cocalc/frontend/components"; import { unreachable } from "@cocalc/util/misc"; import { Col, Row } from "antd"; import { AssignmentCopyStep } from "../types"; +import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context"; interface StudentAssignmentInfoHeaderProps { title: string; @@ -17,42 +18,49 @@ export function StudentAssignmentInfoHeader({ title, peer_grade, }: StudentAssignmentInfoHeaderProps) { - function tip_title(key: AssignmentCopyStep | "grade"): { - tip: string; - title: string; - } { + const { actions } = useFrameContext(); + function tip_title(key: AssignmentCopyStep | "grade") { switch (key) { case "assignment": return { title: "Assign to Student", - tip: "This column gives the status of making homework available to students, and lets you copy homework to one student at a time.", + tip: "Status of making assignment available to students; also, you can copy assignment to one student at a time.", }; case "collect": return { title: "Collect from Student", - tip: "This column gives status information about collecting homework from students, and lets you collect from one student at a time.", + tip: "Status information about collecting assignments from students; also, you can collect from one student at a time.", }; case "grade": return { - title: "Record Homework Grade", - tip: "Use this column to record the grade the student received on the assignment. Once the grade is recorded, you can return the assignment. You can also export grades to a file in the Configuration tab. Enter anything here; it does not have to be a number.", + title: "Record Assignment Grade", + tip: ( + <> + Record the grade the student received on the assignment. Once the + grade is recorded, you can return the assignment. You can also{" "} + (actions as any)?.setModal?.("export-grades")}> + export grades to a file in the Actions tab + + . Enter anything here; it does not have to be a number. + + ), }; case "peer_assignment": return { title: "Assign Peer Grading", - tip: "This column gives the status of sending out collected homework to students for peer grading.", + tip: "Status of sending out collected assignment to students for peer grading.", }; case "peer_collect": return { title: "Collect Peer Grading", - tip: "This column gives status information about collecting the peer grading work that students did, and lets you collect peer grading from one student at a time.", + tip: "Status information about collecting the peer grading work that students did; also, you can collect peer grading from one student at a time.", }; case "return_graded": return { title: "Return to Student", - tip: "This column gives status information about when you returned homework to the students. Once you have entered a grade, you can return the assignment.", + tip: "Status information about when you returned assignment to the students. Once you have entered a grade, you can return the assignment.", }; default: unreachable(key); diff --git a/src/packages/frontend/course/store.ts b/src/packages/frontend/course/store.ts index 9daebbbb77..3578bdd37f 100644 --- a/src/packages/frontend/course/store.ts +++ b/src/packages/frontend/course/store.ts @@ -94,12 +94,16 @@ export type AssignmentRecord = TypedMap<{ due_date: number; map: { [student_id: string]: string[] }; // map from student_id to *who* will grade that student }; + location?: AssignmentLocation; groups?: { // Map student to the group they are in for this group assignment. // This is only used when AssignmentLocation is 'group'. [student_id: string]: string; }; + group_projects?: { [group: string]: string }; + exam_projects?: { [student_id: string]: string }; + note: string; last_assignment?: { [student_id: string]: LastCopyInfo }; diff --git a/src/packages/frontend/course/student-projects/actions.ts b/src/packages/frontend/course/student-projects/actions.ts index eb351f7ee4..0bd16df127 100644 --- a/src/packages/frontend/course/student-projects/actions.ts +++ b/src/packages/frontend/course/student-projects/actions.ts @@ -39,13 +39,14 @@ export class StudentProjectsActions { return store; }; - // Create and configure a single student project. - create_student_project = async ( + // create project that will get used by this student, but doesn't actually + // add student as a collaborator or save the project id anywhere. + createProjectForStudentUse = async ( student_id: string, ): Promise => { const { store, student } = this.course_actions.resolve({ student_id, - finish: this.course_actions.set_error.bind(this), + finish: this.course_actions.set_error, }); if (store == null || student == null) return; if (store.get("students") == null || store.get("settings") == null) { @@ -54,27 +55,21 @@ export class StudentProjectsActions { ); return; } - if (student.get("project_id")) { - // project already created. - return student.get("project_id"); - } - this.course_actions.set({ - create_project: webapp_client.server_time(), - table: "students", - student_id, - }); const id = this.course_actions.set_activity({ desc: `Create project for ${store.get_student_name(student_id)}.`, }); - const dflt_img = await redux.getStore("customize").getDefaultComputeImage(); + const defaultImage = await redux + .getStore("customize") + .getDefaultComputeImage(); let project_id: string; try { project_id = await redux.getActions("projects").create_project({ title: store.get("settings").get("title"), description: store.get("settings").get("description"), - image: store.get("settings").get("custom_image") ?? dflt_img, + image: store.get("settings").get("custom_image") ?? defaultImage, noPool: true, // student is unlikely to use the project right *now* }); + this.configure_project_visibility(project_id); } catch (err) { this.course_actions.set_error( `error creating student project for ${store.get_student_name( @@ -85,16 +80,40 @@ export class StudentProjectsActions { } finally { this.course_actions.clear_activity(id); } + return project_id; + }; + + // Create and configure a single student project. + create_student_project = async ( + student_id: string, + ): Promise => { + const { student } = this.course_actions.resolve({ + student_id, + }); + if (student == null) { + // no such student -- nothing to do + return; + } + if (student.get("project_id")) { + // project already created. + return student.get("project_id"); + } this.course_actions.set({ - create_project: null, - project_id, + create_project: webapp_client.server_time(), table: "students", student_id, }); + const project_id = await this.createProjectForStudentUse(student_id); await this.configure_project({ student_id, student_project_id: project_id, }); + this.course_actions.set({ + create_project: null, + project_id, + table: "students", + student_id, + }); return project_id; }; @@ -561,15 +580,17 @@ export class StudentProjectsActions { } }; - private configure_project = async (props: { + private configure_project = async ({ + student_id, + student_project_id, + force_send_invite_by_email, + license_id, + }: { student_id; student_project_id?: string; force_send_invite_by_email?: boolean; license_id?: string; // relevant for serial license strategy only }): Promise => { - const { student_id, force_send_invite_by_email, license_id } = props; - let student_project_id = props.student_project_id; - // student_project_id is optional. Will be used instead of from student_id store if provided. // Configure project for the given student so that it has the right title, // description, and collaborators for belonging to the indicated student. @@ -605,8 +626,10 @@ export class StudentProjectsActions { ): Promise => { const store = this.get_store(); if (store == null) return; - const dflt_img = await redux.getStore("customize").getDefaultComputeImage(); - const img_id = store.get("settings").get("custom_image") ?? dflt_img; + const defaultImage = await redux + .getStore("customize") + .getDefaultComputeImage(); + const img_id = store.get("settings").get("custom_image") ?? defaultImage; const actions = redux.getProjectActions(student_project_id); await actions.set_compute_image(img_id); }; diff --git a/src/packages/frontend/course/types.ts b/src/packages/frontend/course/types.ts index 4b9e8897dd..4f8a0f801d 100644 --- a/src/packages/frontend/course/types.ts +++ b/src/packages/frontend/course/types.ts @@ -63,6 +63,9 @@ export interface SyncDBRecordAssignment { description?: string; title?: string; location?: AssignmentLocation; + exam_projects?: { [student_id: string]: string }; + group_projects?: { [group: string]: string }; + groups?: { [student_id: string]: string }; grades?: { [student_id: string]: string }; comments?: { [student_id: string]: string }; diff --git a/src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx b/src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx index 2dcad2f27e..0f5e56621f 100644 --- a/src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx +++ b/src/packages/frontend/frame-editors/code-editor/codemirror-editor.tsx @@ -514,8 +514,6 @@ export const CodemirrorEditor: React.FC = React.memo((props) => { ); }); -CodemirrorEditor.defaultProps = { value: "" }; - // Needed e.g., for vim ":w" support; this is global, // so be careful. if ((CodeMirror as any).commands.save == null) { From 80efb6eda7895fc5118f8de53ba75af55c22593d Mon Sep 17 00:00:00 2001 From: William Stein Date: Sun, 29 Sep 2024 02:48:12 +0000 Subject: [PATCH 3/6] add some little scripts to save me time --- src/scripts/g | 11 +++++++++++ src/scripts/g-tmux.sh | 8 ++++++++ 2 files changed, 19 insertions(+) create mode 100755 src/scripts/g create mode 100755 src/scripts/g-tmux.sh diff --git a/src/scripts/g b/src/scripts/g new file mode 100755 index 0000000000..ad4c91c7ec --- /dev/null +++ b/src/scripts/g @@ -0,0 +1,11 @@ +mkdir -p `pwd`/logs +export LOGS=`pwd`/logs +rm -f $LOGS/log +unset INIT_CWD +unset PGHOST +export DEBUG="cocalc:*" +#export DEBUG_CONSOLE="yes" +unset DEBUG_CONSOLE + +export COCALC_DISABLE_API_VALIDATION=yes +pnpm hub diff --git a/src/scripts/g-tmux.sh b/src/scripts/g-tmux.sh new file mode 100755 index 0000000000..8a0f6a3378 --- /dev/null +++ b/src/scripts/g-tmux.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +tmux new-session -d -s mysession +tmux new-window -t mysession:1 +sleep 1 +tmux send-keys -t mysession:1 './scripts/g' C-m +tmux send-keys -t mysession:0 'pnpm database' C-m +tmux attach -t mysession From 2e15c9744f09ff6187d5463c9b4cc46aaaddf504 Mon Sep 17 00:00:00 2001 From: William Stein Date: Sun, 29 Sep 2024 16:34:41 +0000 Subject: [PATCH 4/6] course: working on exam/group modes --- src/packages/frontend/course/assignments/actions.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/packages/frontend/course/assignments/actions.ts b/src/packages/frontend/course/assignments/actions.ts index 7438a1b895..99300110d4 100644 --- a/src/packages/frontend/course/assignments/actions.ts +++ b/src/packages/frontend/course/assignments/actions.ts @@ -2350,6 +2350,8 @@ ${details} "exam_projects", exam_projects.set(student_id, project_id), ); + } else if (location == "group") { + throw Error("create group project: not implemented"); } if (!project_id) { throw Error("failed to create project"); From 9848014920e96fab5a1f9681960bbd3fd3dfb9f7 Mon Sep 17 00:00:00 2001 From: William Stein Date: Mon, 30 Sep 2024 03:53:21 +0000 Subject: [PATCH 5/6] course: starting to work on configuring exam projects --- .../frontend/course/assignments/actions.ts | 7 ++- .../course/student-projects/actions.ts | 55 +++++++++++++++++-- src/packages/frontend/projects/actions.ts | 14 ++--- src/packages/util/db-schema/projects.ts | 2 +- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/src/packages/frontend/course/assignments/actions.ts b/src/packages/frontend/course/assignments/actions.ts index 99300110d4..d92bceb148 100644 --- a/src/packages/frontend/course/assignments/actions.ts +++ b/src/packages/frontend/course/assignments/actions.ts @@ -2297,7 +2297,7 @@ ${details} this.course_actions.set({ table: "assignments", assignment_id, location }); }; - private getProjectId = ({ + getProjectId = ({ assignment, student, }: { @@ -2341,9 +2341,10 @@ ${details} ); } else if (location == "exam") { project_id = - await this.course_actions.student_projects.createProjectForStudentUse( + await this.course_actions.student_projects.createProjectForStudentUse({ student_id, - ); + type: "exam", + }); const exam_projects = assignment.get("exam_projects") ?? iMap({}); this.set_assignment_field( assignment_id, diff --git a/src/packages/frontend/course/student-projects/actions.ts b/src/packages/frontend/course/student-projects/actions.ts index 0bd16df127..14051d17a6 100644 --- a/src/packages/frontend/course/student-projects/actions.ts +++ b/src/packages/frontend/course/student-projects/actions.ts @@ -41,9 +41,13 @@ export class StudentProjectsActions { // create project that will get used by this student, but doesn't actually // add student as a collaborator or save the project id anywhere. - createProjectForStudentUse = async ( - student_id: string, - ): Promise => { + createProjectForStudentUse = async ({ + student_id, + type, + }: { + student_id: string; + type: "student" | "exam" | "group"; + }): Promise => { const { store, student } = this.course_actions.resolve({ student_id, finish: this.course_actions.set_error, @@ -70,6 +74,18 @@ export class StudentProjectsActions { noPool: true, // student is unlikely to use the project right *now* }); this.configure_project_visibility(project_id); + + // important to at least set the basics of the course field, since this + // modifies security model so any instructor can add colabs to this project, + // even if the instructor wasn't added -- that just deals with an edge + // case that often causes problems otherwise. + const actions = redux.getActions("projects"); + await actions.set_project_course_info({ + project_id, + course_project_id: store.get("course_project_id"), + path: store.get("course_filename"), + type, + }); } catch (err) { this.course_actions.set_error( `error creating student project for ${store.get_student_name( @@ -103,7 +119,10 @@ export class StudentProjectsActions { table: "students", student_id, }); - const project_id = await this.createProjectForStudentUse(student_id); + const project_id = await this.createProjectForStudentUse({ + student_id, + type: "student", + }); await this.configure_project({ student_id, student_project_id: project_id, @@ -945,4 +964,32 @@ export class StudentProjectsActions { } } }; + + configureExamProject = async ({ assignment, student, mode }) => { + /* + If the project hasn't already been created, do nothing. Otherwise: + + mode = 'exam': + - set collabs on exam project to be exactly the student and all collabs on course project + - make project visible only to the student + - configure project title and description + - configure project image + - configure project license + + mode = 'instructor' + - remove the student + */ + const project_id = this.course_actions.assignments.getProjectId({ + assignment, + student, + }); + if (project_id == null) { + return; + } + if (mode == "exam") { + } else { + } + }; + + //configureGroupProject = async ({ assignment, student }) => {}; } diff --git a/src/packages/frontend/projects/actions.ts b/src/packages/frontend/projects/actions.ts index 1618a2b0da..2c263dd799 100644 --- a/src/packages/frontend/projects/actions.ts +++ b/src/packages/frontend/projects/actions.ts @@ -290,11 +290,11 @@ export class ProjectsActions extends Actions { } } - public async set_project_course_info({ + set_project_course_info = async ({ project_id, course_project_id, path, - pay, + pay = "", payInfo, account_id, email_address, @@ -306,15 +306,15 @@ export class ProjectsActions extends Actions { project_id: string; course_project_id: string; path: string; - pay: Date | string; + pay?: Date | string; payInfo?: PurchaseInfo | null; account_id?: string | null; email_address?: string | null; - datastore: Datastore; - type: "student" | "shared" | "nbgrader"; + datastore?: Datastore; + type: "student" | "shared" | "nbgrader" | "exam" | "group"; student_project_functionality?: StudentProjectFunctionality | null; envvars?: EnvVars; - }): Promise { + }): Promise => { if (!(await this.have_project(project_id))) { const msg = `Can't set course info -- you are not a collaborator on project '${project_id}'.`; console.warn(msg); @@ -350,7 +350,7 @@ export class ProjectsActions extends Actions { return; } return await api("projects/course/set-course-info", { project_id, course }); - } + }; // Create a new project; returns the project_id of the new project. public async create_project(opts: { diff --git a/src/packages/util/db-schema/projects.ts b/src/packages/util/db-schema/projects.ts index b191fd8a21..7eeeada64e 100644 --- a/src/packages/util/db-schema/projects.ts +++ b/src/packages/util/db-schema/projects.ts @@ -640,7 +640,7 @@ export interface StudentProjectFunctionality { } export interface CourseInfo { - type: "student" | "shared" | "nbgrader"; + type: "student" | "shared" | "nbgrader" | "exam" | "group"; account_id?: string; // account_id of the student that this project is for. project_id: string; // the course project, i.e., project with the .course file path: string; // path to the .course file in project_id From 8bd07affb26cbfb19ccb172e55444cfc3c3c093e Mon Sep 17 00:00:00 2001 From: William Stein Date: Mon, 30 Sep 2024 03:57:27 +0000 Subject: [PATCH 6/6] fix an English mistake in project log description --- src/packages/frontend/project/history/log-entry.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/packages/frontend/project/history/log-entry.tsx b/src/packages/frontend/project/history/log-entry.tsx index 7fbfb2f0b0..427cb1de9b 100644 --- a/src/packages/frontend/project/history/log-entry.tsx +++ b/src/packages/frontend/project/history/log-entry.tsx @@ -805,9 +805,9 @@ export const LogEntry: React.FC = React.memo( case "undelete_project": return undeleted the project; case "hide_project": - return hid the project from themself; + return hid the project from themselves; case "unhide_project": - return unhid the project from themself; + return unhid the project from themselves; case "public_path": return render_public_path(event); case "software_environment":