. /** * Model class for study items * @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'); /** * Model class for study items */ class studyitem { /** @var string */ public const COURSE = 'course'; /** @var string */ public const JUNCTION = 'junction'; /** @var string */ public const BADGE = 'badge'; /** @var string */ public const FINISH = 'finish'; /** @var string */ public const START = 'start'; /** @var string */ public const INVALID = 'invalid'; /** @var string */ public const TABLE = "local_treestudyplan_item"; /** * Cache retrieved studyitems in this session * @var array */ private static $cache = []; /** * Holds database record * @var stdClass */ private $r; /** @var int */ private $id; /** @var courseinfo */ private $courseinfo = null; /** @var studyline */ private $studyline; /** @var aggregator */ private $aggregator; /** * Return the context the studyplan is associated to */ public function context(): \context { return $this->studyline->context(); } /** * Return the studyline for this item */ public function studyline(): studyline { return $this->studyline; } /** * Return the condition string for this item */ public function conditions(): string { if ($this->r->type == self::COURSE) { return (!empty($this->r->conditions)) ? $this->r->conditions : ""; } else { return (!empty($this->r->conditions)) ? $this->r->conditions : "ALL"; } } /** * 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 a new model based on study item id * @param int $id Study item id */ public function __construct($id) { global $DB; $this->id = $id; $this->r = $DB->get_record(self::TABLE, ['id' => $id], "*", MUST_EXIST); $this->studyline = studyline::find_by_id($this->r->line_id); $this->aggregator = $this->studyline()->studyplan()->aggregator(); } /** * Return database identifier */ public function id(): int { return $this->id; } /** * Return period slot for this item */ public function slot(): int { return $this->r->slot; } /** * Return layer (order within a line and slot) for this item */ public function layer(): int { return $this->r->layer; } /** * Return study item type (see constants above) */ public function type(): string { return $this->r->type; } /** * Return period span */ public function span(): string { return $this->r->span; } /** * Return id of linked course (only relevant on COURSE types) or 0 if none */ public function courseid(): int { return $this->r->course_id; } /** * Check if a studyitem with the given id exists * @param int $id Id of studyplan */ public static function exists($id): bool { global $DB; return is_numeric($id) && $DB->record_exists(self::TABLE, ['id' => $id]); } /** * Webservice structure for simple 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 study item'), "type" => new \external_value(PARAM_TEXT, 'shortname of study item'), "slot" => new \external_value(PARAM_INT, 'slot in the study plan'), "layer" => new \external_value(PARAM_INT, 'layer in the slot'), "span" => new \external_value(PARAM_INT, 'how many periods the item spans'), "course" => courseinfo::simple_structure(VALUE_OPTIONAL), "badge" => badgeinfo::simple_structure(VALUE_OPTIONAL), ], "", $value); } /** * Webservice model for editor info * @return array Webservice data model */ public function simple_model() { global $DB; $model = [ 'id' => $this->r->id, // Id is needed in export model because of link references. 'type' => $this->valid() ? $this->r->type : self::INVALID, 'slot' => $this->r->slot, 'layer' => $this->r->layer, 'span' => $this->r->span, ]; $ci = $this->getcourseinfo(); if (isset($ci)) { $model['course'] = $ci->simple_model(); } if (is_numeric($this->r->badge_id) && $DB->record_exists('badge', ['id' => $this->r->badge_id])) { $badge = new \core_badges\badge($this->r->badge_id); $badgeinfo = new badgeinfo($badge); $model['badge'] = $badgeinfo->simple_model(); } return $model; } /** * 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 study item'), "type" => new \external_value(PARAM_TEXT, 'shortname of study item'), "conditions" => new \external_value(PARAM_TEXT, 'conditions for completion'), "slot" => new \external_value(PARAM_INT, 'slot in the study plan'), "layer" => new \external_value(PARAM_INT, 'layer in the slot'), "span" => new \external_value(PARAM_INT, 'how many periods the item spans'), "course" => courseinfo::editor_structure(VALUE_OPTIONAL), "badge" => badgeinfo::editor_structure(VALUE_OPTIONAL), "continuation_id" => new \external_value(PARAM_INT, 'id of continued item'), "connections" => new \external_single_structure([ 'in' => new \external_multiple_structure(studyitemconnection::structure()), 'out' => new \external_multiple_structure(studyitemconnection::structure()), ]), ], "", $value); } /** * Webservice model for editor info * @return array Webservice data model */ public function editor_model() { return $this->generate_model("editor"); } /** * Create a model for the given type of operation * @param string $mode One of [ 'editor', 'export'] */ private function generate_model($mode) { // Mode parameter is used to geep this function for both editor model and export model. // (Export model results in fewer parameters on children, but is otherwise basically the same as this function). global $DB; $model = [ 'id' => $this->r->id, // Id is needed in export model because of link references. 'type' => $this->valid() ? $this->r->type : self::INVALID, 'conditions' => $this->conditions(), 'slot' => $this->r->slot, 'layer' => $this->r->layer, 'span' => $this->r->span, 'continuation_id' => $this->r->continuation_id, 'connections' => [ "in" => [], "out" => [], ], ]; if ($mode == "export") { // Remove slot and layer. unset($model["slot"]); unset($model["layer"]); unset($model["continuation_id"]); $model["connections"] = []; // In export mode, connections is just an array of outgoing connections. } // Add course link if available. $ci = $this->getcourseinfo(); if (isset($ci)) { if ($mode == "export") { $model['course'] = $ci->shortname(); } else { $model['course'] = $ci->editor_model(); } } // Add badge info if available. if (is_numeric($this->r->badge_id) && $DB->record_exists('badge', ['id' => $this->r->badge_id])) { $badge = new \core_badges\badge($this->r->badge_id); $badgeinfo = new badgeinfo($badge); if ($mode == "export") { $model['badge'] = $badgeinfo->name(); } else { // Also supply a list of linked users, so the badgeinfo can give stats on . // The amount issued, related to this studyplan. $studentids = $this->studyline()->studyplan()->find_linked_userids(); $model['badge'] = $badgeinfo->editor_model($studentids); } } if ($mode == "export") { // Also export gradables. $gradables = gradeinfo::list_studyitem_gradables($this); if (count($gradables) > 0) { $model["gradables"] = []; foreach ($gradables as $g) { $model["gradables"][] = $g->export_model();; } } } // Add incoming and outgoing connection info. $connout = studyitemconnection::find_outgoing($this->id); if ($mode == "export") { foreach ($connout as $c) { $model["connections"][] = $c->to_id(); } } else { foreach ($connout as $c) { $model['connections']['out'][$c->to_id()] = $c->model(); } $connin = studyitemconnection::find_incoming($this->id); foreach ($connin as $c) { $model['connections']['in'][$c->from_id()] = $c->model(); } } return $model; } /** * Add a new item * @param array $fields Properties for study line ['line_id', 'type', 'layer', 'conditions', 'slot', * 'competency_id', 'course_id', 'badge_id', 'continuation_id', 'span'] * @param bool $import Set tot true if calling on import */ public static function add($fields, $import = false): self { global $DB; $addable = ['line_id', 'type', 'layer', 'conditions', 'slot', 'competency_id', 'course_id', 'badge_id', 'continuation_id', 'span']; $info = [ 'layer' => 0 ]; foreach ($addable as $f) { if (array_key_exists($f, $fields)) { $info[$f] = $fields[$f]; } } $id = $DB->insert_record(self::TABLE, $info); $item = self::find_by_id($id); if ($item->type() == self::COURSE) { // Signal the studyplan that a course has been added so it can be marked for csync cascading. $item->studyline()->studyplan()->mark_csync_changed(); } return $item; } /** * Edit study item properties * @param array $fields Changed roperties for study line ['conditions', 'course_id', 'continuation_id', 'span'] */ public function edit($fields): self { global $DB; $editable = ['conditions', 'course_id', 'continuation_id', 'span']; $info = ['id' => $this->id ]; foreach ($editable as $f) { if (array_key_exists($f, $fields) && isset($fields[$f])) { $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; } /** * Check if references course and badges are still available */ public function valid(): bool { // Check if referenced courses and/or badges still exist. if ($this->r->type == static::COURSE) { return courseinfo::exists($this->r->course_id); } else if ($this->r->type == static::BADGE) { return badgeinfo::exists($this->r->badge_id); } else { return true; } } /** * Delete studyitem * @param bool $force Force deletion even if item is referenced */ public function delete($force = false) { global $DB; // Check if this item is referenced in a START item. if ($force) { // Clear continuation id from any references to this item. $records = $DB->get_records(self::TABLE, ['continuation_id' => $this->id]); foreach ($records as $r) { $r->continuation_id = 0; $DB->update_record(self::TABLE, $r); } } if ($DB->count_records(self::TABLE, ['continuation_id' => $this->id]) > 0) { return success::fail('Cannot remove: item is referenced by another item'); } else { // Delete al related connections to this item. studyitemconnection::clear($this->id); // Delete all grade inclusion references to this item. $DB->delete_records("local_treestudyplan_gradeinc", ['studyitem_id' => $this->id]); // Delete the item itself. $DB->delete_records(self::TABLE, ['id' => $this->id]); return success::success(); } } /** * Reposition study items in line, layer and/or slot * @param mixed $resequence Array of item info [id, line_id, slot, layer] */ public static function reorder($resequence): success { global $DB; foreach ($resequence as $sq) { // Only change line_id if new line is within the same studyplan page. if (self::find_by_id($sq['id'])->studyline()->page()->id() == studyline::find_by_id($sq['line_id'])->page()->id() ) { $DB->update_record(self::TABLE, [ 'id' => $sq['id'], 'line_id' => $sq['line_id'], 'slot' => $sq['slot'], 'layer' => $sq['layer'], ]); } } return success::success(); } /** * Find all studyitems associated with a studyline * @param studyline $line The studyline to search for * @return studyitem[] */ public static function find_studyline_children(studyline $line): array { global $DB; $list = []; $ids = $DB->get_fieldset_select(self::TABLE, "id", "line_id = :line_id ORDER BY layer", ['line_id' => $line->id()]); foreach ($ids as $id) { $item = self::find_by_id($id, $line); $list[] = $item; } return $list; } /** * List all studyitems of a given type in a specific period in this line * @param studyline $line The studyline to search for * @param int $slot The slot to search in * @param int $type The type of items to include * @return studyitem[] */ public static function search_studyline_children(studyline $line, $slot, $type): array { global $DB; $list = []; $ids = $DB->get_fieldset_select( self::TABLE, "id", "line_id = :line_id AND type = :type AND ( slot <= :slota AND ( slot + (span-1) >= :slotb ) ) ORDER BY layer", ['line_id' => $line->id(), 'slota' => $slot, 'slotb' => $slot, 'type' => $type] ); foreach ($ids as $id) { $item = self::find_by_id($id, $line); $list[] = $item; } return $list; } /** * Webservice structure for linking between plans * @param int $value Webservice requirement constant */ private static function link_structure($value = VALUE_REQUIRED): \external_description { return new \external_single_structure([ "id" => new \external_value(PARAM_INT, 'id of study item'), "type" => new \external_value(PARAM_TEXT, 'type of study item'), "completion" => completion::structure(), "studyline" => new \external_value(PARAM_TEXT, 'reference label of studyline'), "studyplan" => new \external_value(PARAM_TEXT, 'reference label of studyplan'), ], 'basic info of referenced studyitem', $value); } /** * Webservice model for linking between plans * @param int $userid ID for user to links completion from */ private function link_model($userid): array { global $DB; $line = $DB->get_record(studyline::TABLE, ['id' => $this->r->line_id]); $plan = $DB->get_record(studyplan::TABLE, ['id' => $line->studyplan_id]); return [ "id" => $this->r->id, "type" => $this->r->type, "completion" => $this->completion($userid), "studyline" => $line->name(), "studyplan" => $plan->name(), ]; } /** * 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 study item'), "type" => new \external_value(PARAM_TEXT, 'type of study item'), "completion" => new \external_value(PARAM_TEXT, 'completion state (incomplete|progress|completed|excellent)'), "slot" => new \external_value(PARAM_INT, 'slot in the study plan'), "layer" => new \external_value(PARAM_INT, 'layer in the slot'), "span" => new \external_value(PARAM_INT, 'how many periods the item spans'), "course" => courseinfo::user_structure(VALUE_OPTIONAL), "badge" => badgeinfo::user_structure(VALUE_OPTIONAL), "continuation" => self::link_structure(VALUE_OPTIONAL), "connections" => new \external_single_structure([ 'in' => new \external_multiple_structure(studyitemconnection::structure()), 'out' => new \external_multiple_structure(studyitemconnection::structure()), ]), "lineenrolled" => new \external_value(PARAM_BOOL, 'student is enrolled in the line this item is in'), ], 'Study item 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) { global $CFG, $DB; $model = [ 'id' => $this->r->id, 'type' => $this->r->type, 'completion' => completion::label($this->completion($userid)), 'slot' => $this->r->slot, 'layer' => $this->r->layer, 'span' => $this->r->span, 'connections' => [ "in" => [], "out" => [], ], "lineenrolled" => $this->studyline()->isenrolled($userid), ]; // Add badge info if available. if (badgeinfo::exists($this->r->badge_id)) { $badge = new \core_badges\badge($this->r->badge_id); $badgeinfo = new badgeinfo($badge); $model['badge'] = $badgeinfo->user_model($userid); } // Add continuation_info if available. if (self::exists($this->r->continuation_id)) { $citem = self::find_by_id($this->r->continuation_id); $model['continuation'] = $citem->link_model($userid); } // Add course if available. if (courseinfo::exists($this->r->course_id)) { $cinfo = $this->getcourseinfo(); $model['course'] = $cinfo->user_model($userid); } // Add incoming and outgoing connection info. $connout = studyitemconnection::find_outgoing($this->id); foreach ($connout as $c) { $model['connections']['out'][$c->to_id()] = $c->model(); } $connin = studyitemconnection::find_incoming($this->id); foreach ($connin as $c) { $model['connections']['in'][$c->from_id()] = $c->model(); } return $model; } /** * Get courseinfo for studyitem if it references a course */ public function getcourseinfo(): ?courseinfo { if (empty($this->courseinfo) && courseinfo::exists($this->r->course_id)) { $this->courseinfo = new courseinfo($this->r->course_id, $this); } return $this->courseinfo; } /** * Determine completion for a particular user * @param int $userid User id * @return int completion:: constant */ public function completion($userid): int { global $DB; if ($this->valid()) { if (strtolower($this->r->type) == 'course') { // Determine competency by competency completion. $courseinfo = $this->getcourseinfo(); return $this->aggregator->aggregate_course($courseinfo, $this, $userid); } else if (strtolower($this->r->type) == 'start') { // Does not need to use aggregator. // Either true, or the completion of the reference. if (self::exists($this->r->continuation_id)) { $citem = self::find_by_id($this->r->continuation_id); return $citem->completion($userid); } else { return completion::COMPLETED; } } else if (in_array(strtolower($this->r->type), ['junction', 'finish'])) { // Completion of the linked items, according to the rule. $incomingcompletions = []; // Retrieve incoming connections. $incoming = $DB->get_records(studyitemconnection::TABLE, ['to_id' => $this->r->id]); foreach ($incoming as $conn) { $item = self::find_by_id($conn->from_id); $incomingcompletions[] = $item->completion($userid); } return $this->aggregator->aggregate_junction($incomingcompletions, $this, $userid); } else if (strtolower($this->r->type) == 'badge') { global $DB; // Badge awarded. if (badgeinfo::exists($this->r->badge_id)) { $badge = new \core_badges\badge($this->r->badge_id); if ($badge->is_issued($userid)) { if ($badge->can_expire()) { // Get the issued badges and check if any of them have not expired yet. $badgesissued = $DB->get_records("badge_issued", ["badge_id" => $this->r->badge_id, "user_id" => $userid]); $notexpired = false; $now = time(); foreach ($badgesissued as $bi) { if ($bi->dateexpire == null || $bi->dateexpire > $now) { $notexpired = true; break; } } return ($notexpired) ? completion::COMPLETED : completion::INCOMPLETE; } else { return completion::COMPLETED; } } else { return completion::INCOMPLETE; } } else { return completion::INCOMPLETE; } } else { // Return incomplete for other types. return completion::INCOMPLETE; } } else { // Return incomplete for other types. return completion::INCOMPLETE; } } /** * Duplicate this studyitem * @param studyline $newline Study line to add duplicate to */ public function duplicate($newline): self { global $DB; // Clone the database fields. $fields = clone $this->r; // Set new line id. unset($fields->id); $fields->line_id = $newline->id(); // Create new record with the new data. $id = $DB->insert_record(self::TABLE, (array)$fields); $new = self::find_by_id($id, $newline); // Copy the grading info if relevant. $gradables = gradeinfo::list_studyitem_gradables($this); foreach ($gradables as $g) { gradeinfo::include_grade($g->get_gradeitem()->id, $new->id(), true); } return $new; } /** * Export essential information for export * @return array information model */ public function export_model() { return $this->generate_model("export"); } /** * Import studyitems from model * @param array $model Decoded array */ public static function import_item($model): self { unset($model["course_id"]); unset($model["competency_id"]); unset($model["badge_id"]); unset($model["continuation_id"]); if (isset($model["course"])) { $model["course_id"] = courseinfo::id_from_shortname(($model["course"])); } if (isset($model["badge"])) { $model["badge_id"] = badgeinfo::id_from_name(($model["badge"])); } $item = self::add($model, true); if (isset($model["course_id"])) { // Attempt to import the gradables. foreach ($model["gradables"] as $gradable) { gradeinfo::import($item, $gradable); } } return $item; } }