Path: blob/master/src/packages/frontend/course/configuration/actions.ts
1503 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45/*6Actions involving configuration of the course.7*/89// cSpell:ignore collabs1011import { redux } from "@cocalc/frontend/app-framework";12import {13derive_project_img_name,14SoftwareEnvironmentState,15} from "@cocalc/frontend/custom-software/selector";16import { Datastore, EnvVars } from "@cocalc/frontend/projects/actions";17import { store as projects_store } from "@cocalc/frontend/projects/store";18import { webapp_client } from "@cocalc/frontend/webapp-client";19import { reuseInFlight } from "@cocalc/util/reuse-in-flight";20import { CourseActions, primary_key } from "../actions";21import {22DEFAULT_LICENSE_UPGRADE_HOST_PROJECT,23CourseSettingsRecord,24PARALLEL_DEFAULT,25} from "../store";26import { SiteLicenseStrategy, SyncDBRecord, UpgradeGoal } from "../types";27import {28StudentProjectFunctionality,29completeStudentProjectFunctionality,30} from "./customize-student-project-functionality";31import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";32import { delay } from "awaiting";33import {34NBGRADER_CELL_TIMEOUT_MS,35NBGRADER_MAX_OUTPUT,36NBGRADER_MAX_OUTPUT_PER_CELL,37NBGRADER_TIMEOUT_MS,38} from "../assignments/consts";3940interface ConfigurationTarget {41project_id: string;42path: string;43}4445export class ConfigurationActions {46private course_actions: CourseActions;47private configuring: boolean = false;48private configureAgain: boolean = false;4950constructor(course_actions: CourseActions) {51this.course_actions = course_actions;52this.push_missing_handouts_and_assignments = reuseInFlight(53this.push_missing_handouts_and_assignments.bind(this),54);55}5657set = (obj: SyncDBRecord, commit: boolean = true): void => {58this.course_actions.set(obj, commit);59};6061set_title = (title: string): void => {62this.set({ title, table: "settings" });63this.course_actions.student_projects.set_all_student_project_titles(title);64this.course_actions.shared_project.set_project_title();65};6667set_description = (description: string): void => {68this.set({ description, table: "settings" });69this.course_actions.student_projects.set_all_student_project_descriptions(70description,71);72this.course_actions.shared_project.set_project_description();73};7475// NOTE: site_license_id can be a single id, or multiple id's separate by a comma.76add_site_license_id = (license_id: string): void => {77const store = this.course_actions.get_store();78let site_license_id = store.getIn(["settings", "site_license_id"]) ?? "";79if (site_license_id.indexOf(license_id) != -1) return; // already known80site_license_id += (site_license_id.length > 0 ? "," : "") + license_id;81this.set({ site_license_id, table: "settings" });82};8384remove_site_license_id = (license_id: string): void => {85const store = this.course_actions.get_store();86let cur = store.getIn(["settings", "site_license_id"]) ?? "";87let removed = store.getIn(["settings", "site_license_removed"]) ?? "";88if (cur.indexOf(license_id) == -1) return; // already removed89const v: string[] = [];90for (const id of cur.split(",")) {91if (id != license_id) {92v.push(id);93}94}95const site_license_id = v.join(",");96if (!removed.includes(license_id)) {97removed = removed.split(",").concat([license_id]).join(",");98}99this.set({100site_license_id,101site_license_removed: removed,102table: "settings",103});104};105106set_site_license_strategy = (107site_license_strategy: SiteLicenseStrategy,108): void => {109this.set({ site_license_strategy, table: "settings" });110};111112set_pay_choice = (type: "student" | "institute", value: boolean): void => {113this.set({ [type + "_pay"]: value, table: "settings" });114if (type == "student") {115if (!value) {116this.setStudentPay({ when: "" });117}118}119};120121set_upgrade_goal = (upgrade_goal: UpgradeGoal): void => {122this.set({ upgrade_goal, table: "settings" });123};124125set_allow_collabs = (allow_collabs: boolean): void => {126this.set({ allow_collabs, table: "settings" });127this.course_actions.student_projects.configure_all_projects();128};129130set_student_project_functionality = async (131student_project_functionality: StudentProjectFunctionality,132): Promise<void> => {133this.set({ student_project_functionality, table: "settings" });134await this.course_actions.student_projects.configure_all_projects();135};136137set_email_invite = (body: string): void => {138this.set({ email_invite: body, table: "settings" });139};140141// Set the pay option for the course, and ensure that the course fields are142// set on every student project in the course (see schema.coffee for format143// of the course field) to reflect this change in the database.144setStudentPay = async ({145when,146info,147cost,148}: {149when?: Date | string; // date when they need to pay150info?: PurchaseInfo; // what they must buy for the course151cost?: number;152}) => {153const value = {154...(info != null ? { payInfo: info } : undefined),155...(when != null156? { pay: typeof when != "string" ? when.toISOString() : when }157: undefined),158...(cost != null ? { payCost: cost } : undefined),159};160const store = this.course_actions.get_store();161// wait until store changes with new settings, then configure student projects162store.once("change", async () => {163await this.course_actions.student_projects.set_all_student_project_course_info();164});165await this.set({166table: "settings",167...value,168});169};170171configure_host_project = async (): Promise<void> => {172const id = this.course_actions.set_activity({173desc: "Configuring host project.",174});175try {176// NOTE: we never remove it or any other licenses from the host project,177// since instructor may want to augment license with another license.178const store = this.course_actions.get_store();179// be explicit about copying all course licenses to host project180// https://github.com/sagemathinc/cocalc/issues/5360181const license_upgrade_host_project =182store.getIn(["settings", "license_upgrade_host_project"]) ??183DEFAULT_LICENSE_UPGRADE_HOST_PROJECT;184if (license_upgrade_host_project) {185const site_license_id = store.getIn(["settings", "site_license_id"]);186const actions = redux.getActions("projects");187const course_project_id = store.get("course_project_id");188if (site_license_id) {189await actions.add_site_license_to_project(190course_project_id,191site_license_id,192);193}194}195} catch (err) {196this.course_actions.set_error(`Error configuring host project - ${err}`);197} finally {198this.course_actions.set_activity({ id });199}200};201202configure_all_projects = async (force: boolean = false): Promise<void> => {203if (this.configuring) {204// Important -- if configure_all_projects is called *while* it is running,205// wait until it is done, then call it again (though I'm being lazy about the206// await!). Don't do the actual work more than once207// at the same time since that might confuse the db writes, but208// also don't just reuse in flight, which will miss the later calls.209this.configureAgain = true;210return;211}212try {213this.configureAgain = false;214this.configuring = true;215await this.course_actions.shared_project.configure();216await this.configure_host_project();217await this.course_actions.student_projects.configure_all_projects(force);218await this.configure_nbgrader_grade_project();219} finally {220this.configuring = false;221if (this.configureAgain) {222this.configureAgain = false;223this.configure_all_projects();224}225}226};227228push_missing_handouts_and_assignments = async (): Promise<void> => {229const store = this.course_actions.get_store();230for (const student_id of store.get_student_ids({ deleted: false })) {231await this.course_actions.students.push_missing_handouts_and_assignments(232student_id,233);234}235};236237set_copy_parallel = (copy_parallel: number = PARALLEL_DEFAULT): void => {238this.set({239copy_parallel,240table: "settings",241});242};243244configure_nbgrader_grade_project = async (245project_id?: string,246): Promise<void> => {247let store;248try {249store = this.course_actions.get_store();250} catch (_) {251// this could get called during grading that is ongoing right when252// the user decides to close the document, and in that case get_store()253// would throw an error: https://github.com/sagemathinc/cocalc/issues/7050254return;255}256257if (project_id == null) {258project_id = store.getIn(["settings", "nbgrader_grade_project"]);259}260if (project_id == null || project_id == "") return;261262const id = this.course_actions.set_activity({263desc: "Configuring grading project.",264});265266try {267// make sure the course config for that nbgrader project (mainly for the datastore!) is set268const datastore: Datastore = store.get_datastore();269const envvars: EnvVars = store.get_envvars();270const projects_actions = redux.getActions("projects");271272// if for some reason this is a student project, we don't want to reconfigure it273const course_info: any = projects_store274.get_course_info(project_id)275?.toJS();276if (course_info?.type == null || course_info.type == "nbgrader") {277await projects_actions.set_project_course_info({278project_id,279course_project_id: store.get("course_project_id"),280path: store.get("course_filename"),281pay: "", // pay282payInfo: null,283account_id: null,284email_address: null,285datastore,286type: "nbgrader",287envvars,288});289}290291// we also make sure all teachers have access to that project – otherwise NBGrader can't work, etc.292// this has to happen *after* setting the course field, extended access control, ...293const ps = redux.getStore("projects");294const teachers = ps.get_users(store.get("course_project_id"));295const users_of_grade_project = ps.get_users(project_id);296if (users_of_grade_project != null && teachers != null) {297for (const account_id of teachers.keys()) {298const user = users_of_grade_project.get(account_id);299if (user != null) continue;300await webapp_client.project_collaborators.add_collaborator({301account_id,302project_id,303});304}305}306} catch (err) {307this.course_actions.set_error(308`Error configuring grading project - ${err}`,309);310} finally {311this.course_actions.set_activity({ id });312}313};314315// project_id is a uuid *or* empty string.316set_nbgrader_grade_project = async (317project_id: string = "",318): Promise<void> => {319this.set({320nbgrader_grade_project: project_id,321table: "settings",322});323324// not empty string → configure that grading project325if (project_id) {326await this.configure_nbgrader_grade_project(project_id);327}328};329330set_nbgrader_cell_timeout_ms = (331nbgrader_cell_timeout_ms: number = NBGRADER_CELL_TIMEOUT_MS,332): void => {333this.set({334nbgrader_cell_timeout_ms,335table: "settings",336});337};338339set_nbgrader_timeout_ms = (340nbgrader_timeout_ms: number = NBGRADER_TIMEOUT_MS,341): void => {342this.set({343nbgrader_timeout_ms,344table: "settings",345});346};347348set_nbgrader_max_output = (349nbgrader_max_output: number = NBGRADER_MAX_OUTPUT,350): void => {351this.set({352nbgrader_max_output,353table: "settings",354});355};356357set_nbgrader_max_output_per_cell = (358nbgrader_max_output_per_cell: number = NBGRADER_MAX_OUTPUT_PER_CELL,359): void => {360this.set({361nbgrader_max_output_per_cell,362table: "settings",363});364};365366set_nbgrader_include_hidden_tests = (value: boolean): void => {367this.set({368nbgrader_include_hidden_tests: value,369table: "settings",370});371};372373set_inherit_compute_image = (image?: string): void => {374this.set({ inherit_compute_image: image != null, table: "settings" });375if (image != null) {376this.set_compute_image(image);377}378};379380set_compute_image = (image: string) => {381this.set({382custom_image: image,383table: "settings",384});385this.course_actions.student_projects.configure_all_projects();386this.course_actions.shared_project.set_project_compute_image();387};388389set_software_environment = async (390state: SoftwareEnvironmentState,391): Promise<void> => {392const image = await derive_project_img_name(state);393this.set_compute_image(image);394};395396set_nbgrader_parallel = (397nbgrader_parallel: number = PARALLEL_DEFAULT,398): void => {399this.set({400nbgrader_parallel,401table: "settings",402});403};404405set_datastore = (datastore: Datastore): void => {406this.set({ datastore, table: "settings" });407setTimeout(() => {408this.configure_all_projects_shared_and_nbgrader();409}, 1);410};411412set_envvars = (inherit: boolean): void => {413this.set({ envvars: { inherit }, table: "settings" });414setTimeout(() => {415this.configure_all_projects_shared_and_nbgrader();416}, 1);417};418419set_license_upgrade_host_project = (upgrade: boolean): void => {420this.set({ license_upgrade_host_project: upgrade, table: "settings" });421setTimeout(() => {422this.configure_host_project();423}, 1);424};425426private configure_all_projects_shared_and_nbgrader = () => {427this.course_actions.student_projects.configure_all_projects();428this.course_actions.shared_project.set_datastore_and_envvars();429// in case there is a separate nbgrader project, we have to set the envvars as well430this.configure_nbgrader_grade_project();431};432433purgeDeleted = (): void => {434const { syncdb } = this.course_actions;435for (const record of syncdb.get()) {436if (record?.get("deleted")) {437for (const table in primary_key) {438const key = primary_key[table];439if (record.get(key)) {440syncdb.delete({ [key]: record.get(key) });441break;442}443}444}445}446syncdb.commit();447};448449copyConfiguration = async ({450groups,451targets,452}: {453groups: ConfigurationGroup[];454targets: ConfigurationTarget[];455}) => {456const store = this.course_actions.get_store();457if (groups.length == 0 || targets.length == 0 || store == null) {458return;459}460const settings = store.get("settings");461for (const target of targets) {462const targetActions = await openCourseFileAndGetActions({463...target,464maxTimeMs: 30000,465});466for (const group of groups) {467await configureGroup({468group,469settings,470actions: targetActions.course_actions,471});472}473}474// switch back475const { project_id, path } = this.course_actions.syncdb;476redux.getProjectActions(project_id).open_file({ path, foreground: true });477};478}479480async function openCourseFileAndGetActions({ project_id, path, maxTimeMs }) {481await redux482.getProjectActions(project_id)483.open_file({ path, foreground: true });484const t = Date.now();485let d = 250;486while (Date.now() + d - t <= maxTimeMs) {487await delay(d);488const targetActions = redux.getEditorActions(project_id, path);489if (targetActions?.course_actions?.syncdb.get_state() == "ready") {490return targetActions;491}492d *= 1.1;493}494throw Error(`unable to open '${path}'`);495}496497export const CONFIGURATION_GROUPS = [498"collaborator-policy",499"email-invitation",500"copy-limit",501"restrict-student-projects",502"nbgrader",503"upgrades",504// "network-file-systems",505// "env-variables",506// "software-environment",507] as const;508509export type ConfigurationGroup = (typeof CONFIGURATION_GROUPS)[number];510511async function configureGroup({512group,513settings,514actions,515}: {516group: ConfigurationGroup;517settings: CourseSettingsRecord;518actions: CourseActions;519}) {520switch (group) {521case "collaborator-policy":522const allow_collabs = !!settings.get("allow_collabs");523actions.configuration.set_allow_collabs(allow_collabs);524return;525case "email-invitation":526actions.configuration.set_email_invite(settings.get("email_invite"));527return;528case "copy-limit":529actions.configuration.set_copy_parallel(settings.get("copy_parallel"));530return;531case "restrict-student-projects":532actions.configuration.set_student_project_functionality(533completeStudentProjectFunctionality(534settings.get("student_project_functionality")?.toJS() ?? {},535),536);537return;538case "nbgrader":539await actions.configuration.set_nbgrader_grade_project(540settings.get("nbgrader_grade_project"),541);542await actions.configuration.set_nbgrader_cell_timeout_ms(543settings.get("nbgrader_cell_timeout_ms"),544);545await actions.configuration.set_nbgrader_timeout_ms(546settings.get("nbgrader_timeout_ms"),547);548await actions.configuration.set_nbgrader_max_output(549settings.get("nbgrader_max_output"),550);551await actions.configuration.set_nbgrader_max_output_per_cell(552settings.get("nbgrader_max_output_per_cell"),553);554await actions.configuration.set_nbgrader_include_hidden_tests(555!!settings.get("nbgrader_include_hidden_tests"),556);557return;558559case "upgrades":560if (settings.get("student_pay")) {561actions.configuration.set_pay_choice("student", true);562await actions.configuration.setStudentPay({563when: settings.get("pay"),564info: settings.get("payInfo")?.toJS(),565cost: settings.get("payCost"),566});567await actions.configuration.configure_all_projects();568} else {569actions.configuration.set_pay_choice("student", false);570}571if (settings.get("institute_pay")) {572actions.configuration.set_pay_choice("institute", true);573const strategy = settings.get("set_site_license_strategy");574if (strategy != null) {575actions.configuration.set_site_license_strategy(strategy);576}577const site_license_id = settings.get("site_license_id");578actions.configuration.set({ site_license_id, table: "settings" });579} else {580actions.configuration.set_pay_choice("institute", false);581}582return;583584// case "network-file-systems":585// case "env-variables":586// case "software-environment":587default:588throw Error(`configuring group ${group} not implemented`);589}590}591592593