. /** * Studyplan page management class * @package local_treestudyplan * @copyright 2023 P.M. Kuipers * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ namespace local_treestudyplan; defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir.'/externallib.php'); /** * Studyplan page management class */ class studyplanpage { /** * Database table this class models for * @var string */ const TABLE = "local_treestudyplan_page"; /** * Cache for finding previously loaded pages * @var array */ private static $cache = []; /** * Holds database record * @var stdClass */ private $r; /** @var int */ private $id; /** @var studyplan*/ private $studyplan; /** * Get aggregator for the studyplan (page) * @return aggregator */ public function aggregator() : aggregator { return $this->studyplan->aggregator(); } /** * Find record in database and return management object * @param int $id Id of database record */ public static function find_by_id($id): self { if (!array_key_exists($id, self::$cache)) { self::$cache[$id] = new self($id); } return self::$cache[$id]; } /** * Construct new instance from DB record * @param int $id Id of database record */ private function __construct($id) { global $DB; $this->id = $id; $this->r = $DB->get_record(self::TABLE, ['id' => $id]); $this->studyplan = studyplan::find_by_id($this->r->studyplan_id); } /** * Return database identifier * @return int */ public function id() { return $this->id; } /** * Find studyplan for this page */ public function studyplan() : studyplan { return $this->studyplan; } /** * Return short name * @return string */ public function shortname() { return $this->r->shortname; } /** * Numer of periods for this page */ public function periods() { return $this->r->periods; } /** * Return full name * @return string */ public function fullname() { return $this->r->fullname; } /** * Start date * @return \DateTime */ public function startdate() { return new \DateTime($this->r->startdate); } /** * End date * @return \DateTime|null */ public function enddate($farahead = true) { if ($this->r->enddate && strlen($this->r->enddate) > 0) { return new \DateTime($this->r->enddate); } else { // Return a date 100 years into the future. if ($farahead) { return (new \DateTime($this->r->startdate))->add(new \DateInterval("P100Y")); } else { return null; } } } /** * Webservice structure for basic info * @param int $value Webservice requirement constant */ public static function simple_structure($value = VALUE_REQUIRED) : \external_description { 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_RAW, 'description of studyplan page'), "startdate" => new \external_value(PARAM_TEXT, 'start date of studyplan'), "enddate" => new \external_value(PARAM_TEXT, 'end date of studyplan'), "perioddesc" => period::page_structure(), ], 'Studyplan page basic info', $value); } /** * Webservice model for basic info * @return array Webservice data model */ 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, "perioddesc" => period::page_model($this), ]; } /** * Webservice structure for editor info * @param int $value Webservice requirement constant */ public static function editor_structure($value = VALUE_REQUIRED) : \external_description { return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of studyplan'), "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_RAW, '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()), "perioddesc" => period::page_structure(), ], 'Studyplan page full structure', $value); } /** * Webservice model for editor info * @return array Webservice data model */ public function editor_model() { global $DB; $model = [ 'id' => $this->r->id, 'fullname' => $this->r->fullname, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'periods' => $this->r->periods, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'studylines' => [], "perioddesc" => period::page_model($this), ]; $children = studyline::find_page_children($this); foreach ($children as $c) { $model['studylines'][] = $c->editor_model(); } return $model; } /** * Add new study plan page * @param mixed $fields Parameter for new study plan page */ public static function add($fields) : self { global $DB; if (!isset($fields['studyplan_id'])) { throw new \InvalidArgumentException("parameter 'studyplan_id' missing"); } else { $plan = studyplan::find_by_id($fields['studyplan_id']); } $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]; } } if (!isset($info['periods'])) { $info['periods'] = 4; } else if ($info['periods'] < 1) { $info['periods'] = 1; } $id = $DB->insert_record(self::TABLE, $info); // Refresh the page cache for the studyplan $plan->pages(true); return self::find_by_id($id); // Make sure the new page is immediately cached. } /** * Edit studyplan page * @param mixed $fields Parameters to change */ public function edit($fields) : self { 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]; } } if (isset($info['periods']) && $info['periods'] < 1) { $info['periods'] = 1; } $DB->update_record(self::TABLE, $info); // Reload record after edit. $this->r = $DB->get_record(self::TABLE, ['id' => $this->id], "*", MUST_EXIST); return $this; } /** * Delete study plan page * @param bool $force Force deletion even if not empty * @return success */ 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', ['page_id' => $this->id]) > 0) { return success::fail('cannot delete studyplan page that still has studylines'); } else { $DB->delete_records(self::TABLE, ['id' => $this->id]); return success::success(); } } /** * Webservice structure for userinfo * @param int $value Webservice requirement constant */ public static function user_structure($value = VALUE_REQUIRED) : \external_description { 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_RAW, '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()), "perioddesc" => period::page_structure(), ], 'Studyplan page with user info', $value); } /** * Webservice model for user info * @param int $userid ID of user to check specific info for * @return array Webservice data model */ public function user_model($userid) { $model = [ 'id' => $this->r->id, 'fullname' => $this->r->fullname, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'periods' => $this->r->periods, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'studylines' => [], "perioddesc" => period::page_model($this), ]; $children = studyline::find_page_children($this); foreach ($children as $c) { $model['studylines'][] = $c->user_model($userid); } return $model; } /** * Scan user progress (completed modules) over this page for a specific user * @param int $userid ID of user to check for * @return float Fraction of completion */ public function scanuserprogress($userid) { $courses = 0; $completed = 0; foreach (studyline::find_page_children($this) as $line) { $items = studyitem::find_studyline_children($line); foreach ($items as $c) { if (in_array($c->type(), studyline::COURSE_TYPES)) { $courses += 1; if($c->completion($userid) >= completion::COMPLETED){ $completed += 1; } } } } return ($completed/$courses); } /** * Find list of pages belonging to a specified study plan * @param studyplan $plan Studyplan to search pages for * @return studyplanpage[] */ 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::find_by_id($id); } return $list; } /** * Duplicate a studyplan page * @param int $pageid Id of the page to copy * @param studyplan $newstudyplan Studyplan to copy the page into */ public static function duplicate_page(int $pageid, studyplan $newstudyplan) : self { $ori = self::find_by_id($pageid); $new = $ori->duplicate($newstudyplan); return $new; } /** * Duplicate this studyplan page * @param studyplan $newstudyplan Studyplan to copy the page into */ public function duplicate(studyplan $newstudyplan) : self { // First duplicate the studyplan structure. $new = self::add([ 'studyplan_id' => $newstudyplan->id(), 'fullname' => $this->r->fullname, 'shortname' => $this->r->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 $itemid) { // Copy based on the outgoing connections of each item, to avoid duplicates. $connections = studyitemconnection::find_outgoing($itemid); foreach ($connections as $conn) { studyitemconnection::connect($itemtranslation[$conn->from_id], $itemtranslation[$conn->to_id]); } } return $new; } /** * Description of export structure for webservices */ public static function export_structure() : \external_description { 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'); } /** * Export this page into a json model * @return array */ 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]; } /** * Export this page into a csv model * @return array */ public function export_page_csv() { $plist = period::find_for_page($this); $model = $this->editor_model(); $periods = intval($model["periods"]); // First line. $csv = "\"\""; for ($i = 1; $i <= $periods; $i++) { $name = $plist[$i]->shortname(); $csv .= ", \"{$name}\""; } $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 <= $periods; $i++) { if (count($line["slots"]) > $i) { $ct = 0; foreach ($line["slots"][$i][studyline::SLOTSET_COURSES] 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 <= $periods; $i++) { $filled = false; if (count($line["slots"]) > $i) { $ct = 0; foreach ($line["slots"][$i][studyline::SLOTSET_COURSES] 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]; } /** * Export this page's studylines into a json model * @return array */ public function export_studylines() { $model = $this->export_studylines_model(); $json = json_encode([ "type" => "studylines", "version" => 2.0, "studylines" => $model, ], \JSON_PRETTY_PRINT); return [ "format" => "application/json", "content" => $json]; } /** * Export this pages periods into a json model * @return array */ public function export_periods() { $model = period::page_model($this); $json = json_encode([ "type" => "periods", "version" => 2.0, "perioddesc" => $model, ], \JSON_PRETTY_PRINT); return [ "format" => "application/json", "content" => $json]; } /** * Export essential information for export * @return array information model */ public function export_model() { $model = [ 'fullname' => $this->r->fullname, '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(), 'perioddesc' => period::page_model($this), ]; return $model; } /** * Export this pages periods into an array before serialization * @return array */ public function export_studylines_model() { $children = studyline::find_page_children($this); $lines = []; foreach ($children as $c) { $lines[] = $c->export_model(); } return $lines; } /** * Import periods from file contents * @param string $content String * @param string $format Format description */ public function import_periods($content, $format = "application/json") { if ($format != "application/json") { return false; } $content = json_decode($content, true); if ($content["type"] == "periods" && $content["version"] >= 2.0) { return $this->import_periods_model($content["perioddesc"]); } else if ($content["type"] == "studyplanpage" && $content["version"] >= 2.0) { return $this->import_periods_model($content["page"]["perioddesc"]); } else { return false; } } /** * Import studylines from file contents * @param string $content String * @param string $format Format description */ 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 if ($content["type"] == "studyplan" && $content["version"] >= 2.0) { return $this->import_studylines_model($content["studyplan"]["pages"][0]["studylines"]); } else { return false; } } /** * Find a studyline in this page by its shortname * @param string $shortname * @return studyline|null */ 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; } /** * Import periods from decoded array model * @param array $model Decoded array */ public function import_periods_model($model) { $periods = period::find_for_page($this); foreach ($model as $pmodel) { $pi = $pmodel["period"]; if (array_key_exists($pi, $periods)) { $periods[$pi]->edit($pmodel); } } } /** * Import studylines from decoded array model * @param array $model Decoded array */ public function import_studylines_model($model) { // First attempt to map each studyline model to an existing or new line. $linemap = []; foreach ($model as $ix => $linemodel) { $line = $this->find_studyline_by_shortname($linemodel["shortname"]); if (empty($line)) { $linemodel["page_id"] = $this->id; $line = studyline::add($linemodel); } $linemap[$ix] = $line; } // Next, let each study line import the study items. $itemtranslation = []; $connections = []; foreach ($model as $ix => $linemodel) { $linemap[$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; } }