From 3687461585834d824944544a08108bc544ead2fd Mon Sep 17 00:00:00 2001 From: PMKuipers Date: Sun, 23 Jul 2023 16:25:08 +0200 Subject: [PATCH] Groundwork for pages and periods in backend --- classes/gradeinfo.php | 4 +- .../local/aggregators/tristate_aggregator.php | 4 +- classes/studyitem.php | 10 +- classes/studyline.php | 27 +- classes/studyplan.php | 264 ++------- classes/studyplanpage.php | 544 ++++++++++++++++++ cli/prime_students.php | 14 +- cli/randomize_grades.php | 108 ++-- db/install.xml | 5 +- db/upgrade.php | 37 +- 10 files changed, 720 insertions(+), 297 deletions(-) create mode 100644 classes/studyplanpage.php diff --git a/classes/gradeinfo.php b/classes/gradeinfo.php index 47d706c..ff47cc3 100644 --- a/classes/gradeinfo.php +++ b/classes/gradeinfo.php @@ -195,7 +195,7 @@ class gradeinfo { "required" => $this->is_required(), ]; // Unfortunately, lazy loading of the completion data is off, since we need the data to show study item completion... - if($studyitem !== null && $this->is_selected() && has_capability('local/treestudyplan:viewuserreports',$studyitem->getStudyline()->getStudyplan()->context()) + if($studyitem !== null && $this->is_selected() && has_capability('local/treestudyplan:viewuserreports',$studyitem->getStudyline()->studyplan()->context()) && $this->gradingscanner->is_available()){ $model['grading'] = $this->gradingscanner->model(); } @@ -246,7 +246,7 @@ class gradeinfo { if(!isset($this->studyitem)){ throw new \UnexpectedValueException("Study item not set (null) for gradeinfo in report mode"); } - $aggregator = $this->studyitem->getStudyline()->getStudyplan()->getAggregator(); + $aggregator = $this->studyitem->studyline()->studyplan()->aggregator(); $completion = $aggregator->grade_completion($this,$userid); $model = [ diff --git a/classes/local/aggregators/tristate_aggregator.php b/classes/local/aggregators/tristate_aggregator.php index 42cfdff..53a92c3 100644 --- a/classes/local/aggregators/tristate_aggregator.php +++ b/classes/local/aggregators/tristate_aggregator.php @@ -72,7 +72,7 @@ class tristate_aggregator extends \local_treestudyplan\aggregator { } public function aggregate_course(courseinfo $courseinfo, studyitem $studyitem, $userid){ - $condition = $studyitem->getConditions(); + $condition = $studyitem->conditions(); if(empty($condition)){ $condition = self::DEFAULT_CONDITION; } @@ -85,7 +85,7 @@ class tristate_aggregator extends \local_treestudyplan\aggregator { } public function aggregate_junction(array $completion, studyitem $studyitem, $userid){ - $completed = self::aggregate_completion($completion,$studyitem->getConditions()); + $completed = self::aggregate_completion($completion,$studyitem->conditions()); // if null result (conditions are unknown/null) - default to ALL return isset($completed)?$completed:(self::aggregate_completion($completion,'ALL')); } diff --git a/classes/studyitem.php b/classes/studyitem.php index 427da3c..8885ba5 100644 --- a/classes/studyitem.php +++ b/classes/studyitem.php @@ -27,11 +27,11 @@ class studyitem { return $this->studyline->context(); } - public function getStudyline(): studyline { + public function studyline(): studyline { return $this->studyline; } - public function getConditions() { + public function conditions() { return $this->r->conditions; } @@ -49,7 +49,7 @@ class studyitem { $this->r = $DB->get_record(self::TABLE,['id' => $id],"*",MUST_EXIST); $this->studyline = studyline::findById($this->r->line_id); - $this->aggregator = $this->getStudyline()->getStudyplan()->getAggregator(); + $this->aggregator = $this->studyline()->studyplan()->aggregator(); } public function id(){ @@ -147,7 +147,7 @@ class studyitem { } else { // Also supply a list of linked users, so the badgeinfo can give stats on // the amount issued, related to this studyplan - $studentids = $this->getStudyline()->getStudyplan()->find_linked_userids(); + $studentids = $this->studyline()->studyplan()->find_linked_userids(); $model['badge'] = $badgeinfo->editor_model($studentids); } } @@ -200,7 +200,7 @@ class studyitem { $item = self::findById($id); if($item->type() == self::COURSE){ // Signal the studyplan that a course has been added so it can be marked for csync cascading - $item->getStudyline()->getStudyplan()->mark_csync_changed(); + $item->studyline()->studyplan()->mark_csync_changed(); } return $item; } diff --git a/classes/studyline.php b/classes/studyline.php index ab415ad..7573278 100644 --- a/classes/studyline.php +++ b/classes/studyline.php @@ -26,13 +26,14 @@ class studyline { private $r; // Holds database record private $id; + private $page; private $studyplan; public function context(): \context { return $this->studyplan->context(); } - public function getStudyplan() : studyplan { + public function studyplan() : studyplan { return $this->studyplan; } @@ -47,7 +48,8 @@ class studyline { global $DB; $this->id = $id; $this->r = $DB->get_record(self::TABLE,['id' => $id]); - $this->studyplan = studyplan::findById($this->r->studyplan_id); + $this->page = studyplanpage::findById($this->r->page_id); + $this->studyplan = $this->page->studyplan(); } public function id(){ @@ -105,7 +107,7 @@ class studyline { // make sure there are enought slots to account for them // Alternatively, we could ensure that on reduction of slots, the items that no longer have a slot will be removed. $max_slot = $DB->get_field_select(studyitem::TABLE,"MAX(slot)","line_id = :lineid",['lineid' => $this->id]); - $num_slots = max($this->studyplan->slots(),$max_slot +1); + $num_slots = max($this->page->periods(),$max_slot +1); // Create the required amount of slots for($i=0; $i < $num_slots+1; $i++){ @@ -150,13 +152,13 @@ class studyline { public static function add($fields){ global $DB; - if(!isset($fields['studyplan_id'])){ - throw new \InvalidArgumentException("parameter 'studyplan_id' missing"); + if(!isset($fields['page_id'])){ + throw new \InvalidArgumentException("parameter 'page_id' missing"); } - $studyplan_id = $fields['studyplan_id']; - $sqmax = $DB->get_field_select(self::TABLE,"MAX(sequence)","studyplan_id = :studyplan_id",['studyplan_id' => $studyplan_id]); - $addable = ['studyplan_id','name','shortname','color']; + $page_id = $fields['page_id']; + $sqmax = $DB->get_field_select(self::TABLE,"MAX(sequence)","page_id = :page_id",['page_id' => $page_id]); + $addable = ['page_id','name','shortname','color']; $info = ['sequence' => $sqmax+1]; foreach($addable as $f){ if(array_key_exists($f,$fields)){ @@ -217,11 +219,12 @@ class studyline { return success::success(); } - public static function find_studyplan_children(studyplan $plan) + public static function find_page_children(studyplanpage $page) { global $DB; $list = []; - $ids = $DB->get_fieldset_select(self::TABLE,"id","studyplan_id = :plan_id ORDER BY sequence",['plan_id' => $plan->id()]); + $ids = $DB->get_fieldset_select(self::TABLE,"id","page_id = :page_id ORDER BY sequence", + ['page_id' => $page->id()]); foreach($ids as $id) { $list[] = self::findById($id); } @@ -263,7 +266,7 @@ class studyline { // make sure there are enought slots to account for them // Alternatively, we could ensure that on reduction of slots, the items that no longer have a slot will be removed. $max_slot = $DB->get_field_select(studyitem::TABLE,"MAX(slot)","line_id = :lineid",['lineid' => $this->id]); - $num_slots = max($this->studyplan->slots(),$max_slot +1); + $num_slots = max($this->page->periods(),$max_slot +1); // Create the required amount of slots for($i=0; $i < $num_slots+1; $i++){ @@ -339,7 +342,7 @@ class studyline { $filterlayer = 0; foreach($slotmodel as $itemmodel) { - if($itemmodel["type"] == "course" || $itemmodel["type"] == "competency"){ + if($itemmodel["type"] == "course"){ $itemmodel["layer"] = $courselayer; $courselayer++; }else { diff --git a/classes/studyplan.php b/classes/studyplan.php index 3f20c41..847bb06 100644 --- a/classes/studyplan.php +++ b/classes/studyplan.php @@ -15,8 +15,9 @@ class studyplan { private $aggregator; private $context = null; // Hold context object once retrieved private $linked_userids = null; // cache lookup of linked users (saves queries) + private $page_cache = null; - public function getAggregator(){ + public function aggregator(){ return $this->aggregator; } @@ -44,10 +45,6 @@ class studyplan { return $this->r->shortname; } - public function slots(){ - return $this->r->slots; - } - public function name(){ return $this->r->name; } @@ -56,6 +53,16 @@ class studyplan { return new \DateTime($this->r->startdate); } + public function pages(){ + // cached version of find_studyplan_children. + // (may be premature optimization, since + // find_studyplan_children also does some caching) + if(empty($this->page_cache)){ + $this->page_cache = studyplanpage::find_studyplan_children($this); + } + return $this->page_cache; + } + /** * Return the context this studyplan is associated to */ @@ -71,27 +78,13 @@ class studyplan { 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(), @@ -103,11 +96,8 @@ class studyplan { '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(), @@ -120,18 +110,11 @@ class 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()), + "pages" => new \external_multiple_structure(studyplanpage::editor_structure()), "advanced" => new \external_single_structure([ "force_scales" => new \external_single_structure([ "scales" => new \external_multiple_structure(new \external_single_structure([ @@ -151,24 +134,16 @@ class studyplan { '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' => [], + 'aggregation_info' => $this->aggregator->basic_model(), + 'pages' => [], ]; - $children = studyline::find_studyplan_children($this); - foreach($children as $c) + foreach($this->pages() as $p) { - $model['studylines'][] = $c->editor_model(); + $model['pages'][] = $p->editor_model(); } if(has_capability('local/treestudyplan:forcescales', \context_system::instance())){ @@ -231,14 +206,14 @@ class studyplan { global $DB; if($force){ - $children = studyline::find_studyplan_children($this); + $children = studyplanpage::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'); + if($DB->count_records('local_treestudyplan_page',['studyplan_id' => $this->id]) > 0){ + return success::fail('cannot delete studyplan that still has pages'); } else { @@ -407,10 +382,7 @@ class 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()), + "pages" => new \external_multiple_structure(studyplanpage::user_structure()), "aggregation_info" => aggregator::basic_structure(), ],'Studyplan with user info',$value); @@ -423,17 +395,13 @@ class studyplan { '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' => [], + 'pages' => [], 'aggregation_info' => $this->aggregator->basic_model(), ]; - $children = studyline::find_studyplan_children($this); - foreach($children as $c) + foreach($this->pages() as $p) { - $model['studylines'][] = $c->user_model($userid); + $model['pages'][] = $p->user_model($userid); } return $model; } @@ -459,24 +427,10 @@ class studyplan { // 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(); + foreach($this->pages() as $p){ + $newchild = $p->duplicate($this); } - // 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; } @@ -493,95 +447,12 @@ class studyplan { $model = $this->export_model(); $json = json_encode([ "type"=>"studyplan", - "version"=>1.0, + "version"=>2.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 = [ @@ -594,20 +465,19 @@ class studyplan { "aggregation" => $this->r->aggregation, "aggregation_config" => json_decode($this->aggregator->config_string()), 'aggregation_info' => $this->aggregator->basic_model(), - 'studylines' => $this->export_studylines_model(), + 'pages' => $this->export_pages_model(), ]; return $model; } - public function export_studylines_model() + public function export_pages_model() { - $children = studyline::find_studyplan_children($this); - $lines = []; - foreach($children as $c) + $pages = []; + foreach($this->pages() as $p) { - $lines[] = $c->export_model(); + $pages[] = $p->export_model(); } - return $lines; + return $pages; } public static function import_studyplan($content,$format="application/json",$context_id=1) @@ -615,15 +485,18 @@ class studyplan { if($format != "application/json") { return false;} $content = json_decode($content,true); - if($content["type"] == "studyplan" && $content["version"] >= 1.0){ + if($content["type"] == "studyplan" && $content["version"] >= 2.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 + // And make sure the context_id is set to the provided context for import $content["studyplan"]["context_id"] = $context_id; + + // Create a new plan, based on the given parameters - this is the import studyplan part $plan = self::add($content["studyplan"]); - return $plan->import_studylines_model($content["studyplan"]["studylines"]); + // Now import each page + return $plan->import_pages_model($content["studyplan"]["pages"]); } else { @@ -632,58 +505,33 @@ class studyplan { } } - public function import_studylines($content,$format="application/json") + public function import_pages($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"]); + if($content["version"] >= 2.0){ + if($content["type"] == "studyplanpage"){ + // import single page from a studyplanpage (wrapped in array of one page) + return $this->import_pages_model([$content["page"]]); + } + else if($content["type"] == "studyplan"){ + // Import all pages from the studyplan + return $this->import_pages_model($content["studyplan"]["pages"]); + } } 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) + protected function import_pages_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]); - } + $this->pages(); // make sure the page cache is initialized, since we will be adding to it. + foreach($model as $p){ + $p["studyplan_id"] = $this->id(); + $page = studyplanpage::add($p); + $this->page_cache[] = $page; + $page->import_studylines_model($p["studylines"]); } return true; } diff --git a/classes/studyplanpage.php b/classes/studyplanpage.php new file mode 100644 index 0000000..a3e7e50 --- /dev/null +++ b/classes/studyplanpage.php @@ -0,0 +1,544 @@ +libdir.'/externallib.php'); + +class studyplanpage { + + + const TABLE = "local_treestudyplan_page"; + private static $CACHE = []; + + + private $r; // Holds database record + private $id; + private $studyplan; + + public function aggregator(){ + return $this->studyplan->aggregator(); + } + + // Cache constructors to avoid multiple creation events in one session. + public static function findById($id): self { + if(!array_key_exists($id,self::$CACHE)){ + self::$CACHE[$id] = new self($id); + } + return self::$CACHE[$id]; + } + + private function __construct($id) { + global $DB; + $this->id = $id; + $this->r = $DB->get_record(self::TABLE,['id' => $id]); + $this->studyplan = studyplan::findById($this->r->studyplan_id); + } + + public function id(){ + return $this->id; + } + + public function studyplan() : studyplan { + return $this->studyplan; + } + + public function shortname(){ + return $this->r->shortname; + } + + public function periods(){ + return $this->r->periods; + } + + public function fullname(){ + return $this->r->fullname; + } + + public function startdate(){ + return new \DateTime($this->r->startdate); + } + + 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 page'), + "fullname" => new \external_value(PARAM_TEXT, 'name of studyplan page'), + "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan page'), + "periods" => new \external_value(PARAM_INT, 'number of periods in studyplan page'), + "description"=> new \external_value(PARAM_TEXT, 'description of studyplan page'), + "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan'), + "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan'), + ],'Studyplan page basic info',$value); + } + + public function simple_model(){ + return [ + 'id' => $this->r->id, + 'fullname' => $this->r->fullname, + 'shortname' => $this->r->shortname, + 'periods' => $this->r->periods, + 'description' => $this->r->description, + 'startdate' => $this->r->startdate, + 'enddate' => $this->r->enddate, + ]; + } + + 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 page'), + "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan page'), + "description"=> new \external_value(PARAM_TEXT, 'description of studyplan page'), + "periods" => new \external_value(PARAM_INT, 'number of periods in studyplan page'), + "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan page'), + "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan page'), + "studylines" => new \external_multiple_structure(studyline::editor_structure()), + ],'Studyplan page 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, + 'periods' => $this->r->periods, + 'startdate' => $this->r->startdate, + 'enddate' => $this->r->enddate, + 'studylines' => [], + ]; + + $children = studyline::find_page_children($this); + foreach($children as $c) + { + $model['studylines'][] = $c->editor_model(); + } + return $model; + } + + public static function add($fields){ + global $CFG, $DB; + + if(!isset($fields['studyplan_id'])){ + throw new \InvalidArgumentException("parameter 'studyplan_id' missing"); + } + + $addable = ['studyplan_id','fullname','shortname','description','periods','startdate','enddate']; + $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 page is immediately cached + } + + public function edit($fields){ + global $DB; + $editable = ['fullname','shortname','description','periods','startdate','enddate']; + $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); + + return $this; + } + + public function delete($force=false){ + global $DB; + + if($force){ + $children = studyline::find_page_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_page', ['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[] = self::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[] = self::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; + + } + + 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); + } + + public static function user_structure($value=VALUE_REQUIRED){ + return new \external_single_structure([ + "id" => new \external_value(PARAM_INT, 'id of studyplan page'), + "fullname" => new \external_value(PARAM_TEXT, 'name of studyplan page'), + "shortname"=> new \external_value(PARAM_TEXT, 'shortname of studyplan page'), + "description"=> new \external_value(PARAM_TEXT, 'description of studyplan page'), + "periods" => new \external_value(PARAM_INT, 'number of slots in studyplan page'), + "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan page'), + "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan page'), + "studylines" => new \external_multiple_structure(studyline::user_structure()), + ],'Studyplan page with user info',$value); + } + + public function user_model($userid){ + + $model = [ + 'id' => $this->r->id, + 'fullname' => $this->r->name, + 'shortname' => $this->r->shortname, + 'description' => $this->r->description, + 'periods' => $this->r->periods, + 'startdate' => $this->r->startdate, + 'enddate' => $this->r->enddate, + 'studylines' => [], + ]; + + $children = studyline::find_page_children($this); + foreach($children as $c) + { + $model['studylines'][] = $c->user_model($userid); + } + return $model; + } + + public static function find_studyplan_children(studyplan $plan) + { + global $DB; + $list = []; + $ids = $DB->get_fieldset_select(self::TABLE,"id","studyplan_id = :plan_id ORDER BY startdate", + ['plan_id' => $plan->id()]); + foreach($ids as $id) { + $list[] = self::findById($id); + } + return $list; + } + + public static function duplicate_page($page_id,$name,$shortname) + { + $ori = self::findById($page_id); + $new = $ori->duplicate($name,$shortname); + return $new->simple_model(); + } + + public function duplicate($name,$shortname) + { + // First duplicate the studyplan structure + $new = studyplanpage::add([ + 'fullname' => $name, + 'shortname' => $shortname, + 'description' => $this->r->description, + 'pages' => $this->r->pages, + 'startdate' => $this->r->startdate, + 'enddate' => empty($this->r->enddate)?null:$this->r->enddate, + ]); + + // next, copy the studylines + + $children = studyline::find_page_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_page() + { + $model = $this->export_model(); + $json = json_encode([ + "type"=>"studyplanpage", + "version"=>2.0, + "page"=>$model + ],\JSON_PRETTY_PRINT); + return [ "format" => "application/json", "content" => $json]; + } + + public function export_page_csv() + { + //TODO: Period shortnames instead of just P1, P2, P3 etc + + $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 = [ + 'fullname' => $this->r->name, + 'shortname' => $this->r->shortname, + 'description' => $this->r->description, + 'periods' => $this->r->periods, + 'startdate' => $this->r->startdate, + 'enddate' => $this->r->enddate, + 'studylines' => $this->export_studylines_model(), + ]; + return $model; + } + + public function export_studylines_model() + { + $children = studyline::find_page_children($this); + $lines = []; + foreach($children as $c) + { + $lines[] = $c->export_model(); + } + return $lines; + } + + 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"] >= 2.0){ + return $this->import_studylines_model($content["studylines"]); + } + else if($content["type"] == "studyplanpage" && $content["version"] >= 2.0){ + return $this->import_studylines_model($content["page"]["studylines"]); + } + else { + return false; + } + } + + protected function find_studyline_by_shortname($shortname){ + $children = studyline::find_page_children($this); + foreach($children as $l){ + if($shortname == $l->shortname()){ + return $l; + } + } + return null; + } + + public 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; + } + +} \ No newline at end of file diff --git a/cli/prime_students.php b/cli/prime_students.php index e5648d3..d570c21 100644 --- a/cli/prime_students.php +++ b/cli/prime_students.php @@ -65,12 +65,14 @@ foreach($plans as $plan){ cli_writeln(" - {$u->firstname} {$u->lastname} / {$u->username}"); } - $lines = studyline::find_studyplan_children($plan); - foreach($lines as $line){ - cli_writeln(" ** {$line->name()} **"); - $items = studyitem::find_studyline_children($line); - foreach($users as $u){ - $generator->addskill($u->username,$line->shortname()); + foreach($plan->pages() as $page){ + $lines = studyline::find_page_children($page); + foreach($lines as $line){ + cli_writeln(" ** {$line->name()} **"); + $items = studyitem::find_studyline_children($line); + foreach($users as $u){ + $generator->addskill($u->username,$line->shortname()); + } } } } diff --git a/cli/randomize_grades.php b/cli/randomize_grades.php index 74168fd..16cc2ea 100644 --- a/cli/randomize_grades.php +++ b/cli/randomize_grades.php @@ -89,74 +89,76 @@ foreach($plans as $plan){ cli_heading($plan->name()); $users = $plan->find_linked_users(); - $lines = studyline::find_studyplan_children($plan); - foreach($lines as $line){ - cli_writeln(" ** {$line->name()} **"); - $items = studyitem::find_studyline_children($line); - foreach($items as $item){ - if($item->type() == studyitem::COURSE) { // only handle courses for now - $courseinfo = $item->getcourseinfo(); - cli_writeln(" # {$courseinfo->shortname()}"); + foreach($plan->pages() as $page){ + $lines = studyline::find_page_children($page); + foreach($lines as $line){ + cli_writeln(" ** {$line->name()} **"); + $items = studyitem::find_studyline_children($line); + foreach($items as $item){ + if($item->type() == studyitem::COURSE) { // only handle courses for now + $courseinfo = $item->getcourseinfo(); + cli_writeln(" # {$courseinfo->shortname()}"); - if($courseinfo->course()->startdate <= time()){ + if($courseinfo->course()->startdate <= time()){ - foreach($users as $u){ - cli_writeln(" -> {$u->firstname} {$u->lastname} <-"); - $gradables = gradeinfo::list_studyitem_gradables($item); - $gen = $generator->generate($u->username,$line->shortname(),$gradables); - foreach($gen as $gg){ - $g = $gg->gi; - $gi = $g->getGradeitem(); + foreach($users as $u){ + cli_writeln(" -> {$u->firstname} {$u->lastname} <-"); + $gradables = gradeinfo::list_studyitem_gradables($item); + $gen = $generator->generate($u->username,$line->shortname(),$gradables); + foreach($gen as $gg){ + $g = $gg->gi; + $gi = $g->getGradeitem(); - $name = $gi->itemname; - $grade = $gg->gradetext; - cli_write (" - {$name} = {$grade}"); - - // Check if the item is alreaady graded for this user - $existing = $count = $DB->count_records_select('grade_grades','itemid = :gradeitemid AND finalgrade IS NOT NULL and userid = :userid', - ['gradeitemid' => $gi->id, 'userid' => $u->id]); + $name = $gi->itemname; + $grade = $gg->gradetext; + cli_write (" - {$name} = {$grade}"); + + // Check if the item is alreaady graded for this user + $existing = $count = $DB->count_records_select('grade_grades','itemid = :gradeitemid AND finalgrade IS NOT NULL and userid = :userid', + ['gradeitemid' => $gi->id, 'userid' => $u->id]); - if(!$existing){ - if($gg->grade > 0){ - if($gi->itemmodule == "assign"){ - // If it is an assignment, submit though that interface - list($c,$cminfo) = get_course_and_cm_from_instance($gi->iteminstance,$gi->itemmodule); - $cm_ctx = \context_module::instance($cminfo->id); - $a = new \assign($cm_ctx,$cminfo,$c); + if(!$existing){ + if($gg->grade > 0){ + if($gi->itemmodule == "assign"){ + // If it is an assignment, submit though that interface + list($c,$cminfo) = get_course_and_cm_from_instance($gi->iteminstance,$gi->itemmodule); + $cm_ctx = \context_module::instance($cminfo->id); + $a = new \assign($cm_ctx,$cminfo,$c); - $ug = $a->get_user_grade($u->id,true); - $ug->grade = grade_floatval($gg->grade); - $ug->grader = $USER->id; - $ug->feedbacktext = nl2br( htmlspecialchars($gg->fb)); - $ug->feedbackformat = FORMAT_HTML; + $ug = $a->get_user_grade($u->id,true); + $ug->grade = grade_floatval($gg->grade); + $ug->grader = $USER->id; + $ug->feedbacktext = nl2br( htmlspecialchars($gg->fb)); + $ug->feedbackformat = FORMAT_HTML; + + //print_r($ug); + if(!$options["dryrun"]){ + $a->update_grade($ug); + + grade_regrade_final_grades($c->id,$u->id,$gi); + cli_writeln(" ... Stored"); + } else { + cli_writeln(" ... (Dry Run)"); + } - //print_r($ug); - if(!$options["dryrun"]){ - $a->update_grade($ug); - - grade_regrade_final_grades($c->id,$u->id,$gi); - cli_writeln(" ... Stored"); } else { - cli_writeln(" ... (Dry Run)"); + // Otherwise, set the grade through the manual grading override + cli_writeln(" ... Cannot store"); + } - } else { - // Otherwise, set the grade through the manual grading override - cli_writeln(" ... Cannot store"); - + cli_writeln(" ... No grade"); } } else { - cli_writeln(" ... No grade"); + cli_writeln(" ... Already graded"); } - } else { - cli_writeln(" ... Already graded"); } } } - } - else - { - cli_writeln(" Skipping since it has not started yet"); + else + { + cli_writeln(" Skipping since it has not started yet"); + } } } } diff --git a/db/install.xml b/db/install.xml index e736628..05f1a4a 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -27,9 +27,6 @@ - - - diff --git a/db/upgrade.php b/db/upgrade.php index 0301024..60d056f 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -381,14 +381,41 @@ function xmldb_local_treestudyplan_upgrade($oldversion) { $recordset->close(); } - - - - - // Treestudyplan savepoint reached. upgrade_plugin_savepoint(true, 2023071900, 'local', 'treestudyplan'); } + if ($oldversion < 2023072000) { + + // Define field slots to be dropped from local_treestudyplan. + $table = new xmldb_table('local_treestudyplan'); + $field = new xmldb_field('slots'); + + // Conditionally launch drop field slots. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Define field startdate to be dropped from local_treestudyplan. + $table = new xmldb_table('local_treestudyplan'); + $field = new xmldb_field('startdate'); + + // Conditionally launch drop field startdate. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Define field enddate to be dropped from local_treestudyplan. + $table = new xmldb_table('local_treestudyplan'); + $field = new xmldb_field('enddate'); + + // Conditionally launch drop field enddate. + if ($dbman->field_exists($table, $field)) { + $dbman->drop_field($table, $field); + } + + // Treestudyplan savepoint reached. + upgrade_plugin_savepoint(true, 2023072000, 'local', 'treestudyplan'); + }