. /** * 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'); require_once($CFG->libdir.'/filelib.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 * @param bool $farahead Return a date 100 years in the future if end date is not set. Returns null otherwise * @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; } } } /** * Return description with all file references resolved * @return string */ public function description() { $text = \file_rewrite_pluginfile_urls( // The content of the text stored in the database. $this->r->description, // The pluginfile URL which will serve the request. 'pluginfile.php', // The combination of contextid / component / filearea / itemid. // Form the virtual bucket that file are stored in. \context_system::instance()->id, // System instance is always used for this. 'local_treestudyplan', 'studyplanpage', $this->id ); return $text; } /** * Determine studyplan timing * @return string */ public function timing() { $now = new \DateTime(); if ($now > $this->startdate()) { if ($now > $this->enddate()) { return "past"; } else { return "present"; } } else { return "future"; } } /** * 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'), "timing" => new \external_value(PARAM_TEXT, '(past|present|future)'), "perioddesc" => period::page_structure(), "timeless" => new \external_value(PARAM_BOOL, 'Page does not in'), ], '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->description(), 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'timing' => $this->timing(), "perioddesc" => period::page_model($this), 'timeless' => \get_config("local_treestudyplan", "timelessperiods"), ]; } /** * 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'), "timing" => new \external_value(PARAM_TEXT, '(past|present|future)'), "studylines" => new \external_multiple_structure(studyline::editor_structure()), "perioddesc" => period::page_structure(), "timeless" => new \external_value(PARAM_BOOL, 'Page does not include time information'), ], '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->description(), 'periods' => $this->r->periods, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'timing' => $this->timing(), 'studylines' => [], "perioddesc" => period::page_model($this), 'timeless' => \get_config("local_treestudyplan", "timelessperiods"), ]; $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 * @param bool $bare If true, do not create a first page with copy of studyplan names */ public static function add($fields, $bare = false): 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', 'descriptionformat', '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); $page = self::find_by_id($id); // Make sure the new page is immediately cached. if (!$bare) { if (get_config("local_treestudyplan", "copystudylinesnewpage")) { $templatepage = null; $templatelines = []; // Find the latest a page with lines in the plan. $pages = $plan->pages(true); foreach ($pages as $p) { if ($p->id() != $id) { $lines = studyline::find_page_children($p); if (count($lines) > 0) { $templatepage = $p; $templatelines = $lines; } } } if (isset($templatepage)) { foreach ($templatelines as $l) { $map = []; // Bare copy still requires passing an empty array. $l->duplicate($page, $map, true); // Do bare copy, which does not include line content. } } } if (count(studyline::find_page_children($page)) == 0) { // Add an empty study line if there are currently no study lines. $lineinfo = ['page_id' => $id, 'name' => get_string("default_line_name", "local_treestudyplan"), 'shortname' => get_string("default_line_shortname", "local_treestudyplan"), "color" => "#DDDDDD", ]; studyline::add($lineinfo); } } return $page; } /** * Edit studyplan page * @param mixed $fields Parameters to change */ public function edit($fields): self { global $DB; $editable = ['fullname', 'shortname', 'description', 'descriptionformat', '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'), "timing" => new \external_value(PARAM_TEXT, '(past|present|future)'), "studylines" => new \external_multiple_structure(studyline::user_structure()), "perioddesc" => period::page_structure(), "timeless" => new \external_value(PARAM_BOOL, 'Page does not include time information'), ], '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->description(), 'periods' => $this->r->periods, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'timing' => $this->timing(), 'studylines' => [], "perioddesc" => period::page_model($this), 'timeless' => \get_config("local_treestudyplan", "timelessperiods"), ]; $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; } } } } if ($courses > 0) { return ($completed / $courses); } else { return 0; } } /** * 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 * @param \DateInterval|null $timeoffset Amount if time to shift the dates for the new page */ public function duplicate(studyplan $newstudyplan, $timeoffset = null): self { if ($timeoffset == null) { $timeoffset = new \DateInterval("P0D"); } // 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->startdate()->add($timeoffset)->format("Y-m-d"), 'enddate' => empty($this->r->enddate) ? null : ($this->enddate()->add($timeoffset)->format("Y-m-d")), ]); // Copy any files related to this page. $areas = ["studyplanpage"]; $fs = \get_file_storage(); foreach ($areas as $area) { $files = $fs->get_area_files( \context_system::instance()->id, "local_treestudyplan", $area, $this->id() ); foreach ($files as $file) { $path = $file->get_filepath(); $filename = $file->get_filename(); if ($filename != ".") { // Prepare new file info for the target file. $fileinfo = [ 'contextid' => \context_system::instance()->id, // System context. 'component' => 'local_treestudyplan', // Your component name. 'filearea' => $area, // Area name. 'itemid' => $new->id(), // Study plan id. 'filepath' => $path, // Original file path. 'filename' => $filename, // Original file name. ]; // Copy existing file into new file. $fs->create_file_from_storedfile($fileinfo, $file); } } } // Adjust the time offsets of the periods. foreach ($this->periods() as $p) { $p->edit([ 'startdate' => $p->startdate()->add($timeoffset)->format("Y-m-d"), 'enddate' => $p->enddate()->add($timeoffset)->format("Y-m-d"), ]); } // Now , copy the studylines. $children = studyline::find_page_children($this); $itemtranslation = []; // Stores ids of original and copied items. $linetranslation = []; // Stores ids of original and copied lines. foreach ($children as $c) { $translation = []; $newchild = $c->duplicate($new, $translation); $itemtranslation += $translation; // Fixes behaviour where translation array is reset on each call. $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_RAW, '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() { $exportfiles = $this->export_files('studyplanpage'); $model = [ 'fullname' => $this->r->fullname, 'shortname' => $this->r->shortname, 'description' => $this->r->description, 'descriptionformat' => $this->r->descriptionformat, 'periods' => $this->r->periods, 'startdate' => $this->r->startdate, 'enddate' => $this->r->enddate, 'studylines' => $this->export_studylines_model(), 'perioddesc' => period::page_model($this), 'files' => $exportfiles, ]; return $model; } /** * Export files from file storage * @param string $area Name of the file area to export * @return array information model */ public function export_files($area) { $exportfiles = []; $fs = get_file_storage(); $files = $fs->get_area_files( \context_system::instance()->id, 'local_treestudyplan', $area, $this->id); foreach ($files as $file) { if ($file->get_filename() != ".") { $contents = $file->get_content(); $exportfiles[] = [ "name" => $file->get_filename(), "path" => $file->get_filepath(), "content" => convert_uuencode($contents), ]; } } return $exportfiles; } /** * Import previously exported files into the file storage * @param mixed $importfiles List of files to import from string in the format exported in export_model() * @param string $area Name of the file area to export */ public function import_files($importfiles, $area) { $fs = get_file_storage(); foreach ($importfiles as $file) { if ($file['name'] != ".") { $fileinfo = [ 'contextid' => \context_system::instance()->id, // ID of the system context. 'component' => 'local_treestudyplan', // Your component name. 'filearea' => $area, // Usually = table name. 'itemid' => $this->id, // ID of the studyplanpage. 'filepath' => $file["path"], // Path of the file. 'filename' => $file["name"], // Name of the file.. ]; $fs->create_file_from_string($fileinfo, convert_uudecode($file["content"])); } } } /** * 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 = []; $itemconnections = []; foreach ($model as $ix => $linemodel) { $translation = []; $connections = []; $linemap[$ix]->import_studyitems($linemodel["slots"], $translation, $connections); $itemtranslation += $translation; // Fixes behaviour where translation array is reset on each call. $itemconnections += $connections; } // Finally, create the links between the study items. foreach ($itemconnections as $from => $dests) { foreach ($dests as $to) { studyitemconnection::connect($from, $itemtranslation[$to]); } } return true; } }