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/student-projects/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 specific to manipulating the student projects that students have in a course.7*/89import { delay, map as awaitMap } from "awaiting";10import { sortBy } from "lodash";11import { redux } from "@cocalc/frontend/app-framework";12import { markdown_to_html } from "@cocalc/frontend/markdown";13import { Datastore, EnvVars } from "@cocalc/frontend/projects/actions";14import { webapp_client } from "@cocalc/frontend/webapp-client";15import { RESEND_INVITE_INTERVAL_DAYS } from "@cocalc/util/consts/invites";16import { copy, days_ago, keys, len } from "@cocalc/util/misc";17import { SITE_NAME } from "@cocalc/util/theme";18import { CourseActions } from "../actions";19import { CourseStore } from "../store";20import { UpgradeGoal } from "../types";21import { Result, run_in_all_projects } from "./run-in-all-projects";22import type { StudentRecord } from "../store";2324// for tasks that are "easy" to run in parallel, e.g. starting projects25export const MAX_PARALLEL_TASKS = 30;2627export const RESEND_INVITE_BEFORE = days_ago(RESEND_INVITE_INTERVAL_DAYS);28export class StudentProjectsActions {29private course_actions: CourseActions;3031constructor(course_actions: CourseActions) {32this.course_actions = course_actions;33}3435private get_store = (): CourseStore => {36const store = this.course_actions.get_store();37if (store == null) throw Error("no store");38return store;39};4041// Create and configure a single student project.42create_student_project = async (43student_id: string,44): Promise<string | undefined> => {45const { store, student } = this.course_actions.resolve({46student_id,47finish: this.course_actions.set_error.bind(this),48});49if (store == null || student == null) return;50if (store.get("students") == null || store.get("settings") == null) {51this.course_actions.set_error(52"BUG: attempt to create when stores not yet initialized",53);54return;55}56if (student.get("project_id")) {57// project already created.58return student.get("project_id");59}60this.course_actions.set({61create_project: webapp_client.server_time(),62table: "students",63student_id,64});65const id = this.course_actions.set_activity({66desc: `Create project for ${store.get_student_name(student_id)}.`,67});68const dflt_img = await redux.getStore("customize").getDefaultComputeImage();69let project_id: string;70try {71project_id = await redux.getActions("projects").create_project({72title: store.get("settings").get("title"),73description: store.get("settings").get("description"),74image: store.get("settings").get("custom_image") ?? dflt_img,75noPool: true, // student is unlikely to use the project right *now*76});77} catch (err) {78this.course_actions.set_error(79`error creating student project for ${store.get_student_name(80student_id,81)} -- ${err}`,82);83return;84} finally {85this.course_actions.clear_activity(id);86}87this.course_actions.set({88create_project: null,89project_id,90table: "students",91student_id,92});93await this.configure_project({94student_id,95student_project_id: project_id,96});97return project_id;98};99100// if student is an email address, invite via email – otherwise, if account_id, invite via standard collaborator invite101invite_student_to_project = async (props: {102student_id: string;103student: string; // could be account_id or email_address104student_project_id?: string;105}) => {106const { student_id, student, student_project_id } = props;107if (student_project_id == null) return;108109// console.log("invite", x, " to ", student_project_id);110if (student.includes("@")) {111const store = this.get_store();112if (store == null) return;113const account_store = redux.getStore("account");114const name = account_store.get_fullname();115const replyto = account_store.get_email_address();116const title = store.get("settings").get("title");117const site_name =118redux.getStore("customize").get("site_name") ?? SITE_NAME;119const subject = `${site_name} Invitation to Course ${title}`;120let body = store.get_email_invite();121body = body.replace(/{title}/g, title).replace(/{name}/g, name);122body = markdown_to_html(body);123await redux124.getActions("projects")125.invite_collaborators_by_email(126student_project_id,127student,128body,129subject,130true,131replyto,132name,133);134this.course_actions.set({135table: "students",136student_id,137last_email_invite: webapp_client.server_time(),138});139} else {140await redux141.getActions("projects")142.invite_collaborator(student_project_id, student);143}144};145146private configure_project_users = async (props: {147student_project_id: string;148student_id: string;149force_send_invite_by_email?: boolean;150}): Promise<void> => {151const {152student_project_id,153student_id,154force_send_invite_by_email = false,155} = props;156//console.log("configure_project_users", student_project_id, student_id)157// Add student and all collaborators on this project to the project with given project_id.158// users = who is currently a user of the student's project?159const users = redux.getStore("projects").get_users(student_project_id); // immutable.js map160if (users == null) return; // can't do anything if this isn't known...161162const s = this.get_store();163if (s == null) return;164const student = s.get_student(student_id);165if (student == null) return; // no such student..166167// Make sure the student is on the student's project:168const student_account_id = student.get("account_id");169if (student_account_id == null) {170// No known account yet, so invite by email.171// This is done once and then on demand by the teacher – only limited to once per day or less172const last_email_invite = student.get("last_email_invite");173if (force_send_invite_by_email || !last_email_invite) {174const email_address = student.get("email_address");175if (email_address) {176await this.invite_student_to_project({177student_id,178student: email_address,179student_project_id,180});181this.course_actions.set({182table: "students",183student_id,184last_email_invite: webapp_client.server_time(),185});186}187}188} else if (189(users != null ? users.get(student_account_id) : undefined) == null190) {191// users might not be set yet if project *just* created192await this.invite_student_to_project({193student_id,194student: student_account_id,195student_project_id,196});197}198199// Make sure all collaborators on course project are on the student's project:200const course_collaborators = redux201.getStore("projects")202.get_users(s.get("course_project_id"));203if (course_collaborators == null) {204// console.log("projects store isn't sufficiently initialized yet...");205return;206}207for (const account_id of course_collaborators.keys()) {208if (!users.has(account_id)) {209await redux210.getActions("projects")211.invite_collaborator(student_project_id, account_id);212}213}214215// Regarding student_account_id !== undefined below, see https://github.com/sagemathinc/cocalc/pull/3259216// The problem is that student_account_id might not yet be known to the .course, even though217// the student has been added and the account_id exists, and is known to the account opening218// the .course file. This is just due to a race condition somewhere else. For now -- before219// just factoring out and rewriting all this code better -- we at least make this one change220// so the student isn't "brutally" kicked out of the course.221if (222s.get("settings") != undefined &&223!s.get_allow_collabs() &&224student_account_id != undefined225) {226// Remove anybody extra on the student project227for (const account_id of users.keys()) {228if (229!course_collaborators.has(account_id) &&230account_id !== student_account_id231) {232await redux233.getActions("projects")234.remove_collaborator(student_project_id, account_id);235}236}237}238};239240// Sets the licenses for the given project to the given licenses241// from our course configuration. Any licenses already on the242// project that are not set at all in our course configure license243// list stay unchanged. This way a student can buy their own extra244// license and apply it and it stays even when the instructor makes245// changes to licenses.246private set_project_site_license = async (247project_id: string,248license_ids: string[],249): Promise<void> => {250const project_map = redux.getStore("projects").get("project_map");251if (project_map == null || project_map.get(project_id) == null) {252// do nothing if we're not a collab on the project or info about253// it isn't loaded -- this should have been ensured earlier on.254return;255}256const store = this.get_store();257if (store == null) return;258const currentLicenses: string[] = keys(259(project_map.getIn([project_id, "site_license"]) as any)?.toJS() ?? {},260);261const courseLicenses = new Set(262((store.getIn(["settings", "site_license_id"]) as any) ?? "").split(","),263);264const removedLicenses = new Set(265((store.getIn(["settings", "site_license_removed"]) as any) ?? "").split(266",",267),268);269const toApply = [...license_ids];270for (const id of currentLicenses) {271if (!courseLicenses.has(id) && !removedLicenses.has(id)) {272toApply.push(id);273}274}275const actions = redux.getActions("projects");276await actions.set_site_license(project_id, toApply.join(","));277};278279private configure_project_license = async (280student_project_id: string,281license_id?: string, // if not set, all known licenses282): Promise<void> => {283if (license_id != null) {284await this.set_project_site_license(285student_project_id,286license_id.split(","),287);288return;289}290const store = this.get_store();291if (store == null) return;292// Set all license keys we have that are known and not293// expired. (option = false so cached)294const licenses = await store.getLicenses(false);295const license_ids: string[] = [];296for (const license_id in licenses) {297if (!licenses[license_id].expired) {298license_ids.push(license_id);299}300}301await this.set_project_site_license(student_project_id, license_ids);302};303304private remove_project_license = async (305student_project_id: string,306): Promise<void> => {307const actions = redux.getActions("projects");308await actions.set_site_license(student_project_id, "");309};310311remove_all_project_licenses = async (): Promise<void> => {312const id = this.course_actions.set_activity({313desc: "Removing all student project licenses...",314});315try {316const store = this.get_store();317if (store == null) return;318for (const student of store.get_students().valueSeq().toArray()) {319const student_project_id = student.get("project_id");320if (student_project_id == null) continue;321await this.remove_project_license(student_project_id);322}323} finally {324this.course_actions.set_activity({ id });325}326};327328private configure_project_visibility = async (329student_project_id: string,330): Promise<void> => {331const users_of_student_project = redux332.getStore("projects")333.get_users(student_project_id);334if (users_of_student_project == null) {335// e.g., not defined in admin view mode336return;337}338// Make project not visible to any collaborator on the course project.339const store = this.get_store();340if (store == null) return;341const users = redux342.getStore("projects")343.get_users(store.get("course_project_id"));344if (users == null) {345// TODO: should really wait until users is defined, which is a supported thing to do on stores!346return;347}348for (const account_id of users.keys()) {349const x = users_of_student_project.get(account_id);350if (x != null && !x.get("hide")) {351await redux352.getActions("projects")353.set_project_hide(account_id, student_project_id, true);354}355}356};357358private configure_project_title = async (359student_project_id: string,360student_id: string,361): Promise<void> => {362const store = this.get_store();363if (store == null) {364return;365}366const title = `${store.get_student_name(student_id)} - ${store367.get("settings")368.get("title")}`;369await redux370.getActions("projects")371.set_project_title(student_project_id, title);372};373374// start or stop projects of all (non-deleted) students running375action_all_student_projects = async (376action: "start" | "stop",377): Promise<void> => {378if (!["start", "stop"].includes(action)) {379throw new Error(`unknown desired project_action ${action}`);380}381const a2s = { start: "starting", stop: "stopping" } as const;382const state: "starting" | "stopping" = a2s[action];383384this.course_actions.setState({ action_all_projects_state: state });385this.course_actions.shared_project.action_shared_project(action);386387const store = this.get_store();388389const projects_actions = redux.getActions("projects");390if (projects_actions == null) {391throw Error("projects actions must be defined");392}393394const selectedAction = (function () {395switch (action) {396case "start":397return projects_actions.start_project.bind(projects_actions);398case "stop":399return projects_actions.stop_project.bind(projects_actions);400}401})();402403const task = async (student_project_id) => {404if (!student_project_id) return;405// abort if canceled406if (store.get("action_all_projects_state") !== state) return;407// returns true/false, could be useful some day408await selectedAction(student_project_id);409};410411await awaitMap(store.get_student_project_ids(), MAX_PARALLEL_TASKS, task);412};413414cancel_action_all_student_projects = (): void => {415this.course_actions.setState({ action_all_projects_state: "any" });416};417418run_in_all_student_projects = async ({419command,420args,421timeout,422log,423}: {424command: string;425args?: string[];426timeout?: number;427log?: Function;428}): Promise<Result[]> => {429// in case "stop all projects" is running430this.cancel_action_all_student_projects();431432const store = this.get_store();433// calling start also deals with possibility that it's in stop state.434const id = this.course_actions.set_activity({435desc: "Running a command across all student projects…",436});437let id1: number | undefined = this.course_actions.set_activity({438desc: "Starting projects …",439});440let i = 0;441const student_project_ids = store.get_student_project_ids();442const num = student_project_ids.length;443444const clear_id1 = () => {445if (id1 != null) {446this.course_actions.set_activity({ id: id1 });447}448};449450const done = (result: Result) => {451i += 1;452log?.(result);453clear_id1();454id1 = this.course_actions.set_activity({455desc: `Project ${i}/${num} finished…`,456});457};458459try {460return await run_in_all_projects(461// as string[] is right since map option isn't set (make typescript happy)462student_project_ids,463command,464args,465timeout,466done,467);468} finally {469this.course_actions.set_activity({ id });470clear_id1();471}472};473474set_all_student_project_titles = async (title: string): Promise<void> => {475const actions = redux.getActions("projects");476const store = this.get_store();477for (const student of store.get_students().valueSeq().toArray()) {478const student_project_id = student.get("project_id");479const project_title = `${store.get_student_name(480student.get("student_id"),481)} - ${title}`;482if (student_project_id != null) {483await actions.set_project_title(student_project_id, project_title);484if (this.course_actions.is_closed()) return;485}486}487};488489private configure_project_description = async (490student_project_id: string,491): Promise<void> => {492const store = this.get_store();493await redux494.getActions("projects")495.set_project_description(496student_project_id,497store.getIn(["settings", "description"]),498);499};500501set_all_student_project_descriptions = async (502description: string,503): Promise<void> => {504const store = this.get_store();505const actions = redux.getActions("projects");506for (const student of store.get_students().valueSeq().toArray()) {507const student_project_id = student.get("project_id");508if (student_project_id != null) {509await actions.set_project_description(student_project_id, description);510if (this.course_actions.is_closed()) return;511}512}513};514515set_all_student_project_course_info = async (): Promise<void> => {516const store = this.get_store();517if (store == null) return;518let pay = store.get_pay() ?? "";519const payInfo = store.get_payInfo();520521if (pay != "" && !(pay instanceof Date)) {522// pay *must* be a Date, not just a string timestamp... or "" for not paying.523pay = new Date(pay);524}525526const datastore: Datastore = store.get_datastore();527const envvars: EnvVars = store.get_envvars();528const student_project_functionality = store529.getIn(["settings", "student_project_functionality"])530?.toJS();531532const actions = redux.getActions("projects");533const id = this.course_actions.set_activity({534desc: "Updating project course info...",535});536try {537for (const student of store.get_students().valueSeq().toArray()) {538const student_project_id = student.get("project_id");539if (student_project_id == null) continue;540// account_id: might not be known when student first added, or if student541// hasn't joined cocalc yet, so there is no account_id for them.542const student_account_id = student.get("account_id");543const student_email_address = student.get("email_address"); // will be known if account_id isn't known.544await actions.set_project_course_info({545project_id: student_project_id,546course_project_id: store.get("course_project_id"),547path: store.get("course_filename"),548pay,549payInfo,550account_id: student_account_id,551email_address: student_email_address,552datastore,553type: "student",554student_project_functionality,555envvars,556});557}558} finally {559this.course_actions.set_activity({ id });560}561};562563private configure_project = async (props: {564student_id;565student_project_id?: string;566force_send_invite_by_email?: boolean;567license_id?: string; // relevant for serial license strategy only568}): Promise<void> => {569const { student_id, force_send_invite_by_email, license_id } = props;570let student_project_id = props.student_project_id;571572// student_project_id is optional. Will be used instead of from student_id store if provided.573// Configure project for the given student so that it has the right title,574// description, and collaborators for belonging to the indicated student.575// - Add student and collaborators on project containing this course to the new project.576// - Hide project from owner/collabs of the project containing the course.577// - Set the title to [Student name] + [course title] and description to course description.578// console.log("configure_project", student_id);579const store = this.get_store();580if (student_project_id == null) {581student_project_id = store.getIn(["students", student_id, "project_id"]);582}583// console.log("configure_project", student_id, student_project_id);584if (student_project_id == null) {585await this.create_student_project(student_id);586} else {587await Promise.all([588this.configure_project_users({589student_project_id,590student_id,591force_send_invite_by_email,592}),593this.configure_project_visibility(student_project_id),594this.configure_project_title(student_project_id, student_id),595this.configure_project_description(student_project_id),596this.configure_project_compute_image(student_project_id),597this.configure_project_envvars(student_project_id),598this.configure_project_license(student_project_id, license_id),599this.configure_project_envvars(student_project_id),600]);601}602};603604private configure_project_compute_image = async (605student_project_id: string,606): Promise<void> => {607const store = this.get_store();608if (store == null) return;609const dflt_img = await redux.getStore("customize").getDefaultComputeImage();610const img_id = store.get("settings").get("custom_image") ?? dflt_img;611const actions = redux.getProjectActions(student_project_id);612await actions.set_compute_image(img_id);613};614615private configure_project_envvars = async (616student_project_id: string,617): Promise<void> => {618const store = this.get_store();619if (!store?.get_envvars()?.inherit) {620return;621}622const env =623redux624.getStore("projects")625.getIn(["project_map", store.get("course_project_id"), "env"])626?.toJS() ?? {};627const actions = redux.getProjectActions(student_project_id);628await actions.set_environment(env);629};630631private delete_student_project = async (632student_id: string,633): Promise<void> => {634const store = this.get_store();635const student_project_id = store.getIn([636"students",637student_id,638"project_id",639]);640if (student_project_id == null) return;641const student_account_id = store.getIn([642"students",643student_id,644"account_id",645]);646if (student_account_id != undefined) {647redux648.getActions("projects")649.remove_collaborator(student_project_id, student_account_id);650}651await redux.getActions("projects").delete_project(student_project_id);652this.course_actions.set({653create_project: null,654project_id: null,655table: "students",656student_id,657});658};659660reinvite_oustanding_students = async (): Promise<void> => {661const store = this.get_store();662if (store == null) return;663const id = this.course_actions.set_activity({664desc: "Reinviting students...",665});666try {667this.course_actions.setState({ reinviting_students: true });668const ids = store.get_student_ids({ deleted: false });669if (ids == undefined) return;670let i = 0;671672for (const student_id of ids) {673if (this.course_actions.is_closed()) return;674i += 1;675const student = store.get_student(student_id);676if (student == null) continue; // weird677const student_account_id = student.get("account_id");678if (student_account_id != null) continue; // already has an account – no need to reinvite.679680const id1: number = this.course_actions.set_activity({681desc: `Progress ${Math.round((100 * i) / ids.length)}%...`,682});683const last_email_invite = student.get("last_email_invite");684if (685!last_email_invite ||686new Date(last_email_invite) < RESEND_INVITE_BEFORE687) {688const email_address = student.get("email_address");689if (email_address) {690await this.invite_student_to_project({691student_id,692student: email_address,693student_project_id: store.get_student_project_id(student_id),694});695}696}697this.course_actions.set_activity({ id: id1 });698await delay(0); // give UI, etc. a solid chance to render699}700} catch (err) {701this.course_actions.set_error(`Error reinviting students - ${err}`);702} finally {703if (this.course_actions.is_closed()) return;704this.course_actions.setState({ reinviting_students: false });705this.course_actions.set_activity({ id });706}707};708709configure_all_projects = async (force: boolean = false): Promise<void> => {710const store = this.get_store();711if (store == null) {712return;713}714if (store.get("configuring_projects")) {715// currently running already.716return;717}718719const licenses = await store.getLicenses(force);720721// filter all expired licenses – no point in applying them –722// and repeat each license ID as many times as it has seats (run_limit).723// that way, licenses will be applied more often if they have more seats.724// In particular, we are interested in the case, where a course has way more students than license seats.725const allLicenseIDs: string[] = [];726// we want to start with the license with the highest run limit727const sortedLicenseIDs = sortBy(728Object.keys(licenses),729(l) => -licenses[l].runLimit,730);731for (const license_id of sortedLicenseIDs) {732const license = licenses[license_id];733if (license.expired) continue;734for (let i = 0; i < license.runLimit; i++) {735allLicenseIDs.push(license_id);736}737}738739// 2023-03-30: if "serial", then all student projects get exactly one license740// and hence all seats are shared between all student projects.741const isSerial =742store.getIn(["settings", "site_license_strategy"], "serial") == "serial";743744let id: number = -1;745try {746this.course_actions.setState({ configuring_projects: true });747id = this.course_actions.set_activity({748desc: "Ensuring all projects are configured...",749});750const ids = store.get_student_ids({ deleted: false });751if (ids == undefined) {752return;753}754let i = 0;755756// Ensure all projects are loaded, rather than just the most recent757// n projects -- important since courses often have more than n students!758await redux.getActions("projects").load_all_projects();759let project_map = redux.getStore("projects").get("project_map");760if (project_map == null || webapp_client.account_id == null) {761throw Error(762"BUG -- project_map must be initialized and you must be signed in; try again later.",763);764}765766// Make sure we're a collaborator on every student project.767let changed = false;768for (const student_id of ids) {769if (this.course_actions.is_closed()) return;770const project_id = store.getIn(["students", student_id, "project_id"]);771if (project_id && !project_map.get(project_id)) {772await webapp_client.project_collaborators.add_collaborator({773account_id: webapp_client.account_id,774project_id,775});776changed = true;777}778}779780if (changed) {781// wait hopefully long enough for info about licenses to be782// available in the project_map. This is not 100% bullet proof,783// but that is FINE because we only really depend on this to784// slightly reduce doing extra work that is unlikely to be a problem.785await delay(3000);786project_map = redux.getStore("projects").get("project_map");787}788789// we make sure no leftover licenses are used by deleted student projects790const deletedIDs = store.get_student_ids({ deleted: true });791for (const deleted_student_id of deletedIDs) {792i += 1;793const idDel: number = this.course_actions.set_activity({794desc: `Configuring deleted student project ${i} of ${deletedIDs.length}`,795});796await this.configure_project({797student_id: deleted_student_id,798student_project_id: undefined,799force_send_invite_by_email: false,800license_id: "", // no license for a deleted project801});802this.course_actions.set_activity({ id: idDel });803await delay(0); // give UI, etc. a solid chance to render804}805806i = 0;807for (const student_id of ids) {808if (this.course_actions.is_closed()) return;809i += 1;810const id1: number = this.course_actions.set_activity({811desc: `Configuring student project ${i} of ${ids.length}`,812});813814// if isSerial is set, we distribute the licenses in "serial" mode:815// i.e. we allocate one license per student project in a round-robin fashion816// proportional to the number of seats of the license.817const license_id: string | undefined = isSerial818? allLicenseIDs[i % allLicenseIDs.length]819: undefined;820821await this.configure_project({822student_id,823student_project_id: undefined,824force_send_invite_by_email: force,825license_id, // if undefined (i.e. !isSerial), all known licenses will be applied to this student project826});827this.course_actions.set_activity({ id: id1 });828await delay(0); // give UI, etc. a solid chance to render829}830831// always re-invite students on running this.832await this.course_actions.shared_project.configure();833await this.set_all_student_project_course_info();834} catch (err) {835this.course_actions.set_error(836`Error configuring student projects - ${err}`,837);838} finally {839if (this.course_actions.is_closed()) return;840this.course_actions.setState({ configuring_projects: false });841this.course_actions.set_activity({ id });842}843};844845// Deletes student projects and removes students from those projects846deleteAllStudentProjects = async (): Promise<void> => {847const store = this.get_store();848849const id = this.course_actions.set_activity({850desc: "Deleting all student projects...",851});852try {853const ids = store.get_student_ids({ deleted: false });854if (ids == undefined) {855return;856}857for (const student_id of ids) {858await this.delete_student_project(student_id);859}860} catch (err) {861this.course_actions.set_error(862`error deleting a student project... ${err}`,863);864} finally {865this.course_actions.set_activity({ id });866}867};868869// upgrade_goal is a map from the quota type to the goal quota the instructor wishes870// to get all the students to.871upgrade_all_student_projects = async (872upgrade_goal: UpgradeGoal,873): Promise<void> => {874const store = this.get_store();875const plan = store.get_upgrade_plan(upgrade_goal);876if (len(plan) === 0) {877// nothing to do878return;879}880const id = this.course_actions.set_activity({881desc: `Adjusting upgrades on ${len(plan)} student projects...`,882});883const a = redux.getActions("projects");884const s = redux.getStore("projects");885for (const project_id in plan) {886if (project_id == null) continue;887const upgrades = plan[project_id];888if (upgrades == null) continue;889// avoid race if projects are being created *right* when we890// try to upgrade them.891if (!s.has_project(project_id)) continue;892await a.apply_upgrades_to_project(project_id, upgrades, false);893}894this.course_actions.set_activity({ id });895};896897// Do an admin upgrade to all student projects. This changes the base quotas for every student898// project as indicated by the quotas object. E.g., to increase the core quota from 1 to 2, do899// .admin_upgrade_all_student_projects(cores:2)900// The quotas are: cores, cpu_shares, disk_quota, memory, mintime, network, member_host901admin_upgrade_all_student_projects = async (quotas): Promise<void> => {902const account_store = redux.getStore("account");903const groups = account_store.get("groups");904if (groups && groups.includes("admin")) {905throw Error("must be an admin to upgrade");906}907const store = this.get_store();908const ids: string[] = store.get_student_project_ids();909for (const project_id of ids) {910const x = copy(quotas);911x.project_id = project_id;912await webapp_client.project_client.set_quotas(x);913}914};915916removeFromAllStudentProjects = async (student: StudentRecord) => {917/*918- Remove student from their project919- Remove student from shared project920- TODO: Cancel any outstanding invite, in case they haven't even created their account yet.921This isn't even implemented yet as an api endpoint... but will cause confusion.922*/923const shared_id = this.get_store()?.get_shared_project_id();924const account_id = student.get("account_id");925const project_id = student.get("project_id");926if (account_id) {927if (project_id) {928// remove them from their project929await redux930.getActions("projects")931.remove_collaborator(project_id, account_id);932}933934if (shared_id) {935// remove them from shared project936await redux937.getActions("projects")938.remove_collaborator(shared_id, account_id);939}940}941};942}943944945