Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/frontend/course/configuration/actions.ts
Views: 687
/*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*/89import { redux } from "@cocalc/frontend/app-framework";10import {11derive_project_img_name,12SoftwareEnvironmentState,13} from "@cocalc/frontend/custom-software/selector";14import { Datastore, EnvVars } from "@cocalc/frontend/projects/actions";15import { store as projects_store } from "@cocalc/frontend/projects/store";16import { webapp_client } from "@cocalc/frontend/webapp-client";17import { reuseInFlight } from "@cocalc/util/reuse-in-flight";18import { CourseActions, primary_key } from "../actions";19import {20DEFAULT_LICENSE_UPGRADE_HOST_PROJECT,21CourseSettingsRecord,22PARALLEL_DEFAULT,23} from "../store";24import { SiteLicenseStrategy, SyncDBRecord, UpgradeGoal } from "../types";25import {26StudentProjectFunctionality,27completeStudentProjectFunctionality,28} from "./customize-student-project-functionality";29import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";30import { delay } from "awaiting";31import {32NBGRADER_CELL_TIMEOUT_MS,33NBGRADER_MAX_OUTPUT,34NBGRADER_MAX_OUTPUT_PER_CELL,35NBGRADER_TIMEOUT_MS,36} from "../assignments/consts";3738interface ConfigurationTarget {39project_id: string;40path: string;41}4243export class ConfigurationActions {44private course_actions: CourseActions;45private configuring: boolean = false;46private configureAgain: boolean = false;4748constructor(course_actions: CourseActions) {49this.course_actions = course_actions;50this.push_missing_handouts_and_assignments = reuseInFlight(51this.push_missing_handouts_and_assignments.bind(this),52);53}5455set = (obj: SyncDBRecord, commit: boolean = true): void => {56this.course_actions.set(obj, commit);57};5859set_title = (title: string): void => {60this.set({ title, table: "settings" });61this.course_actions.student_projects.set_all_student_project_titles(title);62this.course_actions.shared_project.set_project_title();63};6465set_description = (description: string): void => {66this.set({ description, table: "settings" });67this.course_actions.student_projects.set_all_student_project_descriptions(68description,69);70this.course_actions.shared_project.set_project_description();71};7273// NOTE: site_license_id can be a single id, or multiple id's separate by a comma.74add_site_license_id = (license_id: string): void => {75const store = this.course_actions.get_store();76let site_license_id = store.getIn(["settings", "site_license_id"]) ?? "";77if (site_license_id.indexOf(license_id) != -1) return; // already known78site_license_id += (site_license_id.length > 0 ? "," : "") + license_id;79this.set({ site_license_id, table: "settings" });80};8182remove_site_license_id = (license_id: string): void => {83const store = this.course_actions.get_store();84let cur = store.getIn(["settings", "site_license_id"]) ?? "";85let removed = store.getIn(["settings", "site_license_removed"]) ?? "";86if (cur.indexOf(license_id) == -1) return; // already removed87const v: string[] = [];88for (const id of cur.split(",")) {89if (id != license_id) {90v.push(id);91}92}93const site_license_id = v.join(",");94if (!removed.includes(license_id)) {95removed = removed.split(",").concat([license_id]).join(",");96}97this.set({98site_license_id,99site_license_removed: removed,100table: "settings",101});102};103104set_site_license_strategy = (105site_license_strategy: SiteLicenseStrategy,106): void => {107this.set({ site_license_strategy, table: "settings" });108};109110set_pay_choice = (type: "student" | "institute", value: boolean): void => {111this.set({ [type + "_pay"]: value, table: "settings" });112if (type == "student") {113if (!value) {114this.setStudentPay({ when: "" });115}116}117};118119set_upgrade_goal = (upgrade_goal: UpgradeGoal): void => {120this.set({ upgrade_goal, table: "settings" });121};122123set_allow_collabs = (allow_collabs: boolean): void => {124this.set({ allow_collabs, table: "settings" });125this.course_actions.student_projects.configure_all_projects();126};127128set_student_project_functionality = async (129student_project_functionality: StudentProjectFunctionality,130): Promise<void> => {131this.set({ student_project_functionality, table: "settings" });132await this.course_actions.student_projects.configure_all_projects();133};134135set_email_invite = (body: string): void => {136this.set({ email_invite: body, table: "settings" });137};138139// Set the pay option for the course, and ensure that the course fields are140// set on every student project in the course (see schema.coffee for format141// of the course field) to reflect this change in the database.142setStudentPay = async ({143when,144info,145cost,146}: {147when?: Date | string; // date when they need to pay148info?: PurchaseInfo; // what they must buy for the course149cost?: number;150}) => {151const value = {152...(info != null ? { payInfo: info } : undefined),153...(when != null154? { pay: typeof when != "string" ? when.toISOString() : when }155: undefined),156...(cost != null ? { payCost: cost } : undefined),157};158const store = this.course_actions.get_store();159// wait until store changes with new settings, then configure student projects160store.once("change", async () => {161await this.course_actions.student_projects.set_all_student_project_course_info();162});163await this.set({164table: "settings",165...value,166});167};168169configure_host_project = async (): Promise<void> => {170const id = this.course_actions.set_activity({171desc: "Configuring host project.",172});173try {174// NOTE: we never remove it or any other licenses from the host project,175// since instructor may want to augment license with another license.176const store = this.course_actions.get_store();177// be explicit about copying all course licenses to host project178// https://github.com/sagemathinc/cocalc/issues/5360179const license_upgrade_host_project =180store.getIn(["settings", "license_upgrade_host_project"]) ??181DEFAULT_LICENSE_UPGRADE_HOST_PROJECT;182if (license_upgrade_host_project) {183const site_license_id = store.getIn(["settings", "site_license_id"]);184const actions = redux.getActions("projects");185const course_project_id = store.get("course_project_id");186if (site_license_id) {187await actions.add_site_license_to_project(188course_project_id,189site_license_id,190);191}192}193} catch (err) {194this.course_actions.set_error(`Error configuring host project - ${err}`);195} finally {196this.course_actions.set_activity({ id });197}198};199200configure_all_projects = async (force: boolean = false): Promise<void> => {201if (this.configuring) {202// Important -- if configure_all_projects is called *while* it is running,203// wait until it is done, then call it again (though I'm being lazy about the204// await!). Don't do the actual work more than once205// at the same time since that might confuse the db writes, but206// also don't just reuse in flight, which will miss the later calls.207this.configureAgain = true;208return;209}210try {211this.configureAgain = false;212this.configuring = true;213await this.course_actions.shared_project.configure();214await this.configure_host_project();215await this.course_actions.student_projects.configure_all_projects(force);216await this.configure_nbgrader_grade_project();217} finally {218this.configuring = false;219if (this.configureAgain) {220this.configureAgain = false;221this.configure_all_projects();222}223}224};225226push_missing_handouts_and_assignments = async (): Promise<void> => {227const store = this.course_actions.get_store();228for (const student_id of store.get_student_ids({ deleted: false })) {229await this.course_actions.students.push_missing_handouts_and_assignments(230student_id,231);232}233};234235set_copy_parallel = (copy_parallel: number = PARALLEL_DEFAULT): void => {236this.set({237copy_parallel,238table: "settings",239});240};241242configure_nbgrader_grade_project = async (243project_id?: string,244): Promise<void> => {245let store;246try {247store = this.course_actions.get_store();248} catch (_) {249// this could get called during grading that is ongoing right when250// the user decides to close the document, and in that case get_store()251// would throw an error: https://github.com/sagemathinc/cocalc/issues/7050252return;253}254255if (project_id == null) {256project_id = store.getIn(["settings", "nbgrader_grade_project"]);257}258if (project_id == null || project_id == "") return;259260const id = this.course_actions.set_activity({261desc: "Configuring grading project.",262});263264try {265// make sure the course config for that nbgrader project (mainly for the datastore!) is set266const datastore: Datastore = store.get_datastore();267const envvars: EnvVars = store.get_envvars();268const projects_actions = redux.getActions("projects");269270// if for some reason this is a student project, we don't want to reconfigure it271const course_info: any = projects_store272.get_course_info(project_id)273?.toJS();274if (course_info?.type == null || course_info.type == "nbgrader") {275await projects_actions.set_project_course_info({276project_id,277course_project_id: store.get("course_project_id"),278path: store.get("course_filename"),279pay: "", // pay280payInfo: null,281account_id: null,282email_address: null,283datastore,284type: "nbgrader",285envvars,286});287}288289// we also make sure all teachers have access to that project – otherwise NBGrader can't work, etc.290// this has to happen *after* setting the course field, extended access control, ...291const ps = redux.getStore("projects");292const teachers = ps.get_users(store.get("course_project_id"));293const users_of_grade_project = ps.get_users(project_id);294if (users_of_grade_project != null && teachers != null) {295for (const account_id of teachers.keys()) {296const user = users_of_grade_project.get(account_id);297if (user != null) continue;298await webapp_client.project_collaborators.add_collaborator({299account_id,300project_id,301});302}303}304} catch (err) {305this.course_actions.set_error(306`Error configuring grading project - ${err}`,307);308} finally {309this.course_actions.set_activity({ id });310}311};312313// project_id is a uuid *or* empty string.314set_nbgrader_grade_project = async (315project_id: string = "",316): Promise<void> => {317this.set({318nbgrader_grade_project: project_id,319table: "settings",320});321322// not empty string → configure that grading project323if (project_id) {324await this.configure_nbgrader_grade_project(project_id);325}326};327328set_nbgrader_cell_timeout_ms = (329nbgrader_cell_timeout_ms: number = NBGRADER_CELL_TIMEOUT_MS,330): void => {331this.set({332nbgrader_cell_timeout_ms,333table: "settings",334});335};336337set_nbgrader_timeout_ms = (338nbgrader_timeout_ms: number = NBGRADER_TIMEOUT_MS,339): void => {340this.set({341nbgrader_timeout_ms,342table: "settings",343});344};345346set_nbgrader_max_output = (347nbgrader_max_output: number = NBGRADER_MAX_OUTPUT,348): void => {349this.set({350nbgrader_max_output,351table: "settings",352});353};354355set_nbgrader_max_output_per_cell = (356nbgrader_max_output_per_cell: number = NBGRADER_MAX_OUTPUT_PER_CELL,357): void => {358this.set({359nbgrader_max_output_per_cell,360table: "settings",361});362};363364set_nbgrader_include_hidden_tests = (value: boolean): void => {365this.set({366nbgrader_include_hidden_tests: value,367table: "settings",368});369};370371set_inherit_compute_image = (image?: string): void => {372this.set({ inherit_compute_image: image != null, table: "settings" });373if (image != null) {374this.set_compute_image(image);375}376};377378set_compute_image = (image: string) => {379this.set({380custom_image: image,381table: "settings",382});383this.course_actions.student_projects.configure_all_projects();384this.course_actions.shared_project.set_project_compute_image();385};386387set_software_environment = async (388state: SoftwareEnvironmentState,389): Promise<void> => {390const image = await derive_project_img_name(state);391this.set_compute_image(image);392};393394set_nbgrader_parallel = (395nbgrader_parallel: number = PARALLEL_DEFAULT,396): void => {397this.set({398nbgrader_parallel,399table: "settings",400});401};402403set_datastore = (datastore: Datastore): void => {404this.set({ datastore, table: "settings" });405setTimeout(() => {406this.configure_all_projects_shared_and_nbgrader();407}, 1);408};409410set_envvars = (inherit: boolean): void => {411this.set({ envvars: { inherit }, table: "settings" });412setTimeout(() => {413this.configure_all_projects_shared_and_nbgrader();414}, 1);415};416417set_license_upgrade_host_project = (upgrade: boolean): void => {418this.set({ license_upgrade_host_project: upgrade, table: "settings" });419setTimeout(() => {420this.configure_host_project();421}, 1);422};423424private configure_all_projects_shared_and_nbgrader = () => {425this.course_actions.student_projects.configure_all_projects();426this.course_actions.shared_project.set_datastore_and_envvars();427// in case there is a separate nbgrader project, we have to set the envvars as well428this.configure_nbgrader_grade_project();429};430431purgeDeleted = (): void => {432const { syncdb } = this.course_actions;433for (const record of syncdb.get()) {434if (record?.get("deleted")) {435for (const table in primary_key) {436const key = primary_key[table];437if (record.get(key)) {438syncdb.delete({ [key]: record.get(key) });439break;440}441}442}443}444syncdb.commit();445};446447copyConfiguration = async ({448groups,449targets,450}: {451groups: ConfigurationGroup[];452targets: ConfigurationTarget[];453}) => {454const store = this.course_actions.get_store();455if (groups.length == 0 || targets.length == 0 || store == null) {456return;457}458const settings = store.get("settings");459for (const target of targets) {460const targetActions = await openCourseFileAndGetActions({461...target,462maxTimeMs: 30000,463});464for (const group of groups) {465await configureGroup({466group,467settings,468actions: targetActions.course_actions,469});470}471}472// switch back473const { project_id, path } = this.course_actions.syncdb;474redux.getProjectActions(project_id).open_file({ path, foreground: true });475};476}477478async function openCourseFileAndGetActions({ project_id, path, maxTimeMs }) {479await redux480.getProjectActions(project_id)481.open_file({ path, foreground: true });482const t = Date.now();483let d = 250;484while (Date.now() + d - t <= maxTimeMs) {485await delay(d);486const targetActions = redux.getEditorActions(project_id, path);487if (targetActions?.course_actions?.syncdb.get_state() == "ready") {488return targetActions;489}490d *= 1.1;491}492throw Error(`unable to open '${path}'`);493}494495export const CONFIGURATION_GROUPS = [496"collaborator-policy",497"email-invitation",498"copy-limit",499"restrict-student-projects",500"nbgrader",501"upgrades",502// "network-file-systems",503// "env-variables",504// "software-environment",505] as const;506507export type ConfigurationGroup = (typeof CONFIGURATION_GROUPS)[number];508509async function configureGroup({510group,511settings,512actions,513}: {514group: ConfigurationGroup;515settings: CourseSettingsRecord;516actions: CourseActions;517}) {518switch (group) {519case "collaborator-policy":520const allow_colabs = !!settings.get("allow_collabs");521actions.configuration.set_allow_collabs(allow_colabs);522return;523case "email-invitation":524actions.configuration.set_email_invite(settings.get("email_invite"));525return;526case "copy-limit":527actions.configuration.set_copy_parallel(settings.get("copy_parallel"));528return;529case "restrict-student-projects":530actions.configuration.set_student_project_functionality(531completeStudentProjectFunctionality(532settings.get("student_project_functionality")?.toJS() ?? {},533),534);535return;536case "nbgrader":537await actions.configuration.set_nbgrader_grade_project(538settings.get("nbgrader_grade_project"),539);540await actions.configuration.set_nbgrader_cell_timeout_ms(541settings.get("nbgrader_cell_timeout_ms"),542);543await actions.configuration.set_nbgrader_timeout_ms(544settings.get("nbgrader_timeout_ms"),545);546await actions.configuration.set_nbgrader_max_output(547settings.get("nbgrader_max_output"),548);549await actions.configuration.set_nbgrader_max_output_per_cell(550settings.get("nbgrader_max_output_per_cell"),551);552await actions.configuration.set_nbgrader_include_hidden_tests(553!!settings.get("nbgrader_include_hidden_tests"),554);555return;556557case "upgrades":558if (settings.get("student_pay")) {559actions.configuration.set_pay_choice("student", true);560await actions.configuration.setStudentPay({561when: settings.get("pay"),562info: settings.get("payInfo")?.toJS(),563cost: settings.get("payCost"),564});565await actions.configuration.configure_all_projects();566} else {567actions.configuration.set_pay_choice("student", false);568}569if (settings.get("institute_pay")) {570actions.configuration.set_pay_choice("institute", true);571const strategy = settings.get("set_site_license_strategy");572if (strategy != null) {573actions.configuration.set_site_license_strategy(strategy);574}575const site_license_id = settings.get("site_license_id");576actions.configuration.set({ site_license_id, table: "settings" });577} else {578actions.configuration.set_pay_choice("institute", false);579}580return;581582// case "network-file-systems":583// case "env-variables":584// case "software-environment":585default:586throw Error(`configuring group ${group} not implemented`);587}588}589590591