libdir.'/externallib.php'); class studyplan { const TABLE = "local_treestudyplan"; private static $STUDYPLAN_CACHE = []; private $r; // Holds database record private $id; private $aggregator; private $context = null; // Hold context object once retrieved private $linked_userids = null; // cache lookup of linked users (saves queries) public function getAggregator(){ return $this->aggregator; } // Cache constructors to avoid multiple creation events in one session. public static function findById($id): self { if(!array_key_exists($id,self::$STUDYPLAN_CACHE)){ self::$STUDYPLAN_CACHE[$id] = new self($id); } return self::$STUDYPLAN_CACHE[$id]; } private function __construct($id) { global $DB; $this->id = $id; $this->r = $DB->get_record(self::TABLE,['id' => $id]); $this->aggregator = aggregator::createOrDefault($this->r->aggregation, $this->r->aggregation_config); } public function id(){ return $this->id; } public function shortname(){ return $this->r->shortname; } public function slots(){ return $this->r->slots; } public function name(){ return $this->r->name; } public function startdate(){ return new \DateTime($this->r->startdate); } /** * Return the context this studyplan is associated to */ public function context(): \context{ if(!isset($this->context)){ try{ $this->context = contextinfo::by_id($this->r->context_id)->context; } catch(\dml_missing_record_exception $x){ throw new \InvalidArgumentException("Context {$this->r->context_id} not available"); // Just throw it up again. catch is included here to make sure we know it throws this exception } } return $this->context; } public function enddate(){ if($this->r->enddate && strlen($this->r->enddate) > 0){ return new \DateTime($this->r->enddate); } else{ // return a date 100 years into the future return (new \DateTime($this->r->startdate))->add(new \DateInterval("P100Y")); } } public static function simple_structure($value=VALUE_REQUIRED){ return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of studyplan'), "name" => new \external_value(PARAM_TEXT, 'name of studyplan'), "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan'), "slots" => new \external_value(PARAM_INT, 'number of slots in studyplan'), "context_id" => new \external_value(PARAM_INT, 'context_id of studyplan'), "description"=> new \external_value(PARAM_TEXT, 'description of studyplan'), "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan'), "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan'), "aggregation" => new \external_value(PARAM_TEXT, 'selected aggregator'), "aggregation_config" => new \external_value(PARAM_TEXT, 'config string for aggregator'), "aggregation_info" => aggregator::basic_structure(), ],'Basic studyplan info',$value); } public function simple_model(){ return [ 'id' => $this->r->id, 'name' => $this->r->name, 'shortname' => $this->r->shortname, 'slots' => $this->r->slots, 'context_id' => $this->context()->id, 'description' => $this->r->description, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, "aggregation" => $this->r->aggregation, "aggregation_config" => $this->aggregator->config_string(), 'aggregation_info' => $this->aggregator->basic_model(), ]; } public static function editor_structure($value=VALUE_REQUIRED){ return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of studyplan'), "name" => new \external_value(PARAM_TEXT, 'name of studyplan'), "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan'), "description"=> new \external_value(PARAM_TEXT, 'description of studyplan'), "slots" => new \external_value(PARAM_INT, 'number of slots in studyplan'), "context_id" => new \external_value(PARAM_INT, 'context_id of studyplan'), "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan'), "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan'), "aggregation" => new \external_value(PARAM_TEXT, 'selected aggregator'), "aggregation_config" => new \external_value(PARAM_TEXT, 'config string for aggregator'), "aggregation_info" => aggregator::basic_structure(), /*"association" => new \external_single_structure([ 'cohorts' => new \external_multiple_structure(associationservice::cohort_structure()), 'users' => new \external_multiple_structure(associationservice::user_structure()), ]),*/ "studylines" => new \external_multiple_structure(studyline::editor_structure()), "advanced" => new \external_single_structure([ "force_scales" => new \external_single_structure([ "scales" => new \external_multiple_structure(new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of scale'), "name" => new \external_value(PARAM_TEXT, 'name of scale'), ])), ],"Scale forcing on stuff", VALUE_OPTIONAL), ],"Advanced features available", VALUE_OPTIONAL), ],'Studyplan full structure',$value); } public function editor_model(){ global $DB; $model = [ 'id' => $this->r->id, 'name' => $this->r->name, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'slots' => $this->r->slots, 'context_id' => $this->context()->id, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, "aggregation" => $this->r->aggregation, "aggregation_config" => $this->aggregator->config_string(), 'aggregation_info' => $this->aggregator->basic_model(), /*'association' => [ 'cohorts' => associationservice::associated_cohorts($this->r->id), 'users' => associationservice::associated_users($this->r->id), ],*/ 'studylines' => [], ]; $children = studyline::find_studyplan_children($this); foreach($children as $c) { $model['studylines'][] = $c->editor_model(); } if(has_capability('local/treestudyplan:forcescales', \context_system::instance())){ if(!array_key_exists('advanced',$model)){ // Create advanced node if it does not exist $model['advanced'] = []; } // get a list of available scales $scales = array_map( function($scale){ return [ "id" => $scale->id, "name" => $scale->name,]; }, \grade_scale::fetch_all(array('courseid'=>0)) ) ; $model['advanced']['force_scales'] = [ 'scales' => $scales, ]; } return $model; } public static function add($fields){ global $CFG, $DB; $addable = ['name','shortname','description','context_id','slots','startdate','enddate','aggregation','aggregation_config']; $info = ['enddate' => null ]; foreach($addable as $f){ if(array_key_exists($f,$fields)){ $info[$f] = $fields[$f]; } } $id = $DB->insert_record(self::TABLE, $info); return self::findById($id); // make sure the new studyplan is immediately cached } public function edit($fields){ global $DB; $editable = ['name','shortname','description','context_id','slots','startdate','enddate','aggregation','aggregation_config']; $info = ['id' => $this->id,]; foreach($editable as $f){ if(array_key_exists($f,$fields)){ $info[$f] = $fields[$f]; } } $DB->update_record(self::TABLE, $info); //reload record after edit $this->r = $DB->get_record(self::TABLE,['id' => $this->id],"*",MUST_EXIST); //reload the context... $this->context = null; $this->context(); // reload aggregator $this->aggregator = aggregator::createOrDefault($this->r->aggregation, $this->r->aggregation_config); return $this; } public function delete($force=false){ global $DB; if($force){ $children = studyline::find_studyplan_children($this); foreach($children as $c){ $c->delete($force); } } if($DB->count_records('local_treestudyplan_line',['studyplan_id' => $this->id]) > 0){ return success::fail('cannot delete studyplan that still has studylines'); } else { $DB->delete_records('local_treestudyplan', ['id' => $this->id]); return success::success(); } } public static function find_all($contextid=-1){ global $DB, $USER; $list = []; if($contextid <= 0){ $ids = $DB->get_fieldset_select(self::TABLE,"id",""); } else{ if($contextid == 1){ $contextid = 1; $where = "context_id <= :contextid OR context_id IS NULL"; } else { $where = "context_id = :contextid"; } $ids = $DB->get_fieldset_select(self::TABLE,"id",$where,["contextid" => $contextid]); } foreach($ids as $id) { $list[] = studyplan::findById($id); } return $list; } public static function find_by_shortname($shortname, $contextid = 0): array{ global $DB; $list = []; $where = "shortname = :shortname AND context_id = :contextid"; if($contextid == 0){ $where .= "OR context_id IS NULL"; } $ids = $DB->get_fieldset_select(self::TABLE,"id",$where,["shortname"=>$shortname, "contextid" => $contextid]); foreach($ids as $id) { $list[] = studyplan::findById($id); } return $list; } public static function find_for_user($userid) { global $DB; $sql = "SELECT s.id FROM {local_treestudyplan} s INNER JOIN {local_treestudyplan_cohort} j ON j.studyplan_id = s.id INNER JOIN {cohort_members} cm ON j.cohort_id = cm.cohortid WHERE cm.userid = :userid"; $cohort_plan_ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]); $sql = "SELECT s.id FROM {local_treestudyplan} s INNER JOIN {local_treestudyplan_user} j ON j.studyplan_id = s.id WHERE j.user_id = :userid"; $user_plan_ids = $DB->get_fieldset_sql($sql, ['userid' => $userid]); $plans = []; foreach($cohort_plan_ids as $id) { $plans[$id] = self::findById($id); } foreach($user_plan_ids as $id) { if(!array_key_exists($id,$plans)){ $plans[$id] = self::findById($id); } } return $plans; } /** * Find The active studyplans where the specified user is a teacher * (Has the mod/assign::grade capability in one of the linked courses) * TODO: OPTIMIZE THIS CHECK!!! */ public static function find_teaching($userid){ global $DB; $list = []; // First find all active study plans $sql = "SELECT s.id FROM {local_treestudyplan} s WHERE startdate <= NOW() and enddate >= NOW()"; $plan_ids = $DB->get_fieldset_sql($sql, []); foreach($plan_ids as $planid) { $sql = "SELECT i.course_id FROM mdl_local_treestudyplan_item i INNER JOIN mdl_local_treestudyplan_line l ON i.line_id = l.id WHERE l.studyplan_id = :plan_id AND i.course_id IS NOT NULL"; $course_ids = $DB->get_fieldset_sql($sql, ["plan_id" => $planid]); $linked = false; foreach($course_ids as $cid){ $coursecontext = \context_course::instance($cid); if (is_enrolled($coursecontext, $userid, 'mod/assign:grade')){ $linked = true; break; // No need to search further } } if($linked) { $list[$planid] = self::findById($planid); } } return $list; } static public function exist_for_user($userid) { global $DB; $count = 0; $sql = "SELECT s.* FROM {local_treestudyplan} s INNER JOIN {local_treestudyplan_cohort} j ON j.studyplan_id = s.id INNER JOIN {cohort_members} cm ON j.cohort_id = cm.cohortid WHERE cm.userid = :userid"; $count += $DB->count_records_sql($sql, ['userid' => $userid]); $sql = "SELECT s.* FROM {local_treestudyplan} s INNER JOIN {local_treestudyplan_user} j ON j.studyplan_id = s.id WHERE j.user_id = :userid"; $count += $DB->count_records_sql($sql, ['userid' => $userid]); return ($count > 0); } /** * Retrieve the users linked to this studyplan. * @return array of User objects */ public function find_linked_users(){ global $DB; $users = []; $uids = $this->find_linked_userids(); foreach($uids as $uid){ $users[] = $DB->get_record("user",["id"=>$uid]); } return $users; } /** * Retrieve the user id's of the users linked to this studyplan. * @return array of int (User Id) */ public function find_linked_userids(): array { global $DB; if($this->linked_userids === null){ $uids = []; // First get directly linked userids $sql = "SELECT j.user_id FROM {local_treestudyplan_user} j WHERE j.studyplan_id = :planid"; $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); $uids = array_merge($uids,$ulist); foreach($ulist as $uid){ $users[] = $DB->get_record("user",["id"=>$uid]); } // Next het users linked though cohort $sql = "SELECT cm.userid FROM {local_treestudyplan_cohort} j INNER JOIN {cohort_members} cm ON j.cohort_id = cm.cohortid WHERE j.studyplan_id = :planid"; $ulist = $DB->get_fieldset_sql($sql, ['planid' => $this->id]); $uids = array_merge($uids,$ulist); $this->linked_userids = array_unique($uids); } return $this->linked_userids; } /** Check if this studyplan is linked to a particular user * @param bool|stdClass $user The userid or user record of the user */ public function has_linked_user($user){ if(is_int($user)){ $userid = $user; } else { $userid = $user->id; } $uids = $this->find_linked_userids(); if(in_array($userid,$uids)){ return true; } else { return false; } } public static function user_structure($value=VALUE_REQUIRED){ return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of studyplan'), "name" => new \external_value(PARAM_TEXT, 'name of studyplan'), "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan'), "description"=> new \external_value(PARAM_TEXT, 'description of studyplan'), "slots" => new \external_value(PARAM_INT, 'number of slots in studyplan'), "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan'), "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan'), "studylines" => new \external_multiple_structure(studyline::user_structure()), "aggregation_info" => aggregator::basic_structure(), ],'Studyplan with user info',$value); } public function user_model($userid){ $model = [ 'id' => $this->r->id, 'name' => $this->r->name, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'slots' => $this->r->slots, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'studylines' => [], 'aggregation_info' => $this->aggregator->basic_model(), ]; $children = studyline::find_studyplan_children($this); foreach($children as $c) { $model['studylines'][] = $c->user_model($userid); } return $model; } public static function duplicate_plan($plan_id,$name,$shortname) { $ori = self::findById($plan_id); $new = $ori->duplicate($name,$shortname); return $new->simple_model(); } public function duplicate($name,$shortname) { // First duplicate the studyplan structure $new =studyplan::add([ 'name' => $name, 'shortname' => $shortname, 'description' => $this->r->description, 'slots' => $this->r->slots, 'startdate' => $this->r->startdate, 'enddate' => empty($this->r->enddate)?null:$this->r->enddate, ]); // next, copy the studylines $children = studyline::find_studyplan_children($this); $itemtranslation = []; $linetranslation = []; foreach($children as $c){ $newchild = $c->duplicate($this,$itemtranslation); $linetranslation[$c->id()] = $newchild->id(); } // now the itemtranslation array contains all of the old child id's as keys and all of the related new ids as values // (feature of the studyline::duplicate function) // use this to recreate the lines in the new plan foreach(array_keys($itemtranslation) as $item_id){ // copy based on the outgoing connections of each item, to avoid duplicates $connections = studyitemconnection::find_outgoing($item_id); foreach($connections as $conn){ studyitemconnection::connect($itemtranslation[$conn->from_id],$itemtranslation[$conn->to_id]); } } return $new; } public static function export_structure() { return new \external_single_structure([ "format" => new \external_value(PARAM_TEXT, 'format of studyplan export'), "content"=> new \external_value(PARAM_TEXT, 'exported studyplan content'), ],'Exported studyplan'); } public function export_plan() { $model = $this->export_model(); $json = json_encode([ "type"=>"studyplan", "version"=>1.0, "studyplan"=>$model ],\JSON_PRETTY_PRINT); return [ "format" => "application/json", "content" => $json]; } public function export_plan_csv() { $model = $this->editor_model(); $slots = intval($model["slots"]); // First line $csv = "\"Studyline[{$slots}]\""; for($i = 1; $i <= $slots; $i++){ $csv .= ",\"P{$i}\""; } $csv .= "\r\n"; // next, make one line per studyline foreach($model["studylines"] as $line){ // determine how many fields are simultaneous in the line at maximum $maxlines = 1; for($i = 1; $i <= $slots; $i++){ if(count($line["slots"]) > $i){ $ct = 0; foreach($line["slots"][$i][studyline::SLOTSET_COMPETENCY] as $itm){ if($itm["type"] == "course"){ $ct += 1; } } if($ct > $maxlines){ $maxlines = $ct; } } } for($lct = 0; $lct < $maxlines; $lct++){ $csv .= "\"{$line["name"]}\""; for($i = 1; $i <= $slots; $i++){ $filled = false; if(count($line["slots"]) > $i){ $ct = 0; foreach($line["slots"][$i][studyline::SLOTSET_COMPETENCY] as $itm){ if($itm["type"] == "course"){ if($ct == $lct){ $csv .= ",\""; $csv .= $itm["course"]["fullname"]; $csv .= "\r\n"; $first = true; foreach($itm["course"]["grades"] as $g){ if($g["selected"]){ if($first){ $first = false; } else{ $csv .= "\r\n"; } $csv .= "- ".str_replace('"', '\'', $g["name"]); } } $csv .= "\""; $filled = true; break; } $ct++; } } } if(!$filled) { $csv .= ",\"\""; } } $csv .= "\r\n"; } } return [ "format" => "text/csv", "content" => $csv]; } public function export_studylines(){ $model = $this->export_studylines_model(); $json = json_encode([ "type"=>"studylines", "version"=>1.0, "studylines"=>$model, ],\JSON_PRETTY_PRINT); return [ "format" => "application/json", "content" => $json]; } public function export_model() { $model = [ 'name' => $this->r->name, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'slots' => $this->r->slots, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, "aggregation" => $this->r->aggregation, "aggregation_config" => json_decode($this->aggregator->config_string()), 'aggregation_info' => $this->aggregator->basic_model(), 'studylines' => $this->export_studylines_model(), ]; return $model; } public function export_studylines_model() { $children = studyline::find_studyplan_children($this); $lines = []; foreach($children as $c) { $lines[] = $c->export_model(); } return $lines; } public static function import_studyplan($content,$format="application/json",$context_id=1) { if($format != "application/json") { return false;} $content = json_decode($content,true); if($content["type"] == "studyplan" && $content["version"] >= 1.0){ // Make sure the aggregation_config is re-encoded as json text $content["studyplan"]["aggregation_config"] = json_encode($content["studyplan"]["aggregation_config"]); // And make sure the context_id is set to the $content["studyplan"]["context_id"] = $context_id; $plan = self::add($content["studyplan"]); return $plan->import_studylines_model($content["studyplan"]["studylines"]); } else { error_log("Invalid format and type: {$content['type']} version {$content['version']}"); return false; } } public function import_studylines($content,$format="application/json") { if($format != "application/json") { return false;} $content = json_decode($content,true); if($content["type"] == "studylines" && $content["version"] >= 1.0){ return $this->import_studylines_model($content["studylines"]); } else if($content["type"] == "studyplan" && $content["version"] >= 1.0){ return $this->import_studylines_model($content["studyplan"]["studylines"]); } else { return false; } } protected function find_studyline_by_shortname($shortname){ $children = studyline::find_studyplan_children($this); foreach($children as $l){ if($shortname == $l->shortname()){ return $l; } } return null; } protected function import_studylines_model($model) { // First attempt to map each studyline model to an existing or new line $line_map = []; foreach($model as $ix => $linemodel){ $line = $this->find_studyline_by_shortname($linemodel["shortname"]); if(empty($line)){ $linemodel["studyplan_id"] = $this->id; $line = studyline::add($linemodel); } else { //$line->edit($linemodel); // Update the line with the settings from the imported file } $line_map[$ix] = $line; } // next, let each study line import the study items $itemtranslation = []; $connections = []; foreach($model as $ix => $linemodel){ $line_map[$ix]->import_studyitems($linemodel["slots"],$itemtranslation,$connections); } // Finally, create the links between the study items foreach($connections as $from => $dests){ foreach($dests as $to){ studyitemconnection::connect($from,$itemtranslation[$to]); } } return true; } /** * Mark the studyplan as changed regarding courses and associated cohorts */ public function mark_csync_changed(){ global $DB; $DB->update_record(self::TABLE, ['id' => $this->id,"csync_flag" => 1]); $this->r->csync_flag = 1; //manually set it in the cache, if something unexpected happened, an exception has already been thrown anyway } /** * Clear the csync mark */ public function clear_csync_changed(){ global $DB; $DB->update_record(self::TABLE, ['id' => $this->id,"csync_flag" => 0]); $this->r->csync_flag = 0; //manually set it in the cache, if something unexpected happened, an exception has already been thrown anyway } public function has_csync_changed(){ return ($this->r->csync_flag > 0)?true:false; } /** * See if the specified course id is linked in this studyplan */ public function course_linked($courseid){ global $DB; $sql = "SELECT COUNT(i.id) FROM {local_treestudyplan} INNER JOIN {local_treestudyplan_line} l ON p.id = l.studyplan_id INNER JOIN {local_treestudyplan_item} i ON l.id = i.line_id WHERE p.id = :planid AND i.course_id = :courseid"; $count = $DB->get_field_sql($sql,["courseid" => $courseid, "planid" => $this->id]); return ($count > 0)?true:false; } /** * List the course id is linked in this studyplan * Used for cohort enrolment cascading */ public function get_linked_course_ids(){ global $DB; $sql = "SELECT i.course_id FROM {local_treestudyplan} p INNER JOIN {local_treestudyplan_line} l ON p.id = l.studyplan_id INNER JOIN {local_treestudyplan_item} i ON l.id = i.line_id WHERE p.id = :studyplan_id AND i.type = :itemtype"; $fields = $DB->get_fieldset_sql($sql,["studyplan_id" => $this->id,"itemtype" => studyitem::COURSE]); return $fields; } /** * List the cohort id's associated with this studyplan */ public function get_linked_cohort_ids(){ global $CFG, $DB; $sql = "SELECT DISTINCT j.cohort_id FROM {local_treestudyplan_cohort} j WHERE j.studyplan_id = :studyplan_id"; $fields = $DB->get_fieldset_sql($sql, ['studyplan_id' => $this->id]); return $fields; } /** * List the user id's explicitly associated with this studyplan */ public function get_linked_user_ids(){ global $CFG, $DB; $sql = "SELECT DISTINCT j.user_id FROM {local_treestudyplan_user} j WHERE j.studyplan_id = :studyplan_id"; $fields = $DB->get_fieldset_sql($sql, ['studyplan_id' => $this->id]); return $fields; } /** * See if the specified course id is linked in this studyplan */ public function badge_linked($badgeid){ global $DB; $sql = "SELECT COUNT(i.id) FROM {local_treestudyplan} INNER JOIN {local_treestudyplan_line} l ON p.id = l.studyplan_id INNER JOIN {local_treestudyplan_item} i ON l.id = i.line_id WHERE p.id = :planid AND i.badge_id = :badgeid"; $count = $DB->get_field_sql($sql,["badgeid" => $badgeid, "planid" => $this->id]); return ($count > 0)?true:false; } }