diff --git a/amd/build/revoke.min.js b/amd/build/revoke.min.js new file mode 100644 index 00000000..55eeb39e --- /dev/null +++ b/amd/build/revoke.min.js @@ -0,0 +1,13 @@ +/** + * Revoke an appointment. + * + * This module allows for revoking an appointment from a student list. + * + * @module mod_scheduler/revoke + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define("mod_scheduler/revoke",["jquery","core/ajax","core/notification","core/str"],(function($,Ajax,Notification,Str){function Revoker(root,cmid){this.cmid=cmid,root.on("click",".otherstudent a.revoke-student",function(e){e.preventDefault();var wrapper=$(e.target).closest(".otherstudent");this.triggerRevoke(wrapper)}.bind(this))}return Revoker.prototype.triggerRevoke=function(studentWrapper){var appId=parseInt(studentWrapper.data("appointmentid"),10);appId&&Str.get_strings([{key:"confirmation",component:"core_admin"},{key:"confirmsinglerevoke",component:"mod_scheduler"},{key:"yes",component:"core"},{key:"no",component:"core"}]).then(function(str){Notification.confirm(str[0],str[1],str[2],str[3],function(){studentWrapper.hide(),this.revokeAppointment(appId).then((function(){studentWrapper.remove()})).fail((function(e){studentWrapper.show(),Notification.exception(e)}))}.bind(this),(function(){}))}.bind(this)).fail(Notification.exception)},Revoker.prototype.revokeAppointment=function(appId){return Ajax.call([{methodname:"mod_scheduler_revoke_appointment",args:{cmid:this.cmid,appointmentid:appId}}])[0]},{init:function(wrapperSelector,cmid){new Revoker($(wrapperSelector),cmid)}}})); + +//# sourceMappingURL=revoke.min.js.map \ No newline at end of file diff --git a/amd/build/revoke.min.js.map b/amd/build/revoke.min.js.map new file mode 100644 index 00000000..947940be --- /dev/null +++ b/amd/build/revoke.min.js.map @@ -0,0 +1 @@ +{"version":3,"file":"revoke.min.js","sources":["../src/revoke.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Revoke an appointment.\n *\n * This module allows for revoking an appointment from a student list.\n *\n * @module mod_scheduler/revoke\n * @copyright 2019 Royal College of Art\n * @author Frédéric Massart \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['jquery', 'core/ajax', 'core/notification', 'core/str'], function($, Ajax, Notification, Str) {\n\n var actionSelector = '.otherstudent a.revoke-student';\n var studentWrapperSelector = '.otherstudent';\n\n /**\n * Revoker.\n *\n * @param {jQuery} root The root wrapper as jQuery element.\n * @param {Number} cmid The cmid.\n */\n function Revoker(root, cmid) {\n this.cmid = cmid;\n\n root.on('click', actionSelector, function(e) {\n e.preventDefault();\n var wrapper = $(e.target).closest(studentWrapperSelector);\n this.triggerRevoke(wrapper);\n }.bind(this));\n }\n\n /**\n * Trigger the revoke from a node.\n *\n * @param {jQuery} studentWrapper The student wrapper node.\n */\n Revoker.prototype.triggerRevoke = function(studentWrapper) {\n var appId = parseInt(studentWrapper.data('appointmentid'), 10);\n if (!appId) {\n return;\n }\n\n Str.get_strings([\n {key: 'confirmation', component: 'core_admin'},\n {key: 'confirmsinglerevoke', component: 'mod_scheduler'},\n {key: 'yes', component: 'core'},\n {key: 'no', component: 'core'}\n ]).then(function(str) {\n Notification.confirm(str[0], str[1], str[2], str[3], function() {\n // The user confirmed.\n studentWrapper.hide();\n this.revokeAppointment(appId).then(function() {\n studentWrapper.remove();\n return;\n }).fail(function(e) {\n studentWrapper.show();\n Notification.exception(e);\n });\n }.bind(this), function() {\n // The user cancelled.\n });\n }.bind(this)).fail(Notification.exception);\n };\n\n /**\n * Revoke an appointment.\n *\n * @param {Number} appId The appointment ID.\n * @return {Deferred}\n */\n Revoker.prototype.revokeAppointment = function(appId) {\n return Ajax.call([{\n methodname: 'mod_scheduler_revoke_appointment',\n args: {\n cmid: this.cmid,\n appointmentid: appId\n }\n }])[0];\n };\n\n return {\n init: function(wrapperSelector, cmid) {\n new Revoker($(wrapperSelector), cmid);\n }\n };\n\n});\n"],"names":["define","$","Ajax","Notification","Str","Revoker","root","cmid","on","e","preventDefault","wrapper","target","closest","triggerRevoke","bind","this","prototype","studentWrapper","appId","parseInt","data","get_strings","key","component","then","str","confirm","hide","revokeAppointment","remove","fail","show","exception","call","methodname","args","appointmentid","init","wrapperSelector"],"mappings":";;;;;;;;;;AA0BAA,8BAAO,CAAC,SAAU,YAAa,oBAAqB,aAAa,SAASC,EAAGC,KAAMC,aAAcC,cAWpFC,QAAQC,KAAMC,WACdA,KAAOA,KAEZD,KAAKE,GAAG,QAZS,iCAYgB,SAASC,GACtCA,EAAEC,qBACEC,QAAUV,EAAEQ,EAAEG,QAAQC,QAbL,sBAchBC,cAAcH,UACrBI,KAAKC,cAQXX,QAAQY,UAAUH,cAAgB,SAASI,oBACnCC,MAAQC,SAASF,eAAeG,KAAK,iBAAkB,IACtDF,OAILf,IAAIkB,YAAY,CACZ,CAACC,IAAK,eAAgBC,UAAW,cACjC,CAACD,IAAK,sBAAuBC,UAAW,iBACxC,CAACD,IAAK,MAAOC,UAAW,QACxB,CAACD,IAAK,KAAMC,UAAW,UACxBC,KAAK,SAASC,KACbvB,aAAawB,QAAQD,IAAI,GAAIA,IAAI,GAAIA,IAAI,GAAIA,IAAI,GAAI,WAEjDR,eAAeU,YACVC,kBAAkBV,OAAOM,MAAK,WAC/BP,eAAeY,YAEhBC,MAAK,SAAStB,GACbS,eAAec,OACf7B,aAAa8B,UAAUxB,OAE7BM,KAAKC,OAAO,gBAGhBD,KAAKC,OAAOe,KAAK5B,aAAa8B,YASpC5B,QAAQY,UAAUY,kBAAoB,SAASV,cACpCjB,KAAKgC,KAAK,CAAC,CACdC,WAAY,mCACZC,KAAM,CACF7B,KAAMS,KAAKT,KACX8B,cAAelB,UAEnB,IAGD,CACHmB,KAAM,SAASC,gBAAiBhC,UACxBF,QAAQJ,EAAEsC,iBAAkBhC"} \ No newline at end of file diff --git a/amd/src/revoke.js b/amd/src/revoke.js new file mode 100644 index 00000000..f67c5f71 --- /dev/null +++ b/amd/src/revoke.js @@ -0,0 +1,103 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Revoke an appointment. + * + * This module allows for revoking an appointment from a student list. + * + * @module mod_scheduler/revoke + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define(['jquery', 'core/ajax', 'core/notification', 'core/str'], function($, Ajax, Notification, Str) { + + var actionSelector = '.otherstudent a.revoke-student'; + var studentWrapperSelector = '.otherstudent'; + + /** + * Revoker. + * + * @param {jQuery} root The root wrapper as jQuery element. + * @param {Number} cmid The cmid. + */ + function Revoker(root, cmid) { + this.cmid = cmid; + + root.on('click', actionSelector, function(e) { + e.preventDefault(); + var wrapper = $(e.target).closest(studentWrapperSelector); + this.triggerRevoke(wrapper); + }.bind(this)); + } + + /** + * Trigger the revoke from a node. + * + * @param {jQuery} studentWrapper The student wrapper node. + */ + Revoker.prototype.triggerRevoke = function(studentWrapper) { + var appId = parseInt(studentWrapper.data('appointmentid'), 10); + if (!appId) { + return; + } + + Str.get_strings([ + {key: 'confirmation', component: 'core_admin'}, + {key: 'confirmsinglerevoke', component: 'mod_scheduler'}, + {key: 'yes', component: 'core'}, + {key: 'no', component: 'core'} + ]).then(function(str) { + Notification.confirm(str[0], str[1], str[2], str[3], function() { + // The user confirmed. + studentWrapper.hide(); + this.revokeAppointment(appId).then(function() { + studentWrapper.remove(); + return; + }).fail(function(e) { + studentWrapper.show(); + Notification.exception(e); + }); + }.bind(this), function() { + // The user cancelled. + }); + }.bind(this)).fail(Notification.exception); + }; + + /** + * Revoke an appointment. + * + * @param {Number} appId The appointment ID. + * @return {Deferred} + */ + Revoker.prototype.revokeAppointment = function(appId) { + return Ajax.call([{ + methodname: 'mod_scheduler_revoke_appointment', + args: { + cmid: this.cmid, + appointmentid: appId + } + }])[0]; + }; + + return { + init: function(wrapperSelector, cmid) { + new Revoker($(wrapperSelector), cmid); + } + }; + +}); diff --git a/backup/moodle2/backup_scheduler_stepslib.php b/backup/moodle2/backup_scheduler_stepslib.php index 3aaf6f27..c2bf0d49 100644 --- a/backup/moodle2/backup_scheduler_stepslib.php +++ b/backup/moodle2/backup_scheduler_stepslib.php @@ -47,7 +47,7 @@ protected function define_structure() { 'scale', 'gradingstrategy', 'bookingrouping', 'usenotes', 'usebookingform', 'bookinginstructions', 'bookinginstructionsformat', 'usestudentnotes', 'requireupload', 'uploadmaxfiles', 'uploadmaxsize', - 'usecaptcha', 'timemodified')); + 'usecaptcha', 'timemodified', 'completionattended', 'canwatch')); $slots = new backup_nested_element('slots'); @@ -63,13 +63,19 @@ protected function define_structure() { 'appointmentnote', 'appointmentnoteformat', 'teachernote', 'teachernoteformat', 'studentnote', 'studentnoteformat', 'timecreated', 'timemodified')); + $watchers = new backup_nested_element('watchers'); + $watcher = new backup_nested_element('watcher', ['id'], ['slotid', 'userid']); + // Build the tree. $scheduler->add_child($slots); $slots->add_child($slot); $slot->add_child($appointments); + $slot->add_child($watchers); + $appointments->add_child($appointment); + $watchers->add_child($watcher); // Define sources. $scheduler->set_source_table('scheduler', array('id' => backup::VAR_ACTIVITYID)); @@ -79,6 +85,7 @@ protected function define_structure() { if ($userinfo) { $slot->set_source_table('scheduler_slots', array('schedulerid' => backup::VAR_PARENTID)); $appointment->set_source_table('scheduler_appointment', array('slotid' => backup::VAR_PARENTID)); + $watcher->set_source_table('scheduler_watcher', ['slotid' => backup::VAR_PARENTID]); } // Define id annotations. @@ -87,6 +94,7 @@ protected function define_structure() { if ($userinfo) { $slot->annotate_ids('user', 'teacherid'); $appointment->annotate_ids('user', 'studentid'); + $watcher->annotate_ids('user', 'userid'); } // Define file annotations. diff --git a/backup/moodle2/restore_scheduler_stepslib.php b/backup/moodle2/restore_scheduler_stepslib.php index 39332709..1c130854 100644 --- a/backup/moodle2/restore_scheduler_stepslib.php +++ b/backup/moodle2/restore_scheduler_stepslib.php @@ -50,6 +50,7 @@ protected function define_structure() { $appointment = new restore_path_element('scheduler_appointment', '/activity/scheduler/slots/slot/appointments/appointment'); $paths[] = $appointment; + $paths[] = new restore_path_element('scheduler_watcher', '/activity/scheduler/slots/slot/watchers/watcher'); } // Return the paths wrapped into standard activity structure. @@ -133,6 +134,23 @@ protected function process_scheduler_appointment($data) { $this->set_mapping('scheduler_appointment', $oldid, $newitemid, true); } + /** + * Process watcher. + * + * @param stdClass $data + */ + protected function process_scheduler_watcher($data) { + global $DB; + + $data = (object)$data; + $oldid = $data->id; + + $data->slotid = $this->get_new_parentid('scheduler_slot'); + $data->userid = $this->get_mappingid('user', $data->userid); + + $newitemid = $DB->insert_record('scheduler_watcher', $data); + } + /** * after_execute */ diff --git a/bookingform.php b/bookingform.php index 4ae84b0e..df31baad 100644 --- a/bookingform.php +++ b/bookingform.php @@ -74,9 +74,7 @@ protected function definition() { 'context' => $scheduler->get_context(), 'collapsed' => true); - $this->uploadoptions = array('subdirs' => 0, - 'maxbytes' => $scheduler->uploadmaxsize, - 'maxfiles' => $scheduler->uploadmaxfiles); + $this->uploadoptions = mod_scheduler_get_student_upload_options($scheduler); // Text field for student-supplied data. if ($scheduler->uses_studentnotes()) { @@ -157,24 +155,4 @@ public function prepare_booking_data(appointment $appointment) { return $newdata; } - /** - * save_booking_data - * - * @param stdClass $formdata - * @param appointment $appointment - */ - public function save_booking_data(stdClass $formdata, appointment $appointment) { - $scheduler = $appointment->get_scheduler(); - if ($scheduler->uses_studentnotes() && isset($formdata->studentnote_editor)) { - $editor = $formdata->studentnote_editor; - $appointment->studentnote = $editor['text']; - $appointment->studentnoteformat = $editor['format']; - } - if ($scheduler->uses_studentfiles()) { - file_save_draft_area_files($formdata->studentfiles, $scheduler->context->id, - 'mod_scheduler', 'studentfiles', $appointment->id, - $this->uploadoptions); - } - $appointment->save(); - } } diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php new file mode 100644 index 00000000..43237964 --- /dev/null +++ b/classes/csv_slots_importer.php @@ -0,0 +1,277 @@ +. + +/** + * Slots importer from CSV. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler; + +use core_user; +use csv_import_reader; +use DateTime; +use mod_scheduler\local\iterator\csv_reader_iterator; +use mod_scheduler\local\iterator\map_iterator; +use mod_scheduler\model\scheduler; +use mod_scheduler\permission\scheduler_permissions; +use Traversable; + +/** + * Slots importer from CSV. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class csv_slots_importer implements \IteratorAggregate { + + /** @var csv_import_reader The CSV import reader. */ + protected $cir; + /** @var object The permissions. */ + protected $permissions; + /** @var object[]|null List of availble teachers. */ + protected $availableteachers = null; + /** @var object[] List of teachers resolved by value. */ + protected $resolvedteachers = []; + /** @var scheduler The scheduler. */ + protected $scheduler; + + /** + * Constructor. + * + * @param scheduler $scheduler The scheduler. + * @param scheduler_permissions $permissions The user permissions. + * @param csv_import_reader $cir The CSV reader with content loaded. + */ + public function __construct(scheduler $scheduler, scheduler_permissions $permissions, csv_import_reader $cir) { + $this->scheduler = $scheduler; + $this->permissions = $permissions; + $this->cir = $cir; + } + + /** + * Convert the line to slot data. + * + * @param array $line The line indexed by column keys. + * @return object + */ + protected function convert_line($line) { + + // Mandatory columns. + try { + $date = new DateTime($line['date']); + } catch (\Exception $e) { + $date = new DateTime('@0'); + } + try { + $time = new DateTime($line['time']); + } catch (\Exception $e) { + $time = new DateTime('@0'); + } + $duration = (int) $line['duration']; + + // Optional columns. + $maxstudents = !empty($line['maxstudents']) ? (int) $line['maxstudents'] : 0; + $location = !empty($line['location']) ? $line['location'] : null; + $teacher = !empty($line['teacher']) ? $line['teacher'] : null; + $comment = !empty($line['comment']) ? $line['comment'] : null; + $displayfrom = !empty($line['displayfrom']) ? new DateTime($line['displayfrom']) : new DateTime(); + + // Massaging the data. + $date->setTime($time->format('H'), $time->format('i'), 0, 0); + $displayfrom->setTime(0, 0, 0, 0); + + return (object) [ + 'starttime' => $date, + 'duration' => $duration, + 'exclusivity' => max(0, $maxstudents), + 'teacher' => $this->resolve_teacher($teacher), + 'appointmentlocation' => $location ?? '', + 'hideuntil' => $displayfrom, + 'notes' => $comment ?? '', + 'notesformat' => FORMAT_MARKDOWN + ]; + } + + /** + * Get the available teachers IDs. + * + * @return object[] + */ + protected function get_allowed_teachers() { + if (!isset($this->availableteachers)) { + $teachers = $this->scheduler->get_available_teachers(); + $this->availableteachers = $teachers; + } + return $this->availableteachers; + } + + /** + * Get the iterator. + * + * @return \Iterator + */ + public function getIterator(): Traversable { // @codingStandardsIgnoreLine + return new map_iterator( + new csv_reader_iterator($this->cir), + function($line, $lineno) { + return $this->process_line($line, $lineno); + } + ); + } + + /** + * Make a slot from a processed line. + * + * @param object $info Result from {@see self::process_line}. + * @return slot + */ + public function make_slot_from_processed_line($info) { + if (!empty($info->errors)) { + throw new \coding_exception('We should not create a slot from an invalid line.'); + } + + $data = $info->data; + + $slot = $this->scheduler->create_slot(); + $slot->starttime = $data->starttime->getTimestamp(); + $slot->duration = $data->duration; + $slot->teacherid = $data->teacher->id; + $slot->appointmentlocation = $data->appointmentlocation; + $slot->notes = $data->notes; + $slot->notesformat = $data->notesformat; + $slot->exclusivity = $data->exclusivity; + $slot->hideuntil = $data->hideuntil->getTimestamp(); + $slot->timemodified = time(); + + return $slot; + } + + /** + * Processes a line. + * + * This returned structured information about the line, its data + * and the errors that we may have encountered while processing it. + * + * @param array $line Raw line from CSV. + * @param int $lineno Line number. + * @return object + */ + protected function process_line($line, $lineno) { + $line = array_combine($this->cir->get_columns(), $line); + $data = $this->convert_line($line); + $errors = $this->validate_data($data); + return (object) [ + 'lineno' => $lineno, + 'line' => $line, + 'data' => $data, + 'errors' => $errors, + ]; + } + + /** + * Resolve a teacher from the value passed. + * + * @param string|null $value Typically a user's username. + * @return object|null Or null when unresolved. + */ + protected function resolve_teacher($value) { + if (!isset($this->resolvedteachers[$value])) { + + if (empty($value)) { + $userid = $this->permissions->get_userid(); + $value = "#" . $userid; + $user = core_user::get_user($userid); + + } else { + $user = core_user::get_user_by_username($value); + } + if (!empty($user) && !core_user::is_real_user($user->id)) { + $user = false; + } + $this->resolvedteachers[$value] = $user; + } + + return empty($this->resolvedteachers[$value]) ? null : $this->resolvedteachers[$value]; + } + + /** + * Validate the CSV. + * + * @return string[] Returns an array of errors, if any. + */ + public function validate_csv() { + $errors = []; + $csvloaderror = $this->cir->get_error(); + + if (!is_null($csvloaderror)) { + $errors['csvloaderror'] = $csvloaderror; + return $errors; + } + + $columns = $this->cir->get_columns(); + $columns = $columns ?: []; + $requiredcols = ['date', 'time', 'duration']; + $diff = array_diff($requiredcols, $columns); + if (!empty($diff)) { + $errors['csvloaderror'] = get_string('csvmissingcolumns', 'mod_scheduler', implode(', ', $diff)); + return $errors; + } + + return []; + } + + /** + * Validates the slot data. + * + * @param object $data Data from {@see self::convert_line}. + * @return string[] + */ + protected function validate_data($data) { + $errors = []; + + if ($data->starttime->getTimestamp() < time()) { + $errors[] = get_string('invalidorpastdate', 'mod_scheduler'); + } + + $maxduration = 24 * 60; // Copied from slotforms.php. + if ($data->duration < 1 || $data->duration > $maxduration) { + $errors[] = get_string('durationrange', 'mod_scheduler', ['min' => 1, 'max' => $maxduration]); + } + + if (empty($data->teacher)) { + $errors[] = get_string('couldnotresolveteacher', 'mod_scheduler'); + + } else { + $cansetothers = $this->permissions->can_edit_all_slots() && $this->permissions->can_schedule_slot_to_other_teachers(); + if (!$cansetothers && $data->teacher->id != $this->permissions->get_userid()) { + $errors[] = get_string('cannotscheduleslotforothers', 'mod_scheduler'); + } + if (!array_key_exists($data->teacher->id, $this->get_allowed_teachers())) { + $errors[] = get_string('invalidteacher', 'mod_scheduler'); + } + } + + return $errors; + } + +} diff --git a/classes/event/slot_unwatched.php b/classes/event/slot_unwatched.php new file mode 100644 index 00000000..48f7167b --- /dev/null +++ b/classes/event/slot_unwatched.php @@ -0,0 +1,98 @@ +. + +/** + * Slot unwatched. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\event; + +/** + * Slot unwatched. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class slot_unwatched extends \core\event\base { + + /** + * Create this event from a watcher. + * + * @param \mod_scheduler\model\watcher $watcher The watcher. + * @return \core\event\base + */ + public static function create_from_watcher(\mod_scheduler\model\watcher $watcher) { + $slot = $watcher->get_slot(); + $event = self::create([ + 'context' => $slot->get_scheduler()->get_context(), + 'objectid' => $watcher->slotid, + 'relateduserid' => $watcher->userid + ]); + $event->add_record_snapshot('scheduler_watcher', $watcher->data); + $event->add_record_snapshot('scheduler_slots', $slot->data); + $event->add_record_snapshot('scheduler', $slot->get_scheduler()->data); + return $event; + } + + /** + * Init method. + */ + protected function init() { + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->data['objecttable'] = 'scheduler_slots'; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('event_slotunwatched', 'scheduler'); + } + + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' removed the user with id '{$this->relateduserid}' from the watchers of " . + "the slot with id '{$this->objectid}' in the scheduler with course module id '$this->contextinstanceid'."; + } + + /** + * Custom validation. + * + * @throws \coding_exception + */ + protected function validate_data() { + parent::validate_data(); + if ($this->contextlevel != CONTEXT_MODULE) { + throw new \coding_exception('Context level must be CONTEXT_MODULE.'); + } else if (empty($this->relateduserid)) { + throw new \coding_exception('The \'relateduserid\' must be provided.'); + } + } +} diff --git a/classes/event/slot_watched.php b/classes/event/slot_watched.php new file mode 100644 index 00000000..620e57b1 --- /dev/null +++ b/classes/event/slot_watched.php @@ -0,0 +1,98 @@ +. + +/** + * Slot watched. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\event; + +/** + * Slot watched. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class slot_watched extends \core\event\base { + + /** + * Create this event from a watcher. + * + * @param \mod_scheduler\model\watcher $watcher The watcher. + * @return \core\event\base + */ + public static function create_from_watcher(\mod_scheduler\model\watcher $watcher) { + $slot = $watcher->get_slot(); + $event = self::create([ + 'context' => $slot->get_scheduler()->get_context(), + 'objectid' => $watcher->slotid, + 'relateduserid' => $watcher->userid + ]); + $event->add_record_snapshot('scheduler_watcher', $watcher->data); + $event->add_record_snapshot('scheduler_slots', $slot->data); + $event->add_record_snapshot('scheduler', $slot->get_scheduler()->data); + return $event; + } + + /** + * Init method. + */ + protected function init() { + $this->data['crud'] = 'c'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->data['objecttable'] = 'scheduler_slots'; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('event_slotwatched', 'scheduler'); + } + + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ + public function get_description() { + return "The user with id '$this->userid' set the user with id '{$this->relateduserid}' as a watcher of " . + "the slot with id '{$this->objectid}' in the scheduler with course module id '$this->contextinstanceid'."; + } + + /** + * Custom validation. + * + * @throws \coding_exception + */ + protected function validate_data() { + parent::validate_data(); + if ($this->contextlevel != CONTEXT_MODULE) { + throw new \coding_exception('Context level must be CONTEXT_MODULE.'); + } else if (empty($this->relateduserid)) { + throw new \coding_exception('The \'relateduserid\' must be provided.'); + } + } +} diff --git a/classes/external.php b/classes/external.php new file mode 100644 index 00000000..8401a53f --- /dev/null +++ b/classes/external.php @@ -0,0 +1,957 @@ +. + +/** + * External API. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler; +defined('MOODLE_INTERNAL') || die(); + +use coding_exception; +use core_user; +use external_api; +use external_function_parameters; +use external_multiple_structure; +use external_single_structure; +use external_value; +use moodle_exception; +use moodle_url; +use mod_scheduler\model\appointment; +use mod_scheduler\model\scheduler; +use mod_scheduler\model\slot; +use mod_scheduler\permission\scheduler_permissions; +use mod_scheduler_renderer as renderer; +use scheduler_messenger; +use stored_file; + +require_once($CFG->libdir . '/externallib.php'); +require_once($CFG->dirroot . '/mod/scheduler/mailtemplatelib.php'); + +/** + * External API. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class external extends external_api { + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function appointment_list_viewed_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + ]); + } + + /** + * Trigger appointment list viewed event. + * + * @param int $cmid The cmid. + * @return null + */ + public static function appointment_list_viewed($cmid) { + global $USER; + + $params = self::validate_parameters(self::appointment_list_viewed_parameters(), ['cmid' => $cmid]); + $cmid = $params['cmid']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + self::validate_context($scheduler->get_context()); + $permissions = new scheduler_permissions($scheduler->get_context(), $USER->id); + + $permissions->ensure($permissions->is_teacher()); + \mod_scheduler\event\appointment_list_viewed::create_from_scheduler($scheduler)->trigger(); + + return true; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function appointment_list_viewed_returns() { + return new external_value(PARAM_BOOL); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function booking_form_viewed_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + ]); + } + + /** + * Trigger booking form viewed event. + * + * @param int $cmid The cmid. + * @return null + */ + public static function booking_form_viewed($cmid) { + global $USER; + + $params = self::validate_parameters(self::booking_form_viewed_parameters(), ['cmid' => $cmid]); + $cmid = $params['cmid']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + self::validate_context($scheduler->get_context()); + $permissions = new scheduler_permissions($scheduler->get_context(), $USER->id); + + $permissions->ensure($permissions->is_student()); + \mod_scheduler\event\booking_form_viewed::create_from_scheduler($scheduler)->trigger(); + + return true; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function booking_form_viewed_returns() { + return new external_value(PARAM_BOOL); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function book_slot_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'slotid' => new external_value(PARAM_INT), + 'groupid' => new external_value(PARAM_INT, '', VALUE_DEFAULT, 0), + 'bookingdata' => new external_single_structure([ + 'studentnote' => new external_value(PARAM_RAW, '', VALUE_DEFAULT, ''), + 'studentnoteformat' => new external_value(PARAM_INT, '', VALUE_DEFAULT, FORMAT_PLAIN), + ], '', VALUE_DEFAULT, []) + ]); + } + + /** + * Book a slot. + * + * @param int $cmid The cmid. + * @param int $slotid The slot ID. + * @param int $groupid The group ID, if any. + * @param array|null $bookingdata The booking data. + * @return null + */ + public static function book_slot($cmid, $slotid, $groupid = 0, $bookingdata = []) { + global $USER; + + $params = self::validate_parameters(self::book_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid, + 'groupid' => $groupid, 'bookingdata' => $bookingdata]); + $cmid = $params['cmid']; + $slotid = $params['slotid']; + $groupid = $params['groupid']; + $bookingdata = $params['bookingdata']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + $context = $scheduler->get_context(); + self::validate_context($context); + $permissions = new scheduler_permissions($context, $USER->id); + + $permissions->ensure($permissions->is_student()); + require_capability('mod/scheduler:appoint', $context); + $slot = $scheduler->get_slot($slotid); + + if (!static::is_in_app_booking_supported($scheduler)) { + throw new moodle_exception('bookingnotsupported', 'mod_scheduler'); + } else if ($slot->is_booked_by_student($USER->id)) { + throw new moodle_exception('alreadybookedbyyou', 'mod_scheduler'); + } + + if (empty($bookingdata)) { + $bookingdata = ['studentnote' => '', 'studentnoteformat' => FORMAT_PLAIN]; + } + $bookingdata = (object) $bookingdata; + + if ($scheduler->uses_bookingform()) { + if ($scheduler->is_studentnotes_required() && static::is_empty($bookingdata->studentnote)) { + throw new moodle_exception('studentnotemissing', 'mod_scheduler'); + } + } + + if (!$scheduler->is_individual_scheduling_enabled() && !$groupid) { + throw new moodle_exception('choosegrouptobook', 'mod_scheduler'); + } + + $formdata = (object) []; + if ($scheduler->uses_studentnotes()) { + $formdata->studentnote_editor = [ + 'text' => $bookingdata->studentnote, + 'format' => $bookingdata->studentnoteformat ?: FORMAT_PLAIN, + ]; + } + mod_scheduler_book_slot($scheduler, $slotid, $USER->id, $groupid, $formdata); + + return self::serialize_appointment($scheduler->get_slot($slotid)->get_student_appointment($USER->id)); + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function book_slot_returns() { + return static::appointment_structure(); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function get_available_slots_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'page' => new external_value(PARAM_INT, '', VALUE_DEFAULT, 1), + 'perpage' => new external_value(PARAM_INT, '', VALUE_DEFAULT, 25), + ]); + } + + /** + * Get available slots. + * + * When the student can watch slots, this will also return slots that are fully booked but + * can be watched in order to be notified when they open up. + * + * @param int $cmid The cmid. + * @param int $page The page number. + * @param int $perpage The number of items per page. + * @return array + */ + public static function get_available_slots($cmid, $page = 1, $perpage = 25) { + global $USER; + + $params = self::validate_parameters(self::get_available_slots_parameters(), ['cmid' => $cmid, 'page' => $page]); + $cmid = $params['cmid']; + $page = max($params['page'], 1); + $offset = $page - 1 * $perpage; + + $userid = $USER->id; + $scheduler = scheduler::load_by_coursemodule_id($cmid); + $context = $scheduler->get_context(); + self::validate_context($context); + + require_capability('mod/scheduler:viewslots', $context); + require_capability('mod/scheduler:appoint', $context); + $canbook = has_capability('mod/scheduler:appoint', $context); + $canwatch = has_capability('mod/scheduler:watchslots', $context) && $scheduler->is_watching_enabled(); + $canseeothers = has_capability('mod/scheduler:seeotherstudentsbooking', $context); + + $nobookingsremaining = $scheduler->count_bookable_appointments($userid, false); + $canbookslots = $canbook && $nobookingsremaining != 0; + $canwatchslots = $canwatch && $canbookslots; + $bookableslots = []; + $totalbookableslots = 0; + + if ($canbookslots) { + $bookableslotsraw = array_values($scheduler->get_slots_available_to_student($userid, $canwatchslots)); + $totalbookableslots = count($bookableslotsraw); + $bookableslots = array_map(function($slot) { + return static::serialize_slot($slot); + }, array_slice($bookableslotsraw, ($page - 1) * $perpage, $perpage)); + } + + $hasnextpage = $page * $perpage < $totalbookableslots; + + return [ + 'bookingsremaining' => $nobookingsremaining, + 'canbookslots' => $canbookslots, + 'canwatchslots' => $canwatchslots, + 'hasnextpage' => $hasnextpage, + 'page' => $page, + 'slots' => $bookableslots, + 'total' => $totalbookableslots, + ]; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function get_available_slots_returns() { + return new external_single_structure([ + 'bookingsremaining' => new external_value(PARAM_INT, 'The number of bookings remaining, -1 means unlimited.'), + 'canbookslots' => new external_value(PARAM_BOOL, 'Whether the user can book additional slots.'), + 'canwatchslots' => new external_value(PARAM_BOOL, 'Whether the user can watch additional slots.'), + 'hasnextpage' => new external_value(PARAM_BOOL, 'Whether we can browse another page.'), + 'page' => new external_value(PARAM_INT, 'The current page number.'), + 'slots' => new external_multiple_structure(static::slot_structure(), 'The slots'), + 'total' => new external_value(PARAM_INT, 'The total number of slots in the set.'), + ]); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function revoke_appointment_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'appointmentid' => new external_value(PARAM_INT), + ]); + } + + /** + * Revoke appointment. + * + * @param int $cmid The cmid. + * @param int $appointmentid The appointment ID. + * @return null + */ + public static function revoke_appointment($cmid, $appointmentid) { + global $USER; + + $params = self::validate_parameters(self::revoke_appointment_parameters(), + ['cmid' => $cmid, 'appointmentid' => $appointmentid]); + + $cmid = $params['cmid']; + $appointmentid = $params['appointmentid']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + self::validate_context($scheduler->get_context()); + $permissions = new scheduler_permissions($scheduler->get_context(), $USER->id); + + list($slot, $app) = $scheduler->get_slot_appointment($appointmentid); + $permissions->ensure($permissions->can_edit_slot($slot)); + $slot->remove_appointment($app); + + // Notify the student. + if ($scheduler->allownotifications) { + $student = core_user::get_user($app->studentid, '*', MUST_EXIST); + $teacher = core_user::get_user($slot->teacherid, '*', MUST_EXIST); + scheduler_messenger::send_slot_notification($slot, 'bookingnotification', 'teachercancelled', + $teacher, $student, $teacher, $student, $scheduler->get_courserec()); + } + + $slot->save(); + + return true; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function revoke_appointment_returns() { + return new external_value(PARAM_BOOL); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function update_appointment_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'appid' => new external_value(PARAM_INT), + 'attended' => new external_value(PARAM_BOOL), + 'grade' => new external_value(PARAM_INT, 'The grade: -1 for no grades, null for no change.', VALUE_DEFAULT, null), + ]); + } + + /** + * Update an appointment. + * + * @param int $cmid The cmid. + * @param int $appid The appointment ID. + * @param bool $attended Whether to mark as attended. + * @param int $grade The grade. + * @return array + */ + public static function update_appointment($cmid, $appid, $attended, $grade = null) { + global $USER; + + $params = self::validate_parameters(self::update_appointment_parameters(), ['cmid' => $cmid, 'appid' => $appid, + 'attended' => $attended, 'grade' => $grade]); + $cmid = $params['cmid']; + $appid = $params['appid']; + $attended = $params['attended']; + $grade = $params['grade']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + $context = $scheduler->get_context(); + self::validate_context($context); + $permissions = new scheduler_permissions($context, $USER->id); + + list($slot, $app) = $scheduler->get_slot_appointment($appid); + $permissions->ensure($permissions->is_teacher()); + $permissions->ensure($permissions->can_see_appointment($app)); + + if ($permissions->can_edit_attended($app)) { + $app->attended = $attended; + } + if ($permissions->can_edit_grade($app) && $grade !== null) { + $app->grade = $grade < 0 ? -1 : $grade; + } + + $app->save(); + return self::serialize_appointment($app); + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function update_appointment_returns() { + return static::appointment_structure(); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function unwatch_slot_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'slotid' => new external_value(PARAM_INT), + ]); + } + + /** + * Watch a slot. + * + * @param int $cmid The cmid. + * @param int $slotid The slot ID.. + * @return true + */ + public static function unwatch_slot($cmid, $slotid) { + global $USER; + + $params = self::validate_parameters(self::unwatch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); + $cmid = $params['cmid']; + $slotid = $params['slotid']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + $context = $scheduler->get_context(); + self::validate_context($context); + $permissions = new scheduler_permissions($context, $USER->id); + + $permissions->ensure($permissions->is_student()); + require_capability('mod/scheduler:watchslots', $context); + + $slot = $scheduler->get_slot($slotid); + if (!$slot) { + throw new moodle_exception('error'); + } + + $watcher = $slot->remove_watcher($USER->id); + if ($watcher) { + \mod_scheduler\event\slot_unwatched::create_from_watcher($watcher)->trigger(); + } + + return true; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function unwatch_slot_returns() { + return new external_value(PARAM_BOOL); + } + + /** + * External function parameters. + * + * @return external_function_parameters + */ + public static function watch_slot_parameters() { + return new external_function_parameters([ + 'cmid' => new external_value(PARAM_INT), + 'slotid' => new external_value(PARAM_INT), + ]); + } + + /** + * Watch a slot. + * + * @param int $cmid The cmid. + * @param int $slotid The slot ID.. + * @return true + */ + public static function watch_slot($cmid, $slotid) { + global $USER; + + $params = self::validate_parameters(self::watch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); + $cmid = $params['cmid']; + $slotid = $params['slotid']; + + $scheduler = scheduler::load_by_coursemodule_id($cmid); + $context = $scheduler->get_context(); + self::validate_context($context); + $permissions = new scheduler_permissions($context, $USER->id); + + $permissions->ensure($permissions->is_student()); + require_capability('mod/scheduler:watchslots', $context); + + if (!$scheduler->is_watching_enabled()) { + throw new moodle_exception('error'); + } else if (!$scheduler->student_can_watch_more_slots($USER->id)) { + throw new moodle_exception('cannotwatchmoreslots'); + } + + $slot = $scheduler->get_slot($slotid); + if (!$slot) { + throw new moodle_exception('error'); + } else if (!$slot->is_watchable_by_student($USER->id)) { + throw new moodle_exception('nopermissions'); + } + + $watcher = $slot->add_watcher($USER->id); + if ($watcher) { + \mod_scheduler\event\slot_watched::create_from_watcher($watcher)->trigger(); + } + + return true; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function watch_slot_returns() { + return new external_value(PARAM_BOOL); + } + + /** + * Serialize an appointment. + * + * @param appointment $app The appointment. + * @return array + */ + public static function serialize_appointment(appointment $app) { + global $PAGE, $USER; + + $context = $app->get_scheduler()->get_context(); + $permissions = new scheduler_permissions($context, $USER->id); + $renderer = $PAGE->get_renderer('mod_scheduler'); + + $weburl = new moodle_url('/mod/scheduler/view.php', [ + 'id' => $app->get_scheduler()->get_cmid(), + 'what' => 'viewstudent', + 'appointmentid' => $app->id + ]); + + $studentfiles = []; + $studentnote = null; + $studentnoteformat = null; + $studentnoteformatted = null; + + $appnote = null; + $appnoteformat = null; + $appnoteformatted = null; + + $teachernote = null; + $teachernoteformat = null; + $teachernoteformatted = null; + + // Student note. + if ($permissions->can_see_appointment($app)) { + $appnote = $app->appointmentnote; + $appnoteformat = $app->appointmentnoteformat; + $appnoteformatted = external_format_text($app->appointmentnote, $app->appointmentnoteformat, $context->id, + 'mod_scheduler', 'appointmentnote', $app->id)[0]; + + $studentnote = $app->studentnote; + $studentnoteformat = $app->studentnoteformat; + $studentnoteformatted = external_format_text($app->studentnote, $app->studentnoteformat, $context->id)[0]; + + $fs = get_file_storage(); + $studentfiles = array_map(function($file) { + return static::serialize_file($file); + }, $fs->get_area_files($context->id, 'mod_scheduler', 'studentfiles', $app->id, 'filename', false)); + } + + // Teacher-only. + if ($permissions->is_teacher()) { + $teachernote = $app->teachernote; + $teachernoteformat = $app->teachernoteformat; + $teachernoteformatted = external_format_text($app->teachernote, $app->teachernoteformat, $context->id, + 'mod_scheduler', 'teachernote', $app->id)[0]; + } + + return [ + 'id' => $app->id, + + 'appointmentnote' => $appnote, + 'appointmentnoteformat' => $appnoteformat, + 'appointmentnoteformatted' => $appnoteformatted, + 'hasappointmentnote' => !static::is_empty($appnote), + + 'gradeformatted' => $renderer->format_grade($app->get_scheduler(), $app->grade), + 'hasgrade' => $app->grade !== null, + 'isattended' => $app->attended, + + 'caneditattended' => $permissions->can_edit_attended($app), + 'caneditgrade' => $permissions->can_edit_grade($app), + 'caneditnotes' => $permissions->can_edit_notes($app), + + 'hasstudentdata' => !static::is_empty($studentnote) || !empty($studentfiles), + 'studentfiles' => array_values($studentfiles), + 'hasstudentfiles' => !empty($studentfiles), + 'studentnote' => $studentnote, + 'studentnoteformat' => $studentnoteformat, + 'studentnoteformatted' => $studentnoteformatted, + 'hasstudentnote' => !static::is_empty($studentnote), + + 'teachernote' => $teachernote, + 'teachernoteformat' => $teachernoteformat, + 'teachernoteformatted' => $teachernoteformatted, + 'hasteachernote' => !static::is_empty($teachernote), + + 'student' => static::serialize_user($app->student), + 'weburl' => $weburl->out(false) + ]; + } + + /** + * Get appointment structure. + * + * @return external_value + */ + protected static function appointment_structure() { + return new external_single_structure([ + 'id' => new external_value(PARAM_INT), + + 'appointmentnote' => new external_value(PARAM_RAW, 'Notes for the student'), + 'appointmentnoteformat' => new external_value(PARAM_INT), + 'appointmentnoteformatted' => new external_value(PARAM_RAW), + 'hasappointmentnote' => new external_value(PARAM_BOOL), + + 'gradeformatted' => new external_value(PARAM_RAW, 'The grade formatted'), + 'hasgrade' => new external_value(PARAM_BOOL), + 'isattended' => new external_value(PARAM_BOOL), + + 'hasstudentdata' => new external_value(PARAM_BOOL), + 'studentfiles' => new external_multiple_structure(static::file_structure()), + 'hasstudentfiles' => new external_value(PARAM_BOOL), + 'studentnote' => new external_value(PARAM_RAW, 'Notes by the student.'), + 'studentnoteformat' => new external_value(PARAM_INT), + 'studentnoteformatted' => new external_value(PARAM_RAW), + 'hasstudentnote' => new external_value(PARAM_BOOL), + + 'teachernote' => new external_value(PARAM_RAW, 'Notes for the teacher, hidden to student', VALUE_DEFAULT, null), + 'teachernoteformat' => new external_value(PARAM_INT, '', VALUE_DEFAULT, null), + 'teachernoteformatted' => new external_value(PARAM_RAW, '', VALUE_DEFAULT, null), + 'hasteachernote' => new external_value(PARAM_BOOL), + + 'student' => static::user_structure(), + 'weburl' => new external_value(PARAM_URL), + ]); + } + + /** + * Serialize a file. + * + * @param stored_file $file The file. + * @return array + */ + public static function serialize_file(stored_file $file) { + if ($file->is_directory()) { + throw new coding_exception('Cannot serialize directories'); + } + return [ + 'contextid' => $file->get_contextid(), + 'component' => $file->get_component(), + 'filearea' => $file->get_filearea(), + 'itemid' => $file->get_itemid(), + 'filepath' => $file->get_filepath(), + 'filename' => $file->get_filename(), + 'url' => moodle_url::make_webservice_pluginfile_url( $file->get_contextid(), $file->get_component(), + $file->get_filearea(), $file->get_itemid(), $file->get_filepath(), $file->get_filename()), + 'timemodified' => $file->get_timemodified(), + 'timecreated' => $file->get_timecreated(), + 'filesize' => $file->get_filesize(), + ]; + } + + /** + * Serialized file structure. + * + * @return external_value + */ + protected static function file_structure() { + return new external_single_structure([ + 'contextid' => new external_value(PARAM_INT), + 'component' => new external_value(PARAM_COMPONENT), + 'filearea' => new external_value(PARAM_AREA), + 'itemid' => new external_value(PARAM_INT), + 'filepath' => new external_value(PARAM_TEXT), + 'filename' => new external_value(PARAM_TEXT), + 'url' => new external_value(PARAM_TEXT), + 'timemodified' => new external_value(PARAM_INT), + 'timecreated' => new external_value(PARAM_INT, 'Time created', VALUE_OPTIONAL), + 'filesize' => new external_value(PARAM_INT, 'File size', VALUE_OPTIONAL), + ]); + } + + /** + * Serialize a scheduler. + * + * @param scheduler $scheduler The scheduler. + * @return array + */ + public static function serialize_scheduler(scheduler $scheduler) { + $context = $scheduler->get_context(); + + $weburl = new moodle_url('/mod/scheduler/view.php', [ + 'id' => $scheduler->get_cmid(), + ]); + + $data = (object) [ + 'id' => $scheduler->get_id(), + 'courseid' => $scheduler->get_courseid(), + 'cmid' => $scheduler->get_cmid(), + 'name' => external_format_string($scheduler->name, $context), + + 'bookinginstructions' => $scheduler->bookinginstructions, + 'bookinginstructionsformat' => $scheduler->bookinginstructionsformat, + 'hasbookinginstructions' => $scheduler->has_bookinginstructions(), + + 'intro' => $scheduler->intro, + 'introformat' => $scheduler->introformat, + 'hasintro' => !static::is_empty($scheduler->intro), + + 'isinappbookingsupported' => static::is_in_app_booking_supported($scheduler), + 'isindividualbookingenabled' => $scheduler->is_individual_scheduling_enabled(), + 'isgroupbookingenabled' => $scheduler->is_group_scheduling_enabled(), + + 'teachername' => $scheduler->get_teacher_name(), + 'usesbookingform' => $scheduler->uses_bookingform(), + 'usesgrades' => $scheduler->uses_grades(), + 'usesstudentnotes' => $scheduler->uses_studentnotes(), + + 'weburl' => $weburl->out(false) + ]; + + list($data->introformatted, $unused) = external_format_text($scheduler->intro, $scheduler->introformat, + $context->id, 'mod_scheduler', 'intro'); + + list($data->bookinginstructionsformatted, $unused) = external_format_text($scheduler->bookinginstructions, + $scheduler->bookinginstructionsformat, $context->id, 'mod_scheduler', 'bookinginstructions', 0); + + return (array) $data; + } + + /** + * Serialize a slot. + * + * @param slot $slot The slot. + * @return array + */ + public static function serialize_slot(slot $slot) { + global $USER; + + $context = $slot->get_scheduler()->get_context(); + $permissions = new scheduler_permissions($context, $USER->id); + $nremaining = $slot->count_remaining_appointments(); + + $canbookslots = has_capability('mod/scheduler:appoint', $context); + $canwatchslots = has_capability('mod/scheduler:appoint', $context) + && $slot->get_scheduler()->is_watching_enabled() + && $slot->get_scheduler()->student_can_watch_more_slots($USER->id); + $canseeothers = has_capability('mod/scheduler:seeotherstudentsbooking', $context); + + $canbookslot = $canbookslots && $nremaining != 0 && $slot->is_in_bookable_period(); + $isslotwatchable = $slot->is_watchable_by_student($USER->id); + $iswatching = $isslotwatchable && $slot->is_watched_by_student($USER->id); + $canwatchslot = ($canwatchslots && $isslotwatchable) || $iswatching; + + $appointments = array_map(function($app) { + return static::serialize_appointment($app); + }, array_filter($slot->get_appointments(), function($app) use ($canseeothers, $permissions) { + if ($permissions->is_student() && $canseeothers) { + return true; + } + return $permissions->can_see_appointment($app); + })); + $hasappointments = !empty($appointments); + + return [ + 'id' => $slot->id, + + 'starttime' => $slot->starttime, + 'duration' => $slot->duration, + 'timeformatted' => renderer::slotdatetime($slot->starttime, $slot->duration), + + 'notes' => $slot->notes, + 'notesformat' => $slot->notesformat, + 'notesformatted' => external_format_text($slot->notes, $slot->notesformat, $context->id, + 'mod_scheduler', 'slotnote', $slot->id)[0], + 'hasnotes' => !static::is_empty($slot->notes), + + 'appointments' => $appointments, + 'hasappointments' => $hasappointments, + + 'appointmentlocation' => $slot->appointmentlocation, + 'hasappointmentlocation' => !static::is_empty($slot->appointmentlocation), + + 'canbookslot' => $canbookslot, + 'canwatchslot' => $canwatchslot, + 'iswatching' => $iswatching, + + 'isunlimited' => $slot->exclusivity == 0, + 'iseditable' => $slot->is_in_bookable_period(), + 'isexclusive' => $slot->exclusivity == 1, + 'isgroupallowed' => !$slot->exclusivity || $slot->exclusivity >= 1, + 'isfull' => $nremaining == 0, + + 'nremaining' => $nremaining, + 'ntaken' => $slot->exclusivity > 0 ? $slot->exclusivity - $nremaining : $slot->get_appointment_count(), + 'maxappointments' => $slot->exclusivity, + + 'teacher' => static::serialize_user($slot->teacher) + ]; + } + + /** + * Get slot structure. + * + * @return external_value + */ + protected static function slot_structure() { + return new external_single_structure([ + 'id' => new external_value(PARAM_INT), + + 'starttime' => new external_value(PARAM_INT), + 'duration' => new external_value(PARAM_INT), + 'timeformatted' => new external_single_structure([ + 'date' => new external_value(PARAM_RAW), + 'starttime' => new external_value(PARAM_RAW), + 'shortdatetime' => new external_value(PARAM_RAW), + 'endtime' => new external_value(PARAM_RAW), + ]), + + 'notes' => new external_value(PARAM_RAW), + 'notesformat' => new external_value(PARAM_INT), + 'notesformatted' => new external_value(PARAM_HTML), + 'hasnotes' => new external_value(PARAM_BOOL), + + 'appointments' => new external_multiple_structure( + static::appointment_structure() + ), + 'hasappointments' => new external_value(PARAM_BOOL), + + 'appointmentlocation' => new external_value(PARAM_RAW), + 'hasappointmentlocation' => new external_value(PARAM_BOOL), + + 'canbookslot' => new external_value(PARAM_BOOL), + 'canwatchslot' => new external_value(PARAM_BOOL), + 'iswatching' => new external_value(PARAM_BOOL), + 'isunlimited' => new external_value(PARAM_BOOL), + 'iseditable' => new external_value(PARAM_BOOL), + 'isexclusive' => new external_value(PARAM_BOOL), + 'isgroupallowed' => new external_value(PARAM_BOOL), + 'isfull' => new external_value(PARAM_BOOL), + + 'nremaining' => new external_value(PARAM_INT), + 'ntaken' => new external_value(PARAM_INT, 'The number of seats taken when not unlimited.'), + 'maxappointments' => new external_value(PARAM_INT), + + 'teacher' => static::user_structure(), + ]); + } + + /** + * Serialize a user. + * + * @param object $user The user. + * @return array + */ + public static function serialize_user($user) { + global $PAGE; + $userpicture = new \user_picture($user); + $userpicture->size = 1; + $profileimageurl = $userpicture->get_url($PAGE)->out(false); + return [ + 'id' => $user->id, + 'fullname' => fullname($user), + 'profileimageurl' => $profileimageurl, + ]; + } + + /** + * Get the user structure. + * + * @return external_value + */ + protected static function user_structure() { + return new external_single_structure([ + 'id' => new external_value(PARAM_INT), + 'fullname' => new external_value(PARAM_RAW), + 'profileimageurl' => new external_value(PARAM_URL) + ]); + } + + /** + * Check whether a value is empty. + * + * @param mixed $value The value. + * @return bool + */ + public static function is_empty($value) { + if (is_string($value)) { + $value = trim(strip_tags($value)); + } + return empty($value); + } + + /** + * Whether in-app booking is supported. + * + * @param scheduler $scheduler The scheduler. + * @return bool + */ + public static function is_in_app_booking_supported(scheduler $scheduler) { + $bookingsupported = true; + if ($scheduler->uses_bookingform()) { + if ($scheduler->uses_bookingcaptcha()) { + $bookingsupported = false; + } else if ($scheduler->is_studentfiles_required()) { + $bookingsupported = false; + } + } + return $bookingsupported; + } +} diff --git a/classes/local/iterator/csv_reader_iterator.php b/classes/local/iterator/csv_reader_iterator.php new file mode 100644 index 00000000..1c48df31 --- /dev/null +++ b/classes/local/iterator/csv_reader_iterator.php @@ -0,0 +1,127 @@ +. + +/** + * CSV iterator. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\local\iterator; + +use csv_import_reader; + +/** + * CSV iterator. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class csv_reader_iterator implements \Iterator { + + /** @var csv_import_reader The CSV import reader. */ + protected $cir; + /** @var mixed Current value. */ + protected $current; + /** @var int The current position. */ + protected $pos = 0; + /** @var bool Whether the reader was initialised. */ + protected $initialised = false; + + /** + * Constructor. + * + * @param csv_import_reader $cir The CSV reader. + */ + public function __construct(csv_import_reader $cir) { + $this->cir = $cir; + } + + #[\ReturnTypeWillChange] + /** + * Return current value. + * + * @return mixed + */ + public function current() { + if ($this->current === null) { + $this->next(); + } + return $this->current; + } + + /** + * Ensure the CSV reader was initialised. + * + * @return void + */ + protected function ensure_initialised() { + if (!$this->initialised) { + $this->initialised = true; + $this->cir->init(); + $this->current = null; + } + } + + #[\ReturnTypeWillChange] + /** + * Return the line number. + * + * Note that the reader handles the CSV headers for us, so offset this value by 1. + * + * @return int + */ + public function key() { + return $this->pos + 1; + } + + /** + * Go to the next value. + * + * @return void + */ + public function next(): void { + $this->ensure_initialised(); + $this->pos++; + $this->current = $this->cir->next(); + } + + /** + * Rewind the reader. + * + * @return void + */ + public function rewind(): void { + $this->pos = 0; + $this->initialised = false; + $this->current = null; + $this->cir->close(); + } + + /** + * Whether the reader is still in a valid state. + * + * @return bool + */ + public function valid(): bool { + return $this->current !== false; + } +} diff --git a/classes/local/iterator/map_iterator.php b/classes/local/iterator/map_iterator.php new file mode 100644 index 00000000..c38d786b --- /dev/null +++ b/classes/local/iterator/map_iterator.php @@ -0,0 +1,98 @@ +. + +/** + * CSV iterator. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\local\iterator; + +/** + * Iterator map. + * + * To iterate over an iterator applying a function to its returned values. + * + * The callback receives the iterator's value as first argument, and the + * index of the value as second argument. + */ +class map_iterator implements \Iterator { + + /** @var Iterator The iterator. */ + protected $iterator; + /** @var callable The callback. */ + protected $callback; + + /** + * Constructor. + * + * @param Iterator $iterator The iterator. + * @param callable $callback The callback to apply to each item. + */ + public function __construct(\Iterator $iterator, callable $callback) { + $this->iterator = $iterator; + $this->callback = $callback; + } + + #[\ReturnTypeWillChange] + /** + * Current. + * + * @return mixed + */ + public function current() { + $cb = $this->callback; + return $cb($this->iterator->current(), $this->iterator->key()); + } + + #[\ReturnTypeWillChange] + /** + * Key. + * + * @return mixed + */ + public function key() { + return $this->iterator->key(); + } + + /** + * Next. + */ + public function next(): void { + $this->iterator->next(); + } + + /** + * Rewing. + */ + public function rewind(): void { + $this->iterator->rewind(); + } + + /** + * Valid. + * + * @return bool + */ + public function valid(): bool { + return $this->iterator->valid(); + } + +} diff --git a/classes/model/appointment.php b/classes/model/appointment.php index 977e23e9..3d110792 100644 --- a/classes/model/appointment.php +++ b/classes/model/appointment.php @@ -37,6 +37,9 @@ */ class appointment extends mvc_child_record_model { + /** @var bool Initial 'isattended' value. */ + private $initialisattended = null; + /** * get_table * @@ -65,10 +68,29 @@ public function __construct(slot $slot) { * save */ public function save() { + // Check whether the attended status has changed internally, if the value is still null, then we consider + // that the attended status has not changed, as thus we do not trigger an update. This is especially useful + // when a new appointment is made, to reduce the cost of creating a new appointment. However, if in + // the future a user must have attended ALL of their appointments, then we would have to update the + // completion state when the value is null, which would indicate a new appointment. + $isattendedchanged = $this->initialisattended !== null && $this->initialisattended !== $this->is_attended(); + $isnew = empty($this->data->id); + $this->data->slotid = $this->get_parent()->get_id(); parent::save(); + $this->initialisattended = $this->is_attended(); + $scheddata = $this->get_scheduler()->get_data(); scheduler_update_grades($scheddata, $this->studentid); + + // If we've just created the appointment, make sure the user is no longer a watcher. + if ($isnew) { + $this->get_slot()->remove_watcher($this->studentid); + } + + if ($isattendedchanged) { + $this->get_scheduler()->completion_update_has_attended($this->studentid, $this->is_attended()); + } } /** @@ -87,6 +109,7 @@ public function delete() { $fs->delete_area_files($cid, 'mod_scheduler', 'teachernote', $this->get_id()); $fs->delete_area_files($cid, 'mod_scheduler', 'studentnote', $this->get_id()); + $this->get_scheduler()->completion_update_has_attended($this->studentid); } /** @@ -151,4 +174,22 @@ public function count_studentfiles() { return count($files); } + /** + * Set attended. + * + * This method is protected as it currently is only meant to be used from + * the {@see mvc_record_model::__set} method. + * + * We use this method to observe whether the value has changed and decide + * whether to inform the scheduler that it should be updating the completion + * state of the student. + * + * @param bool $value The value. + */ + protected function set_attended($value) { + if ($this->initialisattended === null) { + $this->initialisattended = $this->is_attended(); + } + $this->data->attended = $value; + } } diff --git a/classes/model/mvc_child_list.php b/classes/model/mvc_child_list.php index cb3dfe02..6575a9e1 100644 --- a/classes/model/mvc_child_list.php +++ b/classes/model/mvc_child_list.php @@ -145,6 +145,16 @@ public function get_children() { return $this->children; } + /** + * Return whether there are children. + * + * @return bool + */ + public function has_children() { + $this->load(); + return !empty($this->children); + } + /** * Count the children in this list. * @@ -212,4 +222,23 @@ public function delete_children() { $child->delete(); } } + + /** + * Get the number of children pending deletion. + * + * @return bool + */ + public function get_children_pending_deletion_count() { + return count($this->childrenfordeletion); + } + + /** + * Whether there are children pending deletion. + * + * @return bool + */ + public function has_children_pending_deletion() { + return !empty($this->childrenfordeletion); + } + } diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index 3a24cff7..af017622 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -26,8 +26,12 @@ defined('MOODLE_INTERNAL') || die(); +require_once($CFG->libdir . '/completionlib.php'); require_once($CFG->dirroot . '/grade/lib.php'); +use completion_info; +use mod_scheduler\slots_query_builder; + /** * A class for representing a scheduler instance, as an MVC model. * @@ -143,6 +147,24 @@ public function delete_all_slots() { scheduler_grade_item_update($this, 'reset'); } + /** + * Delete a student's watchlist. + * + * @param int $userid The user ID. + * @return void + */ + public function delete_student_watchlist($userid) { + global $DB; + $sql = "DELETE FROM {scheduler_watcher} + WHERE userid = :userid + AND slotid IN ( + SELECT id + FROM {scheduler_slots} + WHERE schedulerid = :schedulerid)"; + $params = ['schedulerid' => $this->data->id, 'userid' => $userid]; + $DB->execute($sql, $params); + } + /** * Retrieve the course module id of this scheduler * @@ -283,6 +305,73 @@ public function is_individual_scheduling_enabled() { } } + /** + * Whether students should upload at least one file. + * + * @return bool + */ + public function is_studentfiles_required() { + return $this->uses_studentfiles() && $this->requireupload; + } + + /** + * Whether students must provide notes. + * + * @return bool + */ + public function is_studentnotes_required() { + return $this->uses_studentnotes() && $this->usestudentnotes == 2; + } + + /** + * The maximum number of slots a student can watch at a time. + * + * @return int Where 0 means unlimited. + */ + public function get_maximum_slots_watched() { + $config = get_config('mod_scheduler'); + return isset($config->maxslotswatched) ? (int) $config->maxslotswatched : 0; + } + + /** + * Whether this scheduler supports watching. + * + * @return bool + */ + public function is_watching_enabled() { + return (bool) $this->data->canwatch && $this->is_individual_scheduling_enabled(); + } + + /** + * Whether the student can watch more slots. + * + * @param int $studentid The student ID. + * @return bool + */ + public function student_can_watch_more_slots($studentid) { + global $DB; + + $max = $this->get_maximum_slots_watched(); + if (!$max) { + return true; + } + + $sql = "SELECT COUNT(w.id) + FROM {scheduler_watcher} w + JOIN {scheduler_slots} s + ON s.id = w.slotid + WHERE w.userid = :userid + AND s.starttime > :cutofftime + AND s.hideuntil < :nowhide"; + $params = [ + 'userid' => $studentid, + 'nowhide' => time(), + 'cutofftime' => time() + $this->guardtime + ]; + + return $DB->count_records_sql($sql, $params) < $max; + } + /** * get the last location of a certain teacher in this scheduler * @@ -538,6 +627,61 @@ public function get_gradebook_info($studentid) { return null; } + /** + * Whether attendance is required for completing this activity. + * + * @return bool + */ + public function completion_requires_attended() { + return !empty($this->data->completionattended); + } + + /** + * Update the completion state of a user, if needed. + * + * @param int $userid The user ID. + * @param bool $hasattended Whether the user just attended a slot. + * @return void + */ + public function completion_update_has_attended($userid, $hasattended = false) { + if (!$this->completion_requires_attended()) { + return; + } + + $course = $this->get_courserec(); + $cm = $this->get_cm(); + $completion = new completion_info($course); + + if ($completion->is_enabled($cm)) { + $state = $hasattended ? COMPLETION_COMPLETE : COMPLETION_UNKNOWN; + $completion->update_state($cm, $state, $userid); + } + } + + /** + * Whether the user has attended any slot. + * + * @param int $userid The user ID. + * @return bool + */ + public function has_user_attended_any_slot($userid) { + global $DB; + + $sql = "SELECT 1 + FROM {scheduler_appointment} a + JOIN {scheduler_slots} s + ON a.slotid = s.id + WHERE s.schedulerid = :id + AND a.studentid = :userid + AND a.attended = 1"; + + $params = [ + 'id' => $this->data->id, + 'userid' => $userid + ]; + + return $DB->record_exists_sql($sql, $params); + } /* *********************** Loading lists of slots *********************** */ @@ -550,9 +694,11 @@ public function get_gradebook_info($studentid) { * @param mixed $limitfrom query limit from here * @param mixed $limitnum max number od records to fetch * @param string $orderby ORDER BY fields + * @param string $joins The joins. * @return slot[] */ - protected function fetch_slots($wherecond, $havingcond, array $params, $limitfrom='', $limitnum='', $orderby='') { + protected function fetch_slots($wherecond, $havingcond, array $params, $limitfrom='', $limitnum='', $orderby='', $joins = '') { + global $DB; $select = 'SELECT s.* FROM {scheduler_slots} s'; @@ -573,7 +719,7 @@ protected function fetch_slots($wherecond, $havingcond, array $params, $limitfro $order = "ORDER BY s.id"; } - $sql = "$select $where $having $order"; + $sql = "$select $joins $where $having $order"; $slotdata = $DB->get_records_sql($sql, $params, $limitfrom, $limitnum); $slots = array(); @@ -590,9 +736,10 @@ protected function fetch_slots($wherecond, $havingcond, array $params, $limitfro * * @param string $wherecond WHERE condition * @param array $params parameters for DB query + * @param string $joins The joins. * @return int */ - protected function count_slots($wherecond, array $params) { + protected function count_slots($wherecond, array $params, $joins = '') { global $DB; $select = 'SELECT COUNT(*) FROM {scheduler_slots} s'; @@ -602,11 +749,21 @@ protected function count_slots($wherecond, array $params) { } $params['schedulerid'] = $this->data->id; - $sql = "$select $where"; + $sql = "$select $joins $where"; return $DB->count_records_sql($sql, $params); } + /** + * Count slots using a query builder. + * + * @param slots_query_builder $qb The query builder. + * @return slot[] + */ + public function count_slots_from_query_builder(slots_query_builder $qb) { + list($where, $params) = $qb->get_where(); + return $this->count_slots($where, $params, $qb->get_joins()); + } /** * Subquery that counts appointments in the current slot. @@ -683,6 +840,15 @@ public function get_slot_count() { return $this->slots->get_child_count(); } + /** + * Get a new instance of a slot query builder. + * + * @return slots_query_builder + */ + public function get_slots_query_builder() { + return new slots_query_builder('s.'); + } + /** * Load a list of all slots, between certain limits * @@ -902,6 +1068,17 @@ public function get_slots_for_group($groupid, $limitfrom = '', $limitnum = '', $ return $this->fetch_slots($where, '', $params, $limitfrom, $limitnum, 's.starttime ASC, s.duration ASC, s.teacherid'); } + /** + * Fetch slots using a query builder. + * + * @param slots_query_builder $qb The query builder. + * @return slot[] + */ + public function get_slots_from_query_builder(slots_query_builder $qb) { + list($where, $params) = $qb->get_where(); + list($limitnum, $limitfrom) = $qb->get_limit(); + return $this->fetch_slots($where, '', $params, $limitfrom, $limitnum, $qb->get_order_by(), $qb->get_joins()); + } /* ************** End of slot retrieveal routines ******************** */ @@ -1047,6 +1224,11 @@ public function create_slot() { public function count_bookable_appointments($studentid, $includechangeable = true) { global $DB; + // Bail when bookings are unlimited. + if ($this->allows_unlimited_bookings()) { + return -1; + } + // Find how many slots have already been booked. $sql = 'SELECT COUNT(*) FROM {scheduler_slots} s' .' JOIN {scheduler_appointment} a ON s.id = a.slotid' @@ -1064,9 +1246,7 @@ public function count_bookable_appointments($studentid, $includechangeable = tru $booked = $DB->count_records_sql($sql, $params); $allowed = $this->maxbookings; - if ($allowed == 0) { - return -1; - } else if ($booked >= $allowed) { + if ($booked >= $allowed) { return 0; } else { return $allowed - $booked; @@ -1222,6 +1402,22 @@ public function delete_appointment($appointmentid) { $slot->save(); } + /** + * Free obsolete watchers. + * + * @return void + */ + public static function free_obsolete_watchers() { + global $DB; + $sql = "DELETE FROM {scheduler_watcher} + WHERE slotid IN ( + SELECT id + FROM {scheduler_slots} + WHERE starttime <= :starttime)"; + $params = ['starttime' => time() - DAYSECS]; + $DB->execute($sql, $params); + } + /** * Frees all empty slots that are in the past, hence no longer bookable. * This applies to all schedulers in the system. diff --git a/classes/model/slot.php b/classes/model/slot.php index 3404d113..16dbff35 100644 --- a/classes/model/slot.php +++ b/classes/model/slot.php @@ -37,6 +37,12 @@ class slot extends mvc_child_record_model { */ protected $appointments; + /** @var stdClass Teacher cache. */ + protected $teacher; + + /** @var mvc_child_list The list of watchers. */ + protected $watchers; + /** * get_table * @@ -59,6 +65,7 @@ public function __construct(scheduler $scheduler) { $this->data->schedulerid = $scheduler->get_id(); $this->appointments = new mvc_child_list($this, 'scheduler_appointment', 'slotid', new appointment_factory($this)); + $this->watchers = new mvc_child_list($this, 'scheduler_watcher', 'slotid', new watcher_factory($this)); } /** @@ -74,13 +81,33 @@ public static function load_by_id($id, scheduler $scheduler) { } /** - * Save any changes to the database + * Save any changes to the database. */ public function save() { $this->data->schedulerid = $this->get_parent()->get_id(); + + // Compute whether children were removed while the slot was full, + // and thus whether we should be notifying the watchers. + $notifywatchers = false; + if ($this->get_scheduler()->is_watching_enabled() + && $this->is_watchable() + && $this->data->exclusivity > 0 + && $this->appointments->has_children_pending_deletion()) { + + $deadcount = $this->appointments->get_children_pending_deletion_count(); + $alivecount = count($this->appointments->get_children()); // We purposely don't use get_child_count. + $wasfull = $deadcount + $alivecount >= $this->data->exclusivity; + $notifywatchers = $wasfull && $alivecount < $this->data->exclusivity; + } + parent::save(); $this->appointments->save_children(); $this->update_calendar(); + + // Notify the watchers. + if ($notifywatchers) { + $this->notify_watchers(); + } } /** @@ -156,11 +183,14 @@ public function get_scheduler() { */ public function get_teacher() { global $DB; - if ($this->data->teacherid) { - return $DB->get_record('user', array('id' => $this->data->teacherid), '*', MUST_EXIST); - } else { - return new \stdClass(); + if (!isset($this->teacher) || (is_object($this->teacher) && $this->teacher->id != $this->data->teacherid)) { + $teacher = new \stdClass(); + if ($this->data->teacherid) { + $teacher = $DB->get_record('user', array('id' => $this->data->teacherid), '*', MUST_EXIST); + } + $this->teacher = $teacher; } + return $this->teacher; } /** @@ -194,6 +224,122 @@ public function is_groupslot() { return (boolean) !($this->data->exclusivity == 1); } + /** + * Add a watcher. + * + * @param int $userid The user ID. + * @return watcher|null The watcher that was added. + */ + public function add_watcher($userid) { + if ($this->is_watched_by_student($userid)) { + return; + } + $watcher = $this->watchers->create_child(); + $watcher->userid = $userid; + $this->watchers->save_children(); + return $watcher; + } + + /** + * Get the watchers. + * + * @return watcher[] + */ + public function get_watchers() { + return $this->watchers->get_children(); + } + + /** + * Notify the watchers. + * + * This does not perform any checks to see if there are availabilities in this + * slot, it is assumed that these checks were performed before. This checks that + * the watcher can book more appointments before notifying them. + * + * @return void + */ + public function notify_watchers() { + global $CFG; + if (!$this->watchers->has_children()) { + return; + } + foreach ($this->watchers->get_children() as $watcher) { + if ($this->get_scheduler()->count_bookable_appointments($watcher->userid, false) === 0) { + continue; + } + $watcher->notify(); + } + } + + /** + * Remove a watcher. + * + * @param int $userid The user ID. + * @return watcher|null The watcher that was removed. + */ + public function remove_watcher($userid) { + $watcher = null; + foreach ($this->get_watchers() as $watcher) { + if ($watcher->userid == $userid) { + break; + } + $watcher = null; + } + + if ($watcher) { + $this->watchers->remove_child($watcher); + $this->watchers->save_children(); + } + + return $watcher; + } + + /** + * Whether this slot can be watched. + * + * This only checks that the slot is setup in a way that allows + * for it to ever be watchable. It does not check whether we should be + * expecting new watchers in this current state, e.g. it does not + * check whether all appointments have been booked. + * + * @return bool + */ + public function is_watchable() { + return $this->scheduler->is_watching_enabled() && $this->is_in_bookable_period(); + } + + /** + * Whether this slot can be watched by a student. + * + * Note that this does not check the permissions of the given user. + * However it does check whether the slot is fully booked, as it is + * a requirement, but also that the student does not already have a + * booking in this slot. + * + * @param int $userid The student ID. + * @return bool + */ + public function is_watchable_by_student($userid) { + return $this->is_watchable() + && $this->count_remaining_appointments() === 0 + && !$this->is_booked_by_student($userid); + } + + /** + * Whether the slot is watched by a student. + * + * @param int $userid The student ID. + * @return bool + */ + public function is_watched_by_student($userid) { + $watchers = $this->get_watchers(); + foreach ($watchers as $watcher) { + if ($watcher->userid == $userid) { + return true; + } + } + return false; + } /** * Count the number of appointments in this slot @@ -317,6 +463,7 @@ public function remove_appointment(appointment $app) { */ public function delete() { $this->appointments->delete_children(); + $this->watchers->delete_children(); $this->clear_calendar(); $fs = get_file_storage(); $fs->delete_area_files($this->get_scheduler()->get_context()->id, 'mod_scheduler', 'slotnote', $this->get_id()); diff --git a/classes/model/watcher.php b/classes/model/watcher.php new file mode 100644 index 00000000..2ef68a22 --- /dev/null +++ b/classes/model/watcher.php @@ -0,0 +1,113 @@ +. + +/** + * Watcher. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\model; + +/** + * Watcher. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class watcher extends mvc_child_record_model { + + /** + * Get the table. + * + * @return string + */ + protected function get_table() { + return 'scheduler_watcher'; + } + + /** + * Constructor. + * + * @param slot $slot The parent slot. + */ + public function __construct(slot $slot) { + parent::__construct(); + $this->set_parent($slot); + $this->data = new \stdClass(); + $this->data->slotid = null; + $this->data->userid = null; + $this->data->notified = 0; + } + + /** + * Retrieve the slot associated with this appointment + * + * @return slot; + */ + public function get_slot() { + return $this->get_parent(); + } + + /** + * Retrieve the scheduler associated with this appointment + * + * @return scheduler + */ + public function get_scheduler() { + return $this->get_parent()->get_parent(); + } + + /** + * Get the user. + * + * @return \stdClass + */ + public function get_user() { + return \core_user::get_user($this->data->userid, '*', MUST_EXIST); + } + + /** + * Notify. + * + * @return void + */ + public function notify() { + global $CFG; + require_once($CFG->dirroot . '/mod/scheduler/mailtemplatelib.php'); + + $teacher = $this->get_slot()->get_teacher(); + $student = $this->get_user(); + $course = $this->get_scheduler()->get_courserec(); + + \scheduler_messenger::send_slot_notification($this->get_slot(), 'watchedslotopenedup', 'slotopenedup', + $teacher, $student, $teacher, $student, $course); + } + + /** + * Save. + */ + public function save() { + $this->data->slotid = $this->get_parent()->get_id(); + parent::save(); + } + +} diff --git a/classes/model/watcher_factory.php b/classes/model/watcher_factory.php new file mode 100644 index 00000000..17816b5c --- /dev/null +++ b/classes/model/watcher_factory.php @@ -0,0 +1,48 @@ +. + +/** + * Watcher factory. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\model; + +/** + * Watcher factory. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class watcher_factory extends mvc_child_model_factory { + + /** + * Create child. + * + * @param mvc_record_model $parent The parent. + * @return watcher + */ + public function create_child(mvc_record_model $parent) { + return new watcher($parent); + } + +} diff --git a/classes/output/datetime_filter.php b/classes/output/datetime_filter.php new file mode 100644 index 00000000..60837ef8 --- /dev/null +++ b/classes/output/datetime_filter.php @@ -0,0 +1,110 @@ +. + +/** + * Datetime filter. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use mod_scheduler\slots_query_builder; + +require_once($CFG->libdir . '/formslib.php'); +require_once($CFG->libdir . '/form/group.php'); + +/** + * Datetime filter. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_scheduler_output_datetime_filter extends \MoodleQuickForm_group { + + /** + * Constructor. + * + * @param string $elementname Name of the group. + * @param string $elementlabel Group label. + * @param array $elements Array of HTML_QuickForm_element elements to group. + * @param string $separator String to seperate elements.. + */ + public function __construct($elementname = null, $elementlabel = null, $elements = null, $separator = null) { + parent::__construct($elementname, $elementlabel, $elements, $separator, false); + } + + /** + * Create the elements. + * + * @return void + */ + public function _createElements() { // @codingStandardsIgnoreLine + $this->_elements = []; + + $operator = $this->createFormElement('select', $this->getName() . '[op]', '', [ + slots_query_builder::OPERATOR_AT => get_string('filterdatetimeat', 'mod_scheduler'), + slots_query_builder::OPERATOR_ON => get_string('filterdatetimeon', 'mod_scheduler'), + slots_query_builder::OPERATOR_BEFORE => get_string('filterdatetimebefore', 'mod_scheduler'), + slots_query_builder::OPERATOR_AFTER => get_string('filterdatetimeafter', 'mod_scheduler'), + ]); + $this->_elements[] = $operator; + + $datetime = $this->createFormElement('date_time_selector', $this->getName() . '[dt]', '', [ + 'optional' => true, + 'defaulttime' => strtotime('0 min 0 sec') + ]); + $this->_elements[] = $datetime; + + foreach ($this->_elements as $element) { + if (method_exists($element, 'setHiddenLabel')) { + $element->setHiddenLabel(true); + } + } + } + + /** + * Export value. + * + * @param array $submitvalues The values. + * @param bool $notused Not used. + * @return array field name => value. The value is the time interval in seconds. + */ + public function exportValue(&$submitvalues, $notused = false) { // @codingStandardsIgnoreLine + // Get the values from all the child elements. + $values = []; + foreach ($this->_elements as $element) { + $thisexport = $element->exportValue($submitvalues[$this->getName()], true); + if ($thisexport !== null && !empty($thisexport[$this->getName()])) { + $values += $thisexport[$this->getName()]; + } + } + + if (empty($values) || empty($values['dt'])) { + return [$this->getName() => null]; + } + + return [$this->getName() => $values]; + } +} + +// Auto register the element. +MoodleQuickForm::registerElementType('mod_scheduler_datetime_filter', __FILE__, 'mod_scheduler_output_datetime_filter'); diff --git a/classes/output/import_csv_form.php b/classes/output/import_csv_form.php new file mode 100644 index 00000000..66acc5a3 --- /dev/null +++ b/classes/output/import_csv_form.php @@ -0,0 +1,78 @@ +. + +/** + * Import CSV. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\output; +defined('MOODLE_INTERNAL') || die(); + +use core_text; +use csv_import_reader; +use moodleform; + +require_once($CFG->libdir . '/csvlib.class.php'); +require_once($CFG->libdir . '/formslib.php'); + +/** + * Import CSV. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_csv_form extends moodleform { + + /** + * Definition. + */ + public function definition() { + $mform = $this->_form; + + $mform->addElement('header', 'settingsheader', get_string('csvfile', 'mod_scheduler')); + + $mform->addElement('filepicker', 'file', get_string('file')); + $mform->addRule('file', null, 'required'); + $mform->addHelpButton('file', 'importslots', 'mod_scheduler'); + + $choices = csv_import_reader::get_delimiter_list(); + $mform->addElement('select', 'delimname', get_string('csvfieldseparator', 'mod_scheduler'), $choices); + if (array_key_exists('cfg', $choices)) { + $mform->setDefault('delimname', 'cfg'); + } else if (get_string('listsep', 'langconfig') == ';') { + $mform->setDefault('delimname', 'semicolon'); + } else { + $mform->setDefault('delimname', 'comma'); + } + + $choices = core_text::get_encodings(); + $mform->addElement('select', 'encoding', get_string('encoding', 'core_grades'), $choices); + $mform->setDefault('encoding', 'UTF-8'); + + $choices = ['10' => 10, '20' => 20, '100' => 100, '1000' => 1000, '10000' => 10000]; + $mform->addElement('select', 'previewrows', get_string('rowpreviewnum', 'core_grades'), $choices); + $mform->setType('previewrows', PARAM_INT); + + $this->add_action_buttons(false, get_string('continue')); + } +} diff --git a/classes/output/import_csv_options_form.php b/classes/output/import_csv_options_form.php new file mode 100644 index 00000000..b971e168 --- /dev/null +++ b/classes/output/import_csv_options_form.php @@ -0,0 +1,60 @@ +. + +/** + * Import CSV options. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\output; +defined('MOODLE_INTERNAL') || die(); + +use core_text; +use csv_import_reader; +use moodleform; + +require_once($CFG->libdir . '/csvlib.class.php'); +require_once($CFG->libdir . '/formslib.php'); + +/** + * Import CSV options. + * + * Form for setting the options during import. Presently it's only + * used for confirming that the import should be processed. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_csv_options_form extends moodleform { + + /** + * Definition. + */ + public function definition() { + $mform = $this->_form; + + $mform->addElement('hidden', 'iid'); + $mform->setType('iid', PARAM_INT); + + $this->add_action_buttons(true, get_string('importallvalidslots', 'mod_scheduler')); + } +} diff --git a/classes/output/mobile.php b/classes/output/mobile.php new file mode 100644 index 00000000..fef629eb --- /dev/null +++ b/classes/output/mobile.php @@ -0,0 +1,523 @@ +. + +/** + * Mobile renderer. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\output; + +use moodle_exception; +use mod_scheduler\external; +use mod_scheduler\slots_query_builder; +use mod_scheduler\model\scheduler; +use mod_scheduler\permission\scheduler_permissions; + +/** + * Mobile renderer. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mobile { + + /** + * Common logic for each view to call before anything else. + * + * @param array $args The view args. + * @return object + */ + protected static function pre($args) { + global $PAGE, $USER; + + $args = (object) $args; + $renderer = $PAGE->get_renderer('mod_scheduler'); + + $scheduler = scheduler::load_by_coursemodule_id($args->cmid); + require_login($scheduler->get_courseid(), true, $scheduler->get_cm()); + $permissions = new scheduler_permissions($scheduler->get_context(), $USER->id); + + return (object) ['scheduler' => $scheduler, 'permissions' => $permissions, 'renderer' => $renderer]; + } + + /** + * Get common template data. + * + * @param scheduler $scheduler The scheduler. + * @return array + */ + protected static function get_common_data(scheduler $scheduler) { + $module = (object) external::serialize_scheduler($scheduler); + return [ + 'cmid' => $scheduler->get_cmid(), + 'courseid' => $scheduler->get_courseid(), + 'scheduler' => $module, + ]; + } + + /** + * Appointment view. + * + * @param array $args The arguments. + * @return array + */ + public static function appointment($args) { + global $USER; + + $args = (object) $args; + $appid = (int) $args->id; + + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + $context = $scheduler->get_context(); + $userid = $USER->id; + + list($slot, $app) = $scheduler->get_slot_appointment($appid); + $permissions->ensure($permissions->is_teacher()); + $permissions->ensure($permissions->can_see_appointment($app)); + + $appserialized = external::serialize_appointment($app); + $gradingchoices = $renderer->grading_choices($scheduler); + $grade = $app->grade === null ? -1 : $app->grade; + + $data = array_merge(static::get_common_data($scheduler), [ + 'slot' => external::serialize_slot($slot), + 'app' => $appserialized, + 'caneditany' => $appserialized['caneditgrade'] || $appserialized['caneditattended'], + 'gradeoptions' => array_map(function($value, $key) use ($grade) { + return ['value' => $key, 'name' => $value]; + }, $gradingchoices, array_keys($gradingchoices)) + ]); + + return [ + 'templates' => [ + [ + 'id' => 'appointment', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_teacher_appointment', $data) + ] + ], + 'javascript' => '', + 'otherdata' => [ + 'grade' => $grade, + 'attended' => $app->is_attended(), + ], + 'files' => [], + ]; + } + + /** + * Book slot view. + * + * @param array $args The arguments. + * @return array + */ + public static function book_slot($args) { + global $USER; + + $args = (object) $args; + $slotid = (int) $args->id; + + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + $context = $scheduler->get_context(); + $userid = $USER->id; + + require_capability('mod/scheduler:appoint', $context); + $slot = $scheduler->get_slot($slotid); + $data = static::get_common_data($scheduler); + + if (!$data['scheduler']->isinappbookingsupported) { + throw new moodle_exception('bookingnotsupported', 'mod_scheduler'); + } else if (!$slot->is_in_bookable_period()) { + throw new moodle_exception('error'); + } + + $groups = []; + if ($scheduler->is_group_scheduling_enabled()) { + $groups = groups_get_all_groups($scheduler->courseid, $userid, $scheduler->bookingrouping, 'g.id, g.name'); + } + + $data = array_merge($data, [ + 'slot' => external::serialize_slot($slot), + 'isstudentnotesrequired' => $scheduler->is_studentnotes_required(), + 'hasgroups' => !empty($groups), + 'groups' => array_values($groups), + ]); + + return [ + 'templates' => [ + [ + 'id' => 'book_slot', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_book_slot', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Landing page. + * + * @param array $args The args. + * @return array + */ + public static function landing_page($args) { + $args = (object) $args; + + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + + if ($permissions->is_teacher()) { + return static::teacher_landing_page($args, $renderer, $scheduler, $permissions); + } else if ($permissions->is_student()) { + return static::student_landing_page($args, $renderer, $scheduler, $permissions); + } + + return [ + 'templates' => [ + [ + 'id' => 'noguests', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_noguests', []) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Slot view. + * + * @param array $args The arguments. + * @return array + */ + public static function slot($args) { + global $USER; + + $args = (object) $args; + $slotid = (int) $args->id; + + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + $context = $scheduler->get_context(); + $userid = $USER->id; + + $isstudent = $permissions->is_student(); + $isteacher = $permissions->is_teacher(); + $permissions->ensure($isstudent || $isteacher); + + $appointment = null; + $template = 'mobile_teacher_slot'; + $slot = $scheduler->get_slot($slotid); + if ($isstudent) { + $template = 'mobile_student_slot'; + $appointment = $slot->get_student_appointment($userid); + } + + $data = array_merge(static::get_common_data($scheduler), [ + 'slot' => external::serialize_slot($slot), + 'appointment' => $appointment ? external::serialize_appointment($appointment) : null, + ]); + + return [ + 'templates' => [ + [ + 'id' => $template, + 'html' => $renderer->render_from_template('mod_scheduler/' . $template, $data) + ] + ], + // Due to the transitions back to this page (and hard caches), the user would not see + // the latest content when navigating back to a page they're coming from. This method + // ensures that the view refreshes the content prior to displaying it. + 'javascript' => ' + this.ionViewWillEnter = function() { + this.refreshContent(); + } + ', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Student bookable slots. + * + * @param object $args Contains cmid and optionally page. + * @return array + */ + public static function student_bookable_slots($args) { + $args = (object) $args; + + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + $context = $scheduler->get_context(); + + $permissions->ensure($permissions->is_student()); + require_capability('mod/scheduler:viewslots', $context); + require_capability('mod/scheduler:appoint', $context); + + $page = isset($args->page) ? (int) $args->page : 1; + $result = (object) external::get_available_slots($scheduler->get_cmid(), $page); + + $data = array_merge(static::get_common_data($scheduler), [ + 'prevpage' => max($result->page - 1, 0), + 'hasnext' => $result->hasnextpage, + 'hasprev' => $result->page > 1, + 'nextpage' => $result->page + 1, + 'prevpage' => max($result->page - 1, 0), + 'slots' => $result->slots, + ]); + + return [ + 'templates' => [ + [ + 'id' => 'student_bookable_slots', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_student_bookable_slots', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Student landing page. + * + * @param object $args The original arguments. + * @param renderer_base $renderer The renderer. + * @param scheduler $scheduler The scheduler. + * @param scheduler_permissions $permissions The permissions. + * @return array + */ + public static function student_landing_page($args, $renderer, scheduler $scheduler, scheduler_permissions $permissions) { + global $USER; + + $userid = $USER->id; + $context = $scheduler->get_context(); + + require_capability('mod/scheduler:viewslots', $context); + + // Find attended slots. + $pastslotsraw = $scheduler->get_attended_slots_for_student($userid); + $pastslots = array_map(function($slot) { + return external::serialize_slot($slot); + }, $pastslotsraw); + + // Find the upcoming slots. + $upcomingslotsraw = $scheduler->get_upcoming_slots_for_student($userid); + $upcomingslots = array_map(function($slot) { + return external::serialize_slot($slot); + }, $upcomingslotsraw); + + // Display the bookable slots. + $result = (object) external::get_available_slots($scheduler->get_cmid(), 1, 10); + $nobookingsremaining = $result->bookingsremaining; + $bookableslots = $result->slots; + $totalbookableslots = $result->total; + + $nobookingmessage = ''; + if (!$nobookingsremaining) { + $nobookingmessage = get_string('canbooknofurtherappointments', 'mod_scheduler'); + } else if (!count($bookableslots)) { + $nobookingmessage = get_string('noslotsavailable', 'mod_scheduler'); + } + + $bookingmessage = ''; + if ($nobookingsremaining == 1) { + $msgkey = ($scheduler->schedulermode == 'oneonly') ? 'canbooksingleappointment' : 'canbook1appointment'; + $bookingmessage = get_string($msgkey, 'mod_scheduler'); + } else if ($nobookingsremaining > 1) { + $bookingmessage = get_string('canbooknappointments', 'mod_scheduler', $nobookingsremaining); + } else if ($nobookingsremaining < 0) { + $bookingmessage = get_string('canbookunlimitedappointments', 'mod_scheduler'); + } + + $data = array_merge(static::get_common_data($scheduler), [ + 'haspastslots' => !empty($pastslots), + 'pastslots' => $pastslots, + + 'hasupcomingslots' => !empty($upcomingslots), + 'upcomingslots' => $upcomingslots, + + 'hasbookableslots' => !empty($bookableslots), + 'hasmorebookableslots' => count($bookableslots) < $totalbookableslots, + 'bookableslots' => $bookableslots, + 'bookingmessage' => $bookingmessage, + 'nobookingmessage' => $nobookingmessage, + ]); + + return [ + 'templates' => [ + [ + 'id' => 'student_landing_page', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_student_landing_page', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Teacher landing page. + * + * @param object $args The original arguments. + * @param renderer_base $renderer The renderer. + * @param scheduler $scheduler The scheduler. + * @param scheduler_permissions $permissions The permissions. + * @return array + */ + public static function teacher_landing_page($args, $renderer, scheduler $scheduler, scheduler_permissions $permissions) { + global $USER; + + $userid = $USER->id; + $context = $scheduler->get_context(); + $permissions->ensure($permissions->is_teacher()); + + // The top most recent slots, excluding those older than 8 hours ago. + $recentslotsqb = new slots_query_builder(); + $recentslotsqb->set_teacherid($userid); + $recentslotsqb->filter_starttime(time() - 3600 * 8, slots_query_builder::OPERATOR_BETWEEN, time()); + $recentslotsqb->add_order_by('starttime', SORT_DESC); + $recentslotsqb->set_limit(3, 0); + + // Upcoming slots. + $upcomingslotsqb = new slots_query_builder(); + $upcomingslotsqb->set_teacherid($userid); + $upcomingslotsqb->filter_starttime(time(), slots_query_builder::OPERATOR_AFTER); + $upcomingslotsqb->add_order_by('starttime', SORT_ASC); + $upcomingslotsqb->set_limit(7, 0); + + $relevantslots = array_merge( + array_reverse($scheduler->get_slots_from_query_builder($recentslotsqb)), + $scheduler->get_slots_from_query_builder($upcomingslotsqb) + ); + $hasrelevantslots = !empty($relevantslots); + $slots = $relevantslots; + + // If we don't have any relevant slots, fetch some of the most recent ones. + if (!$hasrelevantslots) { + $recentslotsqb->filter_starttime(time(), slots_query_builder::OPERATOR_BEFORE); + $recentslotsqb->set_limit(10, 0); + $slots = array_reverse($scheduler->get_slots_from_query_builder($recentslotsqb)); + } + + $totalslots = $scheduler->count_slots_for_teacher($userid); + $data = array_merge(static::get_common_data($scheduler), [ + 'isrelevantslots' => $hasrelevantslots, + 'hasslots' => !empty($slots), + 'slots' => array_map('mod_scheduler\external::serialize_slot', $slots), + 'hasmore' => count($slots) < $totalslots, + ]); + + return [ + 'templates' => [ + [ + 'id' => 'teacher_landing_page', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_teacher_landing_page', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } + + /** + * Teacher slots. + * + * @param object $args The original arguments. + * @return array + */ + public static function teacher_slots($args) { + global $USER; + + $args = (object) $args; + $pre = static::pre($args); + $permissions = $pre->permissions; + $scheduler = $pre->scheduler; + $renderer = $pre->renderer; + $context = $scheduler->get_context(); + $userid = $USER->id; + + $permissions->ensure($permissions->is_teacher()); + + $page = isset($args->page) ? (int) $args->page : 0; + $perpage = 25; + + $qb = new slots_query_builder(); + $qb->set_teacherid($userid); + $qb->add_order_by('duration', SORT_ASC); + $qb->add_order_by('starttime', SORT_ASC); + $totalslots = $scheduler->count_slots_from_query_builder($qb); + + // When we were not given a page, we'll go to the first page including future slots. + if ($page <= 0) { + $page = 1; + if ($totalslots > $perpage) { + $qbpast = $qb->clone(); + $qbpast->set_timerange(slots_query_builder::TIMERANGE_PAST); + $offsetcount = $scheduler->count_slots_from_query_builder($qbpast); + $page = floor($offsetcount / $perpage) + 1; + } + } + + $qb->set_limit($perpage, ($page - 1) * $perpage); + $slots = array_map('mod_scheduler\external::serialize_slot', $scheduler->get_slots_from_query_builder($qb)); + + $data = array_merge(static::get_common_data($scheduler), [ + 'hasnext' => $totalslots > $perpage * $page, + 'hasprev' => $page > 1, + 'prevpage' => max($page - 1, 0), + 'nextpage' => $page + 1, + 'slots' => $slots + ]); + + return [ + 'templates' => [ + [ + 'id' => 'teacher_slots', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_teacher_slots', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; + } +} diff --git a/classes/output/slots_filter_form.php b/classes/output/slots_filter_form.php new file mode 100644 index 00000000..6943fc43 --- /dev/null +++ b/classes/output/slots_filter_form.php @@ -0,0 +1,134 @@ +. + +/** + * Slots filter form. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\output; +defined('MOODLE_INTERNAL') || die(); + +use core_collator; +use moodleform; +use mod_scheduler\slots_query_builder; + +require_once($CFG->libdir . '/formslib.php'); +require_once($CFG->dirroot . '/mod/scheduler/classes/output/datetime_filter.php'); + +/** + * Slots filter form. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class slots_filter_form extends moodleform { + + /** + * Definition. + */ + public function definition() { + $scheduler = $this->_customdata['scheduler']; + $hasfilters = $this->_customdata['hasfilters']; + $mform = $this->_form; + + // Header. + $mform->addElement('header', 'settingsheader', get_string('filter')); + + // Start time filter. + $mform->addElement('mod_scheduler_datetime_filter', 'tfstarttimearr', get_string('filterstarttime', 'mod_scheduler')); + $mform->addHelpButton('tfstarttimearr', 'filterstarttime', 'mod_scheduler'); + + // Location filter. + $mform->addElement('text', 'tflocation', get_string('location', 'mod_scheduler')); + $mform->setType('tflocation', PARAM_RAW); + + // Teacher filter. + if ($this->_customdata['showteacher']) { + $teacheroptions = array_map(function($user) { + return fullname($user); + }, $scheduler->get_teachers()); + core_collator::asort($teacheroptions); + $teacheroptions = [0 => get_string('choosedots')] + $teacheroptions; + $mform->addElement('select', 'tfteacherid', $scheduler->get_teacher_name(), $teacheroptions); + } + + // Add action buttons. We are not using the standard method because we want to include the + // buttons within the global fieldset. + $buttons = []; + $buttons[] = &$mform->createElement('submit', 'submitbutton', get_string('applyfilters', 'mod_scheduler')); + $buttons[] = &$mform->createElement('cancel', '', get_string('clearfilters', 'mod_scheduler')); + $mform->addGroup($buttons, 'buttonar', '', [' '], false); + + // Form customisation. + $mform->setDefault('tfstarttimearr', ['op' => slots_query_builder::OPERATOR_ON]); + $mform->disable_form_change_checker(); + if (!$hasfilters) { + $mform->setExpanded('settingsheader', false); + } + } + + /** + * Get the data. + * + * We remove the internal tfstarttimearr, and replace it with tfstarttime and tfstarttimeop. + * + * @return object + */ + public function get_data() { + $data = parent::get_data(); + if (empty($data)) { + return $data; + } + $data = (object) array_intersect_key((array) $data, ['tfstarttimearr' => 1, 'tflocation' => 1, 'tfteacherid' => 1]); + + if (!empty($data->tfstarttimearr) && !empty($data->tfstarttimearr['dt'])) { + $data->tfstarttimeop = $data->tfstarttimearr['op']; + $data->tfstarttime = $data->tfstarttimearr['dt']; + unset($data->tfstarttimearr); + } + + return $data; + } + + /** + * Set data. + * + * We convert tfstarttime (and tfstarttimeop) to tfstarttimearr if needed. + * + * @param object|array $data The data. + */ + public function set_data($data) { + $data = (array) $data; + + if (!empty($data['tfstarttime'])) { + $data['tfstarttimearr'] = [ + 'op' => !empty($data['tfstarttimeop']) ? $data['tfstarttimeop'] : slots_query_builder::OPERATOR_ON, + 'dt' => (int) $data['tfstarttime'], + ]; + unset($data['tfstarttime']); + unset($data['tfstarttimeop']); + } + + parent::set_data($data); + } +} diff --git a/classes/permission/permissions_manager.php b/classes/permission/permissions_manager.php index ce0be45c..225e23fa 100644 --- a/classes/permission/permissions_manager.php +++ b/classes/permission/permissions_manager.php @@ -98,6 +98,15 @@ public function get_context() { return $this->context; } + /** + * Get the user ID. + * + * @return int + */ + public function get_userid() { + return $this->userid; + } + /** * ensure * diff --git a/classes/permission/scheduler_permissions.php b/classes/permission/scheduler_permissions.php index b80f1043..50fb0afe 100644 --- a/classes/permission/scheduler_permissions.php +++ b/classes/permission/scheduler_permissions.php @@ -92,6 +92,15 @@ public function can_edit_all_slots() { return $this->has_capability('manageallappointments'); } + /** + * Whether the user can schedule a slot for another user. + * + * @return bool + */ + public function can_schedule_slot_to_other_teachers() { + return $this->has_capability('canscheduletootherteachers'); + } + /** * can_see_all_slots * @@ -161,4 +170,32 @@ public function can_edit_notes(\mod_scheduler\model\appointment $app) { } } + /** + * Whether the user appears to be a teacher based on their permissions. + * + * This check is only based on the permissions assigned within the scheduler + * activity and will not perform checks based on roles assigned in the course. + * + * @return bool + */ + public function is_teacher() { + $caps = ['manage', 'manageallappointments', 'canseeotherteachersbooking']; + return $this->has_any_capability($caps); + } + + /** + * Whether the user appears to be a student based on their permissions. + * + * This check is only based on the permissions assigned within the scheduler + * activity and will not perform checks based on roles assigned in the course. + * + * For our purpose, a user cannot be both a teacher and student, therefore + * the teacher role will take precedence over the student one. + * + * @return bool + */ + public function is_student() { + return !$this->is_teacher() && $this->has_capability('viewslots'); + } + } diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 490de14f..d39458e1 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -91,6 +91,15 @@ public static function get_metadata(collection $collection) : collection { 'privacy:metadata:scheduler_appointment' ); + $collection->add_database_table( + 'scheduler_watcher', + [ + 'userid' => 'privacy:metadata:scheduler_watcher:userid', + 'slotid' => 'privacy:metadata:scheduler_watcher:slotid', + ], + 'privacy:metadata:scheduler_watcher' + ); + // Subsystems used. $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose'); @@ -130,13 +139,16 @@ public static function get_contexts_for_userid(int $userid) : contextlist { INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname INNER JOIN {scheduler} s ON s.id = cm.instance INNER JOIN {scheduler_slots} t ON t.schedulerid = s.id - INNER JOIN {scheduler_appointment} a ON a.slotid = t.id - WHERE a.studentid = :userid"; + LEFT JOIN {scheduler_appointment} a ON a.slotid = t.id AND a.studentid = :userid1 + LEFT JOIN {scheduler_watcher} w ON w.slotid = t.id AND w.userid = :userid2 + WHERE a.id IS NOT NULL + OR w.id IS NOT NULL"; $params = [ 'modname' => 'scheduler', 'contextlevel' => CONTEXT_MODULE, - 'userid' => $userid + 'userid1' => $userid, + 'userid2' => $userid ]; $contextlist->add_from_sql($sql, $params); @@ -188,6 +200,20 @@ public static function get_users_in_context(userlist $userlist) { $userlist->add_from_sql('studentid', $sql, $params); + // Fetch watchers. + $sql = "SELECT w.userid + FROM {course_modules} cm + INNER JOIN {modules} m ON m.id = cm.module AND m.name = :modname + INNER JOIN {scheduler} s ON s.id = cm.instance + INNER JOIN {scheduler_slots} t ON t.schedulerid = s.id + INNER JOIN {scheduler_watcher} w ON w.slotid = t.id + WHERE cm.id = :cmid"; + $params = [ + 'modname' => 'scheduler', + 'cmid' => $context->instanceid + ]; + $userlist->add_from_sql('userid', $sql, $params); + return $userlist; } @@ -232,8 +258,6 @@ public static function export_user_data(approved_contextlist $contextlist) { return; } - self::$renderer = new \mod_scheduler_renderer(); - $user = $contextlist->get_user(); list($contextsql, $contextparams) = $DB->get_in_or_equal($contextlist->get_contextids(), SQL_PARAMS_NAMED); @@ -244,18 +268,21 @@ public static function export_user_data(approved_contextlist $contextlist) { a.studentid, a.attended, a.grade, a.appointmentnote, a.appointmentnoteformat, a.teachernote, a.teachernoteformat, - a.studentnote, a.studentnoteformat + a.studentnote, a.studentnoteformat, + w.id AS iswatcher FROM {context} ctx JOIN {course_modules} cm ON cm.id = ctx.instanceid JOIN {modules} m ON m.id = cm.module AND m.name = :modname JOIN {scheduler} s ON s.id = cm.instance JOIN {scheduler_slots} t ON t.schedulerid = s.id - JOIN {scheduler_appointment} a ON a.slotid = t.id + LEFT JOIN {scheduler_appointment} a ON a.slotid = t.id AND a.studentid =:userid2 + LEFT JOIN {scheduler_watcher} w ON w.slotid = t.id AND w.userid = :userid3 WHERE ctx.id {$contextsql} AND ctx.contextlevel = :contextlevel - AND t.teacherid = :userid1 OR a.studentid = :userid2 + AND t.teacherid = :userid1 + OR (a.id IS NOT NULL OR w.id IS NOT NULL) ORDER BY cm.id, t.id, a.id"; $rs = $DB->get_recordset_sql($sql, $contextparams + ['contextlevel' => CONTEXT_MODULE, - 'modname' => 'scheduler', 'userid1' => $user->id, 'userid2' => $user->id]); + 'modname' => 'scheduler', 'userid1' => $user->id, 'userid2' => $user->id, 'userid3' => $user->id]); $context = null; $lastrow = null; @@ -330,6 +357,7 @@ protected static function export_slot($context, $user, $record) { 'notes' => self::format_note($record->notes, $record->notesformat, 'slotnote', $record->slotid, $context, $wrc, $slotarea), 'exclusivity' => $record->exclusivity, + 'watching_slot' => transform::yesno(!empty($record->iswatcher)) ]; // Data about the slot. @@ -346,7 +374,7 @@ protected static function export_slot($context, $user, $record) { * @param \stdClass $record */ protected static function export_appointment($context, $scheduler, $user, $record) { - if (!$record) { + if (!$record || empty($record->appointmentid)) { return; } $wrc = writer::with_context($context); @@ -358,7 +386,7 @@ protected static function export_appointment($context, $scheduler, $user, $recor $data = [ 'studentid' => transform::user($record->studentid), 'attended' => transform::yesno($record->attended), - 'grade' => self::$renderer->format_grade($scheduler, $record->grade), + 'grade' => self::get_renderer()->format_grade($scheduler, $record->grade), 'appointmentnote' => self::format_note($record->appointmentnote, $record->appointmentnoteformat, 'appointmentnote', $record->appointmentid, $context, $wrc, $apparea), 'studentnote' => self::format_note($record->studentnote, $record->studentnoteformat, @@ -432,6 +460,7 @@ public static function delete_data_for_user(approved_contextlist $contextlist) { foreach ($apps as $app) { $app->delete(); } + $scheduler->delete_student_watchlist($user->id); } } } @@ -456,8 +485,25 @@ public static function delete_data_for_users(approved_userlist $userlist) { foreach ($apps as $app) { $app->delete(); } + $scheduler->delete_student_watchlist($userid); } } } + /** + * Get our renderer. + * + * We must include the file directly because autoloading does not work + * for classes placed at the root the plugin. + * + * @return \mod_scheduler_renderer + */ + private static function get_renderer() { + global $CFG; + require_once($CFG->dirroot . '/mod/scheduler/renderer.php'); + if (!isset(self::$renderer)) { + self::$renderer = new \mod_scheduler_renderer(); + } + return self::$renderer; + } } diff --git a/classes/slots_query_builder.php b/classes/slots_query_builder.php new file mode 100644 index 00000000..47b00338 --- /dev/null +++ b/classes/slots_query_builder.php @@ -0,0 +1,328 @@ +. + +/** + * Slots query builder. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler; + +use coding_exception; +use core\dml\sql_join; +use mod_scheduler\model\scheduler; + +/** + * Slots query builder. + * + * Simple builder for preparing a query of slots. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class slots_query_builder { + + /** All slots. */ + const TIMERANGE_ALL = 0; + /** Future slots. */ + const TIMERANGE_FUTURE = 1; + /** Past slots. */ + const TIMERANGE_PAST = 2; + + /** On a specific date, discarding the time. */ + const OPERATOR_ON = 0; + /** Before the date and time. */ + const OPERATOR_BEFORE = 1; + /** After the date and time. */ + const OPERATOR_AFTER = 2; + /** At the exact date and time. */ + const OPERATOR_AT = 3; + /** Between two times. */ + const OPERATOR_BETWEEN = 4; + + /** @var int|null The group ID. */ + protected $groupid = 0; + /** @var sql_join An array of joins. */ + protected $joins = []; + /** @var int Limit offset. */ + protected $limitfrom = 0; + /** @var int Limit quantity. */ + protected $limitnum = 0; + /** @var array[] Array of column names and direction. */ + protected $orderby = []; + /** @var mixed[] A list of parameters. */ + protected $params = []; + /** @var string The slot table prefix. */ + protected $prefix = ''; + /** @var int|null The teacher ID. */ + protected $teacherid = 0; + /** @var int Timerange constant. */ + protected $timerange = self::TIMERANGE_ALL; + /** @var string[] A list where conditions. */ + protected $wheres = []; + + /** + * Constructor. + * + * @param string $prefix The slot table prefix. + */ + public function __construct($prefix = 's.') { + $this->prefix = $prefix; + } + + /** + * Add order. + * + * The last call to this method sets the most significant column. + * + * @param string $field The order field. + * @param int $dir The constant SORT_ASC or SORT_DESC. + */ + public function add_order_by($field, $dir = SORT_ASC) { + $entry = [$this->prefix . $field, $dir]; + $this->orderby = array_merge([$entry], $this->orderby); + } + + /** + * Add order by teacher. + * + * @param int $dir The constant SORT_ASC or SORT_DESC. + */ + public function add_order_by_teacher($dir = SORT_ASC) { + $this->joins['sortbyteacher'] = new sql_join("JOIN {user} t ON t.id = {$this->prefix}teacherid"); + $entry = ['t.lastname', $dir]; + $this->orderby = array_merge([$entry], $this->orderby); + } + + /** + * Return a clone of the builder. + * + * @return self + */ + public function clone() { + $clone = new self($this->prefix); + $clone->groupid = $this->groupid; + $clone->joins = $this->joins; + $clone->limitfrom = $this->limitfrom; + $clone->limitnum = $this->limitnum; + $clone->orderby = $this->orderby; + $clone->teacherid = $this->teacherid; + $clone->timerange = $this->timerange; + $clone->params = $this->params; + $clone->wheres = $this->wheres; + return $clone; + } + + /** + * Filter by location. + * + * @param string $query The string to match. + * @return void + */ + public function filter_location($query) { + global $DB; + if (empty($query)) { + unset($this->wheres['filterlocation']); + unset($this->params['filterlocation']); + return; + } + $this->wheres['filterlocation'] = $DB->sql_like($this->prefix . 'appointmentlocation', ':filterlocation', false); + $this->params['filterlocation'] = '%' . $DB->sql_like_escape($query) . '%'; + } + + /** + * Filter by starttime. + * + * @param string $timestamp The timestamp. + * @param int $operator The operator constant. + * @param int $timestampend The second timestamp when OPERATOR_BETWEEN. + * @return void + */ + public function filter_starttime($timestamp, $operator = self::OPERATOR_ON, $timestampend = 0) { + $timestamp = (int) $timestamp; + $timestampend = (int) $timestampend; + + if (empty($timestamp)) { + unset($this->wheres['filterstarttime']); + unset($this->params['filterstarttime']); + unset($this->params['filterstarttimeend']); + return; + } + + $sql = '1=1'; + $params = ['filterstarttime' => $timestamp]; + + // Convert the operator ON to BETWEEN. + if ($operator === self::OPERATOR_ON) { + $operator = self::OPERATOR_BETWEEN; + $timestamp = usergetmidnight($timestamp); + $timestampend = $timestamp + DAYSECS; + } + + switch ($operator) { + case self::OPERATOR_AT: + $sql = "{$this->prefix}starttime = :filterstarttime"; + break; + case self::OPERATOR_AFTER: + $sql = "{$this->prefix}starttime > :filterstarttime"; + break; + case self::OPERATOR_BEFORE: + $sql = "{$this->prefix}starttime < :filterstarttime"; + break; + case self::OPERATOR_BETWEEN: + $sql = "{$this->prefix}starttime > :filterstarttime AND {$this->prefix}starttime < :filterstarttimeend"; + $params = [ + 'filterstarttime' => $timestamp, + 'filterstarttimeend' => $timestampend + ]; + break; + default: + throw new coding_exception('Unexpected operator'); + } + + $this->wheres['filterstarttime'] = $sql; + $this->params = array_merge($this->params, $params); + } + + /** + * Get the joins. + * + * @return string + */ + public function get_joins() { + return implode(' ', array_map(function($join) { + return $join->joins; + }, $this->joins)); + } + + /** + * Get the limit. + * + * @return array With amount and offset. + */ + public function get_limit() { + return [$this->limitnum, $this->limitfrom]; + } + + /** + * Get order by. + * + * @return string + */ + public function get_order_by() { + if (empty($this->orderby)) { + return ''; + } + return implode(', ', array_map(function($order) { + return $order[0] . ' ' . ($order[1] === SORT_ASC ? 'ASC' : 'DESC'); + }, $this->orderby)); + } + + /** + * Get where fragment. + * + * @return string + */ + public function get_where() { + $wheres = $this->wheres; + $params = $this->params; + + if ($this->teacherid) { + $wheres[] = "{$this->prefix}teacherid = :paramtid"; + $params['paramtid'] = $this->teacherid; + } + + if ($this->groupid) { + $wheres[] = "EXISTS (SELECT 1 + FROM {groups_members} gm + WHERE gm.groupid = :paramgid + AND gm.userid = {$this->prefix}teacherid)"; + $params['paramgid'] = $this->groupid; + } + + if ($this->timerange === static::TIMERANGE_PAST) { + $wheres[] = "s.starttime < :paramtimerange"; + $params['paramtimerange'] = time(); + + } else if ($this->timerange === static::TIMERANGE_FUTURE) { + $wheres[] = "s.starttime >= :paramtimerange"; + $params['paramtimerange'] = time(); + } + + foreach ($this->joins as $join) { + $where[] = '(' . $join->wheres . ')'; + $params = array_merge($params, $join->params); + } + + $where = implode(' AND ', $wheres); + return [$where, $params]; + } + + /** + * Reset the current order by. + * + * @return void + */ + public function reset_order_by() { + $this->orderby = []; + unset($this->joins['sortbyteacher']); + } + + /** + * Set the group ID. + * + * @param int $groupid The group ID. + */ + public function set_groupid($groupid) { + $this->groupid = empty($groupid) ? 0 : (int) $groupid; + } + + /** + * Set the teacher ID. + * + * @param int $teacherid The teacher ID. + */ + public function set_teacherid($teacherid) { + $this->teacherid = empty($teacherid) ? 0 : (int) $teacherid; + } + + /** + * Set the desired time range. + * + * @param int $rangetype Constant TIMERANGE_. + */ + public function set_timerange($rangetype) { + $this->timerange = $rangetype; + } + + /** + * Set the limit. + * + * @param int $limit The quantity. + * @param int $offset The offset. + */ + public function set_limit($limit, $offset = 0) { + $this->limitnum = max(0, (int) $limit); + $this->limitfrom = max(0, (int) $offset); + } + +} diff --git a/classes/task/purge_obsolete_watchers.php b/classes/task/purge_obsolete_watchers.php new file mode 100644 index 00000000..4587a7ba --- /dev/null +++ b/classes/task/purge_obsolete_watchers.php @@ -0,0 +1,54 @@ +. + +/** + * Purge obsolete watchers. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_scheduler\task; + +/** + * Purge obsolete watchers. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class purge_obsolete_watchers extends \core\task\scheduled_task { + + /** + * Get name. + * + * @return string + */ + public function get_name() { + return get_string('purgeobsoletewatchers', 'mod_scheduler'); + } + + /** + * Execute. + */ + public function execute() { + \mod_scheduler\model\scheduler::free_obsolete_watchers(); + } + +} diff --git a/db/access.php b/db/access.php index 1547f4d2..fb60af2c 100644 --- a/db/access.php +++ b/db/access.php @@ -186,7 +186,15 @@ 'coursecreator' => CAP_ALLOW, 'manager' => CAP_ALLOW ) - ) + ), + + 'mod/scheduler:watchslots' => array( + 'captype' => 'write', + 'contextlevel' => CONTEXT_MODULE, + 'archetypes' => [ + 'student' => CAP_ALLOW, + ] + ), ); diff --git a/db/install.xml b/db/install.xml index 5df0139f..71c09404 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -31,6 +31,8 @@ + + @@ -83,5 +85,18 @@ + + + + + + + + + + + + +
-
\ No newline at end of file + diff --git a/db/messages.php b/db/messages.php index 702fcf2a..33924d02 100644 --- a/db/messages.php +++ b/db/messages.php @@ -38,4 +38,12 @@ 'reminder' => array( ), + // Message sent when an appointment opened up in a slot being watched. + 'watchedslotopenedup' => [ + 'defaults' => [ + 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED, + 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_ENABLED + ] + ] + ); diff --git a/db/mobile.php b/db/mobile.php new file mode 100644 index 00000000..e604afdf --- /dev/null +++ b/db/mobile.php @@ -0,0 +1,44 @@ +. + +/** + * Mobile support. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$addons = [ + 'mod_scheduler' => [ + 'handlers' => [ + 'coursescheduler' => [ + 'displaydata' => [ + 'icon' => $CFG->wwwroot . '/mod/scheduler/pix/icon.gif', + 'class' => '' + ], + 'delegate' => 'CoreCourseModuleDelegate', + 'method' => 'landing_page', + ] + ], + 'lang' => [ + ['modulename', 'mod_scheduler'] + ] + ], +]; diff --git a/db/services.php b/db/services.php new file mode 100644 index 00000000..f690372e --- /dev/null +++ b/db/services.php @@ -0,0 +1,78 @@ +. + +/** + * Services definition. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'mod_scheduler_appointment_list_viewed' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'appointment_list_viewed', + 'description' => 'Trigger the event reporting that list of appointment was viewed', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_booking_form_viewed' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'booking_form_viewed', + 'description' => 'Trigger the event reporting that booking for was viewed', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_book_slot' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'book_slot', + 'description' => 'Book a slot', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_revoke_appointment' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'revoke_appointment', + 'description' => 'Revoke an appointment', + 'type' => 'write', + 'ajax' => true, + ], + 'mod_scheduler_unwatch_slot' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'unwatch_slot', + 'description' => 'Unwatch a slot', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_update_appointment' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'update_appointment', + 'description' => 'Update an appointment', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_watch_slot' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'watch_slot', + 'description' => 'Watch a slot', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], +]; diff --git a/db/tasks.php b/db/tasks.php index 6a7709df..b2ffc6d1 100644 --- a/db/tasks.php +++ b/db/tasks.php @@ -40,5 +40,13 @@ 'day' => '*', 'dayofweek' => '*', 'month' => '*' + ), + array( + 'classname' => 'mod_scheduler\task\purge_obsolete_watchers', + 'minute' => 'R', + 'hour' => 'R', + 'day' => '*', + 'dayofweek' => '*', + 'month' => '*' ) ); diff --git a/db/upgrade.php b/db/upgrade.php index 8dbee09d..ecb811c6 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -367,5 +367,73 @@ function xmldb_scheduler_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2022120200, 'scheduler'); } + if ($oldversion < 2023050800) { + + // Define field completionattended to be added to scheduler. + $table = new xmldb_table('scheduler'); + $field = new xmldb_field('completionattended', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'timemodified'); + + // Conditionally launch add field completionattended. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Scheduler savepoint reached. + upgrade_mod_savepoint(true, 2023050800, 'scheduler'); + } + + if ($oldversion < 2023050803) { + + // Define table scheduler_watcher to be created. + $table = new xmldb_table('scheduler_watcher'); + + // Adding fields to table scheduler_watcher. + $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null); + $table->add_field('slotid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null); + + // Adding keys to table scheduler_watcher. + $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); + + // Conditionally launch create table for scheduler_watcher. + if (!$dbman->table_exists($table)) { + $dbman->create_table($table); + } + + // Scheduler savepoint reached. + upgrade_mod_savepoint(true, 2023050803, 'scheduler'); + } + + if ($oldversion < 2023050804) { + + // Define field canwatch to be added to scheduler. + $table = new xmldb_table('scheduler'); + $field = new xmldb_field('canwatch', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'completionattended'); + + // Conditionally launch add field canwatch. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Scheduler savepoint reached. + upgrade_mod_savepoint(true, 2023050804, 'scheduler'); + } + + if ($oldversion < 2023050805) { + + // Define index slotuseridx (unique) to be added to scheduler_watcher. + $table = new xmldb_table('scheduler_watcher'); + $index = new xmldb_index('slotuseridx', XMLDB_INDEX_UNIQUE, ['slotid', 'userid']); + + // Conditionally launch add index slotuseridx. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Scheduler savepoint reached. + upgrade_mod_savepoint(true, 2023050805, 'scheduler'); + } + return true; + } diff --git a/import.php b/import.php new file mode 100644 index 00000000..2ed0351c --- /dev/null +++ b/import.php @@ -0,0 +1,214 @@ +. + +/** + * Import page. + * + * @package mod_scheduler + * @copyright 2019 Royal College of Art + * @author Frédéric Massart + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/csvlib.class.php'); + +$iid = optional_param('iid', null, PARAM_INT); + +$baseurl = new moodle_url('/mod/scheduler/view.php', ['what' => 'import', 'id' => $scheduler->cmid]); +$returnurl = new moodle_url($baseurl, ['what' => 'view', 'subaction' => '']); + +$PAGE->set_url($baseurl); +$PAGE->set_docs_path('mod/scheduler/import'); + +// Check permissions and whether we have teachers. +$permissions->ensure($permissions->can_edit_own_slots()); +if (!$scheduler->has_available_teachers()) { + throw new moodle_exception('needteachers', 'scheduler', $returnurl); +} + +// While we don't yet have a valid file. +if (empty($iid)) { + $csvform = new mod_scheduler\output\import_csv_form($baseurl->out(false)); + if ($csvform->is_cancelled()) { + redirect($returnurl); + } + + $formdata = $csvform->get_data(); + if ($formdata) { + core_php_time_limit::raise(); + raise_memory_limit(MEMORY_EXTRA); + + $iid = csv_import_reader::get_new_iid('importslots'); + $cir = new csv_import_reader($iid, 'importslots'); + + $content = $csvform->get_file_content('file'); + $cir->load_csv_content($content, $formdata->encoding, $formdata->delimname); + $importer = new mod_scheduler\csv_slots_importer($scheduler, $permissions, $cir); + + $errors = $importer->validate_csv(); + if (!empty($errors)) { + $errorkey = array_keys($errors)[0]; + $error = reset($errors); + throw new moodle_exception($errorkey, 'core_error', $baseurl, $error); + } + + $table = new flexible_table('import-slot-preview'); + $table->define_baseurl($baseurl); + $table->define_columns(['indicator', 'lineno', 'starttime', 'duration', 'teacher', 'errors']); + $table->define_headers([ + '', + get_string('csvline', 'mod_scheduler'), + get_string('field-starttime', 'mod_scheduler'), + get_string('duration', 'mod_scheduler'), + get_string('teacher', 'mod_scheduler'), + get_string('errors', 'mod_scheduler'), + ]); + + echo $OUTPUT->header(); + echo $output->teacherview_tabs($scheduler, $permissions, $baseurl, 'import'); + echo $output->heading(get_string('importslots', 'mod_scheduler'), 2); + echo $output->heading(get_string('preview'), 3); + $table->setup(); + + $i = 0; + foreach ($importer as $lineno => $info) { + if ($i++ >= $formdata->previewrows) { + break; + } + + $valid = empty($info->errors); + $data = $info->data; + $table->add_data([ + $OUTPUT->pix_icon($valid ? 'i/valid' : 'i/invalid', ''), + $info->lineno, + userdate($data->starttime->getTimestamp(), get_string('strftimedatetime', 'langconfig')), + get_string('numminutes', 'core', $data->duration), + $data->teacher ? fullname($data->teacher) : '', + !$valid ? implode(' ', $info->errors) : '' + ]); + } + + $table->finish_output(); + + $optsform = new mod_scheduler\output\import_csv_options_form($baseurl->out(false)); + $optsform->set_data((object) ['iid' => $iid]); + + echo $OUTPUT->heading(get_string('confirm'), 3); + $optsform->display(); + echo $OUTPUT->footer(); + exit(); + } + + if (!$formdata) { + echo $OUTPUT->header(); + echo $output->teacherview_tabs($scheduler, $permissions, $baseurl, 'import'); + echo $OUTPUT->heading_with_help(get_string('importslots', 'mod_scheduler'), 'importslots', 'mod_scheduler'); + echo html_writer::tag('div', markdown_to_html(get_string('importslotsintro', 'mod_scheduler', [ + 'exampleurl' => (new moodle_url('/mod/scheduler/tests/fixtures/slots.csv'))->out(false) + ]))); + $csvform->display(); + echo $output->footer(); + exit(); + } + +} else { + + core_php_time_limit::raise(); + raise_memory_limit(MEMORY_EXTRA); + $cir = new csv_import_reader($iid, 'importslots'); + + // We've got an import ID. + $optsform = new mod_scheduler\output\import_csv_options_form($baseurl->out(false)); + if ($data = $optsform->get_data()) { + + $importer = new mod_scheduler\csv_slots_importer($scheduler, $permissions, $cir); + $errors = $importer->validate_csv(); + if (!empty($errors)) { + $errorkey = array_keys($errors)[0]; + $error = reset($errors); + throw new moodle_exception($errorkey, 'core_error', $baseurl, $error); + } + + $imported = 0; + $errors = []; + + foreach ($importer as $info) { + $valid = empty($info->errors); + if (!$valid) { + $errors[$info->lineno] = implode(' ', $info->errors); + continue; + } + try { + $slot = $importer->make_slot_from_processed_line($info); + $slot->save(); + } catch (moodle_exception $e) { + $errors[$info->lineno] = $e->getMessage(); + continue; + } + + $imported++; + + // Avoid error event from being bothered by these missing properties. In the future, + // we should make it so that the slot factory adds all the required properties on + // the object when creating a blank one. We could not do this by fetching the record + // from the database, but that seems to be an unecessary performance drain. + $slot->reuse = 0; + $slot->emaildate = 0; + \mod_scheduler\event\slot_added::create_from_slot($slot)->trigger(); + } + + echo $OUTPUT->header(); + echo $output->teacherview_tabs($scheduler, $permissions, $baseurl, 'import'); + echo $output->heading(get_string('importslots', 'mod_scheduler'), 2); + echo $output->heading(get_string('results', 'mod_scheduler'), 3); + + echo html_writer::start_tag('ul'); + echo html_writer::tag('li', get_string('nslotsimported', 'mod_scheduler', $imported)); + echo html_writer::tag('li', get_string('nslotswitherror', 'mod_scheduler', count($errors))); + echo html_writer::end_tag('ul'); + + if (!empty($errors)) { + $table = new flexible_table('import-slots-errors'); + $table->define_baseurl($baseurl); + $table->define_columns(['indicator', 'lineno', 'errors']); + $table->define_headers([ + '', + get_string('csvline', 'mod_scheduler'), + get_string('errors', 'mod_scheduler'), + ]); + $table->setup(); + + echo $OUTPUT->heading(get_string('errors', 'mod_scheduler'), 3); + foreach ($errors as $lineno => $error) { + $table->add_data([$OUTPUT->pix_icon('i/invalid', ''), $lineno, $error]); + } + $table->finish_output(); + } + + echo $OUTPUT->single_button($returnurl, get_string('continue'), 'get'); + echo $OUTPUT->footer(); + + $cir->cleanup(); + exit(); + } + + // Right now we should not get here unless the form was cancelled. + $cir->cleanup(); + redirect($baseurl); +} + diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 3b2b19db..aa2604f6 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -51,6 +51,7 @@ $string['scheduler:editallattended'] = 'Mark students in all appointments as attended / not attended.'; $string['scheduler:editallgrades'] = 'Edit grades in all appointments.'; $string['scheduler:editallnotes'] = 'Edit appointment notes in all appointments.'; +$string['scheduler:watchslots'] = 'Watch slots'; /* ***** Events ***** */ $string['event_bookingformviewed'] = 'Scheduler booking form viewed'; @@ -59,11 +60,14 @@ $string['event_appointmentlistviewed'] = 'Scheduler appointment list viewed'; $string['event_slotadded'] = 'Scheduler slot added'; $string['event_slotdeleted'] = 'Scheduler slot deleted'; +$string['event_slotunwatched'] = 'Scheduler slot unwatched'; +$string['event_slotwatched'] = 'Scheduler slot watched'; /* ***** Message types ***** */ $string['messageprovider:invitation'] = 'Invitation to book a slot'; $string['messageprovider:bookingnotification'] = 'Notification when a booking is made or cancelled'; $string['messageprovider:reminder'] = 'Reminder of an upcoming appointment'; +$string['messageprovider:watchedslotopenedup'] = 'Notification when an appointment becomes available in a watched slot'; /* ***** Search areas ***** */ $string['search:activity'] = 'Scheduler - activity information'; @@ -92,6 +96,10 @@ $string['privacy:metadata:scheduler_appointment:studentnote'] = "Note by student"; $string['privacy:metadata:scheduler_appointment:studentnoteformat'] = "Format of student note"; +$string['privacy:metadata:scheduler_watcher'] = "Represents a student watching a slot"; +$string['privacy:metadata:scheduler_watcher:userid'] = "Student who watching the slot"; +$string['privacy:metadata:scheduler_watcher:slotid'] = "The slot being watched"; + $string['privacy:metadata:filepurpose'] = 'File used in notes for the slot or appointment'; @@ -99,6 +107,10 @@ $string['onedaybefore'] = '1 day before slot'; $string['oneweekbefore'] = '1 week before slot'; +$string['allowwatching'] = 'Allow watching'; +$string['allowwatching_help'] = 'When watching is allowed, students can watch slots that are full to be notified when an appointment becomes available. However note that watching is not possible when the activity enforces booking in groups. + +This option can only work by displaying full slots to students, it will therefore ignore the students\' permission to view full slots, and always assume that they can view them.'; $string['areaappointmentnote'] = 'Files in appointment notes'; $string['areaslotnote'] = 'Files in slot notes'; $string['areateachernote'] = 'Files in confidential notes'; @@ -114,7 +126,9 @@ $string['allappointments'] = 'All appointments'; $string['allononepage'] = 'All slots on one page'; $string['allowgroup'] = 'Exclusive slot - click to change'; +$string['alreadybookedbyyou'] = 'This slot has already been booked for you.'; $string['alreadyappointed'] = 'Cannot make the appointment. The slot is already fully booked.'; +$string['applyfilters'] = 'Apply filters'; $string['appointfor'] = 'Make appointment for'; $string['appointforgroup'] = 'Make appointments for: {$a}'; $string['appointingstudent'] = 'Appointment for slot'; @@ -122,6 +136,7 @@ $string['appointment'] = 'Appointment'; $string['appointmentno'] = 'Appointment {$a}'; $string['appointmentnote'] = 'Notes for appointment (visible to student)'; +$string['appointmentnotes'] = 'Appointment notes'; $string['appointments'] = 'Appointments'; $string['appointmentsgrouped'] = 'Appointments grouped by slot'; $string['appointsolo'] = 'just me'; @@ -139,6 +154,7 @@ $string['bookingformoptions'] = 'Booking form and student-supplied data'; $string['bookinginstructions'] = 'Booking instructions'; $string['bookinginstructions_help'] = 'This text will be displayed to students before they make a booking. It can, for example, instruct students how to fill out the optional message field or which files to upload.'; +$string['bookingnotsupported'] = 'Booking not supported, please use the online version.'; $string['bookslot'] = 'Book slot'; $string['bookaslot'] = 'Book a slot'; $string['bookingdetails'] = 'Booking details'; @@ -151,8 +167,12 @@ $string['canbooknappointments'] = 'You can book {$a} more appointments in this scheduler.'; $string['canbooknofurtherappointments'] = 'You cannot book further appointments in this scheduler.'; $string['canbookunlimitedappointments'] = 'You can book any number of appointments in this scheduler.'; +$string['cannotscheduleslotforothers'] = 'You cannot schedule appointments for other staff members.'; +$string['cannotwatchmoreslots'] = 'You cannot watch more slots at this time.'; $string['chooseexisting'] = 'Choose existing'; +$string['choosegrouptobook'] = 'Please select a group to assign the booking to.'; $string['choosingslotstart'] = 'Choosing the start time'; +$string['clearfilters'] = 'Clear filters'; $string['comments'] = 'Comments'; $string['conflictlocal'] = '{$a->datetime} ({$a->duration} minutes) in this scheduler'; $string['conflictremote'] = '{$a->datetime} ({$a->duration} minutes) in course {$a->courseshortname}, scheduler {$a->schedulername}'; @@ -179,6 +199,9 @@ You can explore the effect of these options using the "Preview" button.

'; $string['complete'] = 'Booked'; +$string['completionattended'] = 'Require attended'; +$string['completionattended_desc'] = 'Students must be marked as seen'; +$string['completionattended_help'] = 'When enabled, a student must be marked as having attended an appointment for this requirement to be met.'; $string['confirmbooking'] = "Confirm booking"; $string['confirmdelete-all'] = 'This will delete all slots in this scheduler. Deletion cannot be undone. Continue anyway?'; $string['confirmdelete-mine'] = 'This will delete all your slots in this scheduler. Deletion cannot be undone. Continue anyway?'; @@ -187,12 +210,17 @@ $string['confirmdelete-one'] = 'Delete slot?'; $string['confirmdelete-unused'] = 'This will delete all unused slots in this scheduler. Deletion cannot be undone. Continue anyway?'; $string['confirmrevoke'] = 'Revoke all appointments in the current slot?'; +$string['confirmsinglerevoke'] = 'Would you like to revoke this appointment?'; $string['conflictingslots'] = 'The slot on {$a} cannot be created due to conflicting slots:'; $string['copytomyself'] = 'Send a copy to myself'; +$string['couldnotresolveteacher'] = 'Could not resolve the teacher to an existing user account.'; $string['course'] = 'Course'; $string['createexport'] = 'Create export file'; +$string['csvfile'] = 'CSV file'; $string['csvformat'] = 'CSV'; $string['csvfieldseparator'] = 'Field separator for CSV'; +$string['csvline'] = 'Line'; +$string['csvmissingcolumns'] = 'The following columns are missing: {$a}.'; $string['cumulatedduration'] = 'Summed duration of appointments'; $string['datatoinclude'] = 'Data to include'; $string['datatoinclude_help'] = 'Select the fields that should be included in the export. Each of these will appear in one column of the output file.'; @@ -221,6 +249,7 @@ $string['emailreminderondate'] = 'Email a reminder on'; $string['end'] = 'End'; $string['enddate'] = 'Repeat time slots until'; +$string['errors'] = 'Errors'; $string['excelformat'] = 'Excel'; $string['exclusive'] = 'Exclusive'; $string['exclusivity'] = 'Exclusivity'; @@ -266,12 +295,17 @@ which can be printed using the browser\'s print feature;
  • PDF documents. You can choose between landscape and portrait orientation.
  • '; +$string['filterdatetimeafter'] = 'After'; +$string['filterdatetimeat'] = 'At'; +$string['filterdatetimebefore'] = 'Before'; +$string['filterdatetimeon'] = 'On'; $string['finalgrade'] = 'Final grade'; $string['firstslotavailable'] = 'The first slot will be open on: {$a}'; $string['forbidgroup'] = 'Group slot - click to change'; $string['forcewhenoverlap'] = 'Force when overlap'; $string['forcourses'] = 'Choose students in courses'; $string['friday'] = 'Friday'; +$string['full'] = 'Full'; $string['generalconfig'] = 'General configuration'; $string['grade'] = 'Grade'; $string['gradeingradebook'] = 'Grade in gradebook'; @@ -300,8 +334,31 @@ $string['includeemptyslots'] = 'Include empty slots'; $string['includeslotsfor'] = 'Include slots for'; $string['incourse'] = ' in course '; +$string['invalidorpastdate'] = 'The date could not be parsed, or is in the past.'; +$string['invalidteacher'] = 'Invalid teacher provided, they are not a teacher.'; $string['mixindivgroup'] = 'Mix individual and group bookings'; $string['mixindivgroup_desc'] = 'Where group scheduling is enabled, allow individual bookings as well.'; +$string['import'] = 'Import'; +$string['importslots'] = 'Import slots'; +$string['importslots_help'] = ' +Slots can be imported from a CSV file, containing the following __mandatory__ columns: + +- date: The date at which the slot starts +- time: The time of the day at which the slots starts +- duration: The duration of the slots, in minutes + +The following columns are also supported: + +- maxstudents: The maximum number of students in the slot. Use 1 for exclusive, or 0 for unlimited. +- location: The location of the appointment. +- teacher: The username of the Moodle account of the teacher. An empty value defaults to the current user. +- displayfrom: The date from which the slot will be visible. +- comment: Notes to be attached to the slot. The Markdown format is supported. + +The dates can be expressed in either of these formats: MM/DD/YYYY, YYYY-MM-DD or DD-MM-YYYY. The time can be expressed in 12h or 24h form: 2:00pm or 14:00. +'; +$string['importslotsintro'] = 'Please provide a CSV matching the required format, an example file can be downloaded [here]({$a->exampleurl}).'; +$string['importallvalidslots'] = 'Import all valid slots'; $string['introduction'] = 'Introduction'; $string['isnonexclusive'] = 'Non-exclusive'; $string['landscape'] = 'Landscape'; @@ -311,6 +368,8 @@ $string['markseen'] = 'After you have had an appointment with a student please mark them as "Seen" by clicking the checkbox near to their user picture above.'; $string['markasseennow'] = 'Mark as seen now'; $string['maxgrade'] = 'Take the highest grade'; +$string['maxslotswatched'] = 'Maximum number of slots watched'; +$string['maxslotswatched_desc'] = 'The maximum number of slots a student can watch per scheduler activity. When set to 0, students can watch an unlimited number of slots.'; $string['maxstudentsperslot'] = 'Maximum number of students per slot'; $string['maxstudentsperslot_desc'] = 'Group slots / non-exclusive slots can have at most this number of students. Note that in addition, the setting "unlimited" can always be chosen for a slot.'; $string['maxstudentlistsize'] = 'Maximum length of student list'; @@ -332,6 +391,7 @@ $string['modeoneonly'] = 'in this scheduler'; $string['modeoneatatime'] = 'at a time'; $string['monday'] = 'Monday'; +$string['mostrecentslots'] = 'Most recent slots'; $string['multiple'] = '(multiple)'; $string['myappointments'] = 'My appointments'; $string['myself'] = 'Myself'; @@ -342,6 +402,7 @@ $string['never'] = 'Never'; $string['nfiles'] = '{$a} files'; $string['noappointments'] = 'No appointments'; +$string['noappointmentsyet'] = 'You do not have any appointments in this scheduler yet.'; $string['noexistingstudents'] = 'No students available for scheduling'; $string['nogroups'] = 'No group available for scheduling.'; $string['noresults'] = 'No results. '; @@ -359,6 +420,8 @@ $string['notifications'] = 'Notifications'; $string['notseen'] = 'Not seen'; $string['now'] = 'Now'; +$string['nslotsimported'] = '{$a} slot(s) imported'; +$string['nslotswitherror'] = '{$a} slot(s) with error'; $string['occurrences'] = 'Occurrences'; $string['odsformat'] = 'ODS'; $string['on'] = 'on'; @@ -382,12 +445,15 @@ $string['portrait'] = 'Portrait'; $string['preview'] = 'Preview'; $string['previewlimited'] = '(Preview is limited to {$a} rows.)'; +$string['purgeobsoletewatchers'] = 'Purge obsolete watchers'; $string['purgeunusedslots'] = 'Purge unused slots in the past'; +$string['recentandupcomingslots'] = 'Recent and upcoming slots'; $string['recipients'] = 'Recipients'; $string['registeredlbl'] = 'Student appointed'; $string['reminder'] = 'Reminder'; $string['requireupload'] = 'File upload required'; $string['resetslots'] = 'Delete scheduler slots'; +$string['results'] = 'Results'; $string['resetappointments'] = 'Delete appointments and grades'; $string['revealteachernotes'] = 'Reveal teacher notes in privacy exports'; $string['revealteachernotes_desc'] = 'If this option is selected, then confidential teacher notes (which are normally not visible to students) @@ -437,6 +503,10 @@ $string['staffbreakdown'] = 'By {$a}'; $string['staffrolename'] = 'Role name of the teacher'; $string['start'] = 'Start'; +$string['filterstarttime'] = 'Start time'; +$string['filterstarttime_help'] = 'Filter slots based on their starting date. + +Note that the "On" operator is the only one that ignores the time of the day, using this operator will select all slots occurring on a particular day.'; $string['startpast'] = 'You can\'t start an empty appointment slot in the past'; $string['statistics'] = 'Statistics'; $string['student'] = 'Student'; @@ -446,6 +516,7 @@ $string['studentfiles'] = 'Uploaded files'; $string['studentmultiselect'] = 'Each student can be selected only once in this slot'; $string['studentnote'] = 'Message by student'; +$string['studentnotemissing'] = 'The message from the student is missing.'; $string['students'] = 'Students'; $string['studentprovided'] = 'Student provided: {$a}'; $string['sunday'] = 'Sunday'; @@ -467,6 +538,7 @@ $string['unattended'] = 'Unattended'; $string['unlimited'] = 'Unlimited'; $string['unregisteredlbl'] = 'Unappointed students'; +$string['unwatchslot'] = 'Unwatch slot'; $string['upcomingslots'] = 'Upcoming slots'; $string['updategrades'] = 'Update grades'; $string['updatesingleslot'] = ''; @@ -493,10 +565,18 @@ $string['usestudentnotes'] = 'Let students enter a message'; $string['usestudentnotes_help'] = 'If enabled, the booking screen will contain a text box in which students can enter a message. Use the "booking instructions" above to instruct students what information they should supply.'; $string['viewbooking'] = 'See details'; +$string['viewmoreoptions'] = 'View more options'; +$string['visitwebtoaddnotes'] = 'Please visit the website to add notes.'; +$string['visitwebtobook'] = 'Visit the website to book this slot'; +$string['visitwebtoeditcancel'] = 'Please visit the website if you wish to make changes to the booking.'; +$string['visitwebtouploadfiles'] = 'Please visit the website to attach files to your booking.'; +$string['watchslotsintro'] = 'To be notified when a fully booked slot becomes available, click the "Watch slot" button for that corresponding slot.'; +$string['watchslotsintromax'] = 'To be notified when a fully booked slot becomes available, click the "Watch slot" button for that corresponding slot. Note that you cannot watch more than {$a} slot(s) at any given time.'; $string['wednesday'] = 'Wednesday'; $string['welcomebackstudent'] = 'You can book additional slots by clicking on the corresponding "Book slot" button below.'; $string['welcomenewstudent'] = 'The table below shows all available slots for an appointment. Make your choice by clicking on the corresponding "Book slot" button. If you need to make a change later you can revisit this page.'; $string['welcomenewteacher'] = 'Please click on the button below to add appointment slots.'; +$string['watchslot'] = 'Watch slot'; $string['what'] = 'What?'; $string['whathappened'] = 'What happened?'; $string['whatresulted'] = 'What resulted?'; @@ -605,6 +685,13 @@ Location: {$a->location}'; +$string['email_slotopenedup_html'] = '

    An appointment has become available for a slot on {$a->date} at {$a->time} with the {$a->staffrole} {$a->attendant}.

    +

    This is regarding the activity titled "{$a->module}", in the course "{$a->course_short}: {$a->course}" on the website "{$a->site}".

    '; +$string['email_slotopenedup_plain'] = 'An appointment has become available for a slot on {$a->date} at {$a->time} with the {$a->staffrole} {$a->attendant}. + +This is regarding the activity titled "{$a->module}", in the course "{$a->course_short}: {$a->course}" on the website "{$a->site}".'; +$string['email_slotopenedup_subject'] = '{$a->course_short}: A slot became available'; + $string['email_reminder_html'] = '

    You have an upcoming appointment on {$a->date} from {$a->time} to {$a->endtime}
    with {$a->attendant}.

    diff --git a/lib.php b/lib.php index 907ef63b..3a7b3f2a 100644 --- a/lib.php +++ b/lib.php @@ -367,6 +367,8 @@ function scheduler_supports($feature) { return true; case FEATURE_MOD_INTRO: return true; + case FEATURE_COMPLETION_HAS_RULES: + return true; case FEATURE_COMPLETION_TRACKS_VIEWS: return false; case FEATURE_GRADE_HAS_GRADE: @@ -757,3 +759,25 @@ function mod_scheduler_core_calendar_provide_event_action(calendar_event $event, ); } +/** + * Obtains the completion state. + * + * @param object $course The course. + * @param object $cm The course module. + * @param int $userid The user ID. + * @param bool $type The type of comparison (COMPLETION_AND or _OR), or the default return value. + */ +function scheduler_get_completion_state($course, $cm, $userid, $type) { + global $DB; + $result = $type; + + $scheduler = scheduler::load_by_id($cm->instance); + + // Check whether the user has been seen. + if ($scheduler->completion_requires_attended()) { + $hasattended = $scheduler->has_user_attended_any_slot($userid); + $result = $type == COMPLETION_AND ? $result && $hasattended : $result || $hasattended; + } + + return $result; +} diff --git a/locallib.php b/locallib.php index 60ce8232..2b0a8551 100644 --- a/locallib.php +++ b/locallib.php @@ -27,6 +27,8 @@ require_once($CFG->libdir.'/filelib.php'); require_once(dirname(__FILE__).'/customlib.php'); +use mod_scheduler\model\scheduler; + /* Events related functions */ @@ -336,3 +338,129 @@ public function get_parent() { return $this->browser->get_file_info($this->context); } } + +/** + * Get the upload options for student files. + * + * @param scheduler $scheduler The scheduler. + * @return array + */ +function mod_scheduler_get_student_upload_options(scheduler $scheduler) { + return ['subdirs' => 0, 'maxbytes' => $scheduler->uploadmaxsize, 'maxfiles' => $scheduler->uploadmaxfiles]; +} + +/** + * Book a slot. + * + * @param scheduler $scheduler The scheduler. + * @param int $slotid The slot ID. + * @param int $userid The user ID. + * @param int $groupid The group ID, or 0. + * @param mixed $formdata The form data from {@see scheduler_booking_form}. + * @throws mixed moodle_exception + */ +function mod_scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $formdata) { + global $DB; + + $slot = $scheduler->get_slot($slotid); + if (!$slot) { + throw new moodle_exception('error'); + } + + if (!$slot->is_in_bookable_period()) { + throw new moodle_exception('nopermissions'); + } + + $requiredcapacity = 1; + $userstobook = array($userid); + if ($groupid > 0) { + if (!$scheduler->is_group_scheduling_enabled()) { + throw new moodle_exception('error'); + } + $groupmembers = $scheduler->get_available_students($groupid); + $requiredcapacity = count($groupmembers); + $userstobook = array_keys($groupmembers); + } else if ($groupid == 0) { + if (!$scheduler->is_individual_scheduling_enabled()) { + throw new moodle_exception('error'); + } + } else { + // Group scheduling enabled but no group selected. + throw new moodle_exception('error'); + } + + $errormessage = ''; + + $bookinglimit = $scheduler->count_bookable_appointments($userid, false); + if ($bookinglimit == 0) { + throw new moodle_exception('selectedtoomany', 'mod_scheduler', null, $bookinglimit); + + } else { + // Validate our user ids. + $existingstudents = array(); + foreach ($slot->get_appointments() as $app) { + $existingstudents[] = $app->studentid; + } + $userstobook = array_diff($userstobook, $existingstudents); + + $remaining = $slot->count_remaining_appointments(); + // If the slot is already overcrowded... + if ($remaining >= 0 && $remaining < $requiredcapacity) { + if ($requiredcapacity > 1) { + throw new moodle_exception('notenoughplaces', 'mod_scheduler'); + } else { + throw new moodle_exception('slot_is_just_in_use', 'mod_scheduler'); + } + } + } + + // Create new appointment for each member of the group. + foreach ($userstobook as $studentid) { + $appointment = $slot->create_appointment(); + $appointment->studentid = $studentid; + $appointment->attended = 0; + $appointment->timecreated = time(); + $appointment->timemodified = time(); + $appointment->save(); + + if ($studentid == $userid && $formdata) { + mod_scheduler_save_booking_data($appointment, $formdata); + } + + \mod_scheduler\event\booking_added::create_from_slot($slot)->trigger(); + + // Notify the teacher. + if ($scheduler->allownotifications) { + $student = $DB->get_record('user', array('id' => $appointment->studentid), '*', MUST_EXIST); + $teacher = $DB->get_record('user', array('id' => $slot->teacherid), '*', MUST_EXIST); + scheduler_messenger::send_slot_notification($slot, 'bookingnotification', 'applied', + $student, $teacher, $teacher, $student, $scheduler->get_courserec()); + } + } + + $slot->save(); +} + +/** + * Save the booking data. + * + * @param appointment $appointment The appointment. + * @param object $formdata The form data. + * @return void + */ +function mod_scheduler_save_booking_data($appointment, $formdata) { + $scheduler = $appointment->get_scheduler(); + + if ($scheduler->uses_studentnotes() && isset($formdata->studentnote_editor)) { + $editor = $formdata->studentnote_editor; + $appointment->studentnote = $editor['text']; + $appointment->studentnoteformat = $editor['format']; + } + + if ($scheduler->uses_studentfiles() && !empty($formdata->studentfiles)) { + file_save_draft_area_files($formdata->studentfiles, $scheduler->context->id, 'mod_scheduler', + 'studentfiles', $appointment->id, mod_scheduler_get_student_upload_options($scheduler)); + } + + $appointment->save(); +} diff --git a/mod_form.php b/mod_form.php index 90dcfbf3..831540aa 100644 --- a/mod_form.php +++ b/mod_form.php @@ -122,6 +122,13 @@ public function definition() { $mform->addElement('select', 'usenotes', get_string('usenotes', 'scheduler'), $noteoptions); $mform->setDefault('usenotes', '1'); + $mform->addElement('selectyesno', 'canwatch', get_string('allowwatching', 'scheduler')); + $mform->addHelpButton('canwatch', 'allowwatching', 'scheduler'); + // Disable canwatch when we cannot group bookings is enforced. + if (!get_config('mod_scheduler', 'mixindivgroup')) { + $mform->disabledIf('canwatch', 'bookingrouping', 'neq', '-1'); + } + // Grade settings. $this->standard_grading_coursemodule_elements(); @@ -191,6 +198,32 @@ public function definition() { $this->add_action_buttons(); } + /** + * Add custom completion rules. + * + * @return array Of element names. + */ + public function add_completion_rules() { + $mform =& $this->_form; + + $mform->addElement('checkbox', 'completionattended', get_string('completionattended', 'mod_scheduler'), + get_string('completionattended_desc', 'mod_scheduler')); + $mform->addHelpButton('completionattended', 'completionattended', 'mod_scheduler'); + + return ['completionattended']; + } + + + /** + * Whether any custom completion rule is enabled. + * + * @param array $data Form data. + * @return bool + */ + public function completion_rule_enabled($data) { + return !empty($data['completionattended']); + } + /** * Allows module to modify data returned by get_moduleinfo_data() or prepare_new_moduleinfo_data() before calling set_data() * This method is also called in the bulk activity completion form. @@ -220,6 +253,18 @@ public function data_preprocessing(&$defaultvalues) { } } + /** + * Post processing. + * + * @param stdClass $data passed by reference + */ + public function data_postprocessing($data) { + // Force watching to be disabled when it would not be working. + if (!get_config('mod_scheduler', 'mixindivgroup') && !empty($data->groupbookings)) { + $data->canwatch = 0; + } + } + /** * save_mod_data * diff --git a/renderable.php b/renderable.php index dfd6d318..9f806c8d 100644 --- a/renderable.php +++ b/renderable.php @@ -257,8 +257,11 @@ class scheduler_slot_booker implements renderable { * @param bool $bookedbyme whether the slot is already booked by the current student * @param string $groupinfo information about group slots * @param array $otherstudents other students in this slot + * @param bool $canwatch Whether the user can watch this slot + * @param bool $iswatching Whether the user is currently watching the slot. */ - public function add_slot(slot $slotmodel, $canbook, $bookedbyme, $groupinfo, $otherstudents) { + public function add_slot(slot $slotmodel, $canbook, $bookedbyme, $groupinfo, $otherstudents, + $canwatch = false, $iswatching = false) { $slot = new stdClass(); $slot->slotid = $slotmodel->id; $slot->starttime = $slotmodel->starttime; @@ -271,6 +274,8 @@ public function add_slot(slot $slotmodel, $canbook, $bookedbyme, $groupinfo, $ot $slot->groupinfo = $groupinfo; $slot->teacher = $slotmodel->get_teacher(); $slot->otherstudents = $otherstudents; + $slot->canwatch = $canwatch; + $slot->iswatching = $iswatching; $this->slots[] = $slot; } @@ -389,6 +394,15 @@ class scheduler_slot_manager implements renderable { */ public $showteacher = true; + /** @var bool Whether we can sort slots. */ + public $sortable = true; + + /** @var string The column we're sorting on. */ + public $sortcolumn = null; + + /** @var int The direction we're sorting on. */ + public $sortdir = null; + /** * Add a slot to the list. * diff --git a/renderer.php b/renderer.php index fc9bca14..a45eef81 100644 --- a/renderer.php +++ b/renderer.php @@ -109,6 +109,24 @@ public static function slotdatetime($slotdate, $duration) { */ protected $scalecache = array(); + /** + * Get the sort URL for a column. + * + * @param scheduler_slot_manager $slotman The manager. + * @param string $column The column. + * @return moodle_url + */ + public function get_slot_manager_sort_url(scheduler_slot_manager $slotman, $column) { + $params = [ + 'offset' => -1, + 'tsort' => $column + ]; + if ($slotman->sortcolumn === $column && !empty($slotman->sortdir)) { + $params['tdir'] = $slotman->sortdir > 0 ? -1 : 1; + } + return new moodle_url($slotman->actionurl, $params); + } + /** * Get a list of levels in a grading scale. * @@ -368,6 +386,7 @@ public function teacherview_tabs(scheduler $scheduler, scheduler_permissions $pe $level1[] = $this->teacherview_tab($baseurl, 'datelist', 'datelist'); $level1[] = $statstab; $level1[] = $this->teacherview_tab($baseurl, 'export', 'export'); + $level1[] = $this->teacherview_tab($baseurl, 'import', 'import'); return $this->tabtree($level1, $selected, $inactive); } @@ -569,6 +588,15 @@ public function render_scheduler_student_list(scheduler_student_list $studentlis $studicons .= $this->render($attachicon); } + if ($editable && count($studentlist->students) > 1) { + $studicons .= $this->action_icon( + '#', + new pix_icon('s/no', get_string('revoke', 'scheduler')), + null, + ['class' => 'action-icon revoke-student'] + ); + } + if ($student->highlight) { $class .= ' highlight'; } @@ -577,7 +605,9 @@ public function render_scheduler_student_list(scheduler_student_list $studentlis if ($studentlist->showgrades && $student->grade) { $grade = $this->format_grade($studentlist->scheduler, $student->grade, true); } - $o .= html_writer::div($checkbox . $picture . ' ' . $name . $studicons . ' ' . $grade, $class); + $o .= html_writer::div($checkbox . $picture . ' ' . $name . $studicons . ' ' . $grade, $class, [ + 'data-appointmentid' => $student->entryid + ]); } if ($editable) { @@ -664,6 +694,13 @@ public function render_scheduler_slot_booker(scheduler_slot_booker $booker) { $bookurl = new moodle_url($booker->actionurl, array('what' => $bookaction, 'slotid' => $slot->slotid)); $button = new single_button($bookurl, get_string('bookslot', 'scheduler')); $rowdata[] = $this->render($button); + } else if ($slot->canwatch) { + $what = $slot->iswatching ? 'unwatchslot' : 'watchslot'; + $bookurl = new moodle_url($booker->actionurl, ['slotid' => $slot->slotid, + 'what' => $what, + ]); + $button = new single_button($bookurl, get_string($what, 'mod_scheduler')); + $rowdata[] = $this->render($button); } else { $rowdata[] = ''; } @@ -710,15 +747,36 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { $this->page->requires->yui_module('moodle-mod_scheduler-saveseen', 'M.mod_scheduler.saveseen.init', array($slotman->scheduler->cmid) ); + $this->page->requires->js_call_amd('mod_scheduler/revoke', 'init', ['#slotmanager', $slotman->scheduler->cmid]); $o = ''; + $ascicon = $this->pix_icon('t/sort_asc', get_string('asc')); + $descicon = $this->pix_icon('t/sort_desc', get_string('desc')); + + $headerdate = get_string('date', 'scheduler'); + $headerlocation = get_string('location', 'scheduler'); + $headerteacher = s($slotman->scheduler->get_teacher_name()); + if ($slotman->sortable) { + $url = $this->get_slot_manager_sort_url($slotman, 'starttime'); + $icon = $slotman->sortcolumn === 'starttime' ? $slotman->sortdir < 0 ? $descicon : $ascicon : ''; + $headerdate = html_writer::link($url, $headerdate) . $icon; + + $url = $this->get_slot_manager_sort_url($slotman, 'location'); + $icon = $slotman->sortcolumn === 'location' ? $slotman->sortdir < 0 ? $descicon : $ascicon : ''; + $headerlocation = html_writer::link($url, $headerlocation) . $icon; + + $url = $this->get_slot_manager_sort_url($slotman, 'teacher'); + $icon = $slotman->sortcolumn === 'teacher' ? $slotman->sortdir < 0 ? $descicon : $ascicon : ''; + $headerteacher = html_writer::link($url, $headerteacher) . $icon; + } + $table = new html_table(); - $table->head = array('', get_string('date', 'scheduler'), get_string('start', 'scheduler'), - get_string('end', 'scheduler'), get_string('location', 'scheduler'), get_string('students', 'scheduler') ); + $table->head = array('', $headerdate, get_string('start', 'scheduler'), + get_string('end', 'scheduler'), $headerlocation, get_string('students', 'scheduler') ); $table->align = array ('center', 'left', 'left', 'left', 'left', 'left'); if ($slotman->showteacher) { - $table->head[] = s($slotman->scheduler->get_teacher_name()); + $table->head[] = $headerteacher; $table->align[] = 'left'; } $table->head[] = get_string('action', 'scheduler'); diff --git a/settings.php b/settings.php index b2fee38b..ca894a38 100644 --- a/settings.php +++ b/settings.php @@ -48,6 +48,11 @@ get_string('maxstudentlistsize_desc', 'scheduler'), 200, PARAM_INT)); + $settings->add(new admin_setting_configtext('mod_scheduler/maxslotswatched', + get_string('maxslotswatched', 'mod_scheduler'), + get_string('maxslotswatched_desc', 'mod_scheduler'), + 3, PARAM_INT)); + $settings->add(new admin_setting_configtext('mod_scheduler/uploadmaxfiles', get_string('uploadmaxfilesglobal', 'scheduler'), get_string('uploadmaxfilesglobal_desc', 'scheduler'), diff --git a/studentview.controller.php b/studentview.controller.php index 1f0b5f06..1de0d0bb 100644 --- a/studentview.controller.php +++ b/studentview.controller.php @@ -39,91 +39,12 @@ * @throws mixed moodle_exception */ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $formdata, $returnurl) { - - global $DB, $COURSE, $output; - - $slot = $scheduler->get_slot($slotid); - if (!$slot) { - throw new moodle_exception('error'); - } - - if (!$slot->is_in_bookable_period()) { - throw new moodle_exception('nopermissions'); - } - - $requiredcapacity = 1; - $userstobook = array($userid); - if ($groupid > 0) { - if (!$scheduler->is_group_scheduling_enabled()) { - throw new moodle_exception('error'); - } - $groupmembers = $scheduler->get_available_students($groupid); - $requiredcapacity = count($groupmembers); - $userstobook = array_keys($groupmembers); - } else if ($groupid == 0) { - if (!$scheduler->is_individual_scheduling_enabled()) { - throw new moodle_exception('error'); - } - } else { - // Group scheduling enabled but no group selected. - throw new moodle_exception('error'); - } - - $errormessage = ''; - - $bookinglimit = $scheduler->count_bookable_appointments($userid, false); - if ($bookinglimit == 0) { - $errormessage = get_string('selectedtoomany', 'scheduler', $bookinglimit); - } else { - // Validate our user ids. - $existingstudents = array(); - foreach ($slot->get_appointments() as $app) { - $existingstudents[] = $app->studentid; - } - $userstobook = array_diff($userstobook, $existingstudents); - - $remaining = $slot->count_remaining_appointments(); - // If the slot is already overcrowded... - if ($remaining >= 0 && $remaining < $requiredcapacity) { - if ($requiredcapacity > 1) { - $errormessage = get_string('notenoughplaces', 'scheduler'); - } else { - $errormessage = get_string('slot_is_just_in_use', 'scheduler'); - } - } - } - - if ($errormessage) { - \core\notification::error($errormessage); - redirect($returnurl); - } - - // Create new appointment for each member of the group. - foreach ($userstobook as $studentid) { - $appointment = $slot->create_appointment(); - $appointment->studentid = $studentid; - $appointment->attended = 0; - $appointment->timecreated = time(); - $appointment->timemodified = time(); - $appointment->save(); - - if (($studentid == $userid) && $mform) { - $mform->save_booking_data($formdata, $appointment); - } - - \mod_scheduler\event\booking_added::create_from_slot($slot)->trigger(); - - // Notify the teacher. - if ($scheduler->allownotifications) { - $student = $DB->get_record('user', array('id' => $appointment->studentid), '*', MUST_EXIST); - $teacher = $DB->get_record('user', array('id' => $slot->teacherid), '*', MUST_EXIST); - scheduler_messenger::send_slot_notification($slot, 'bookingnotification', 'applied', - $student, $teacher, $teacher, $student, $COURSE); - } + try { + mod_scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $formdata); + } catch (moodle_exception $e) { + \core\notification::error($e->getMessage()); } - $slot->save(); redirect($returnurl); - } $returnurlparas = array('id' => $cm->id); @@ -192,6 +113,46 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo scheduler_book_slot($scheduler, $slotid, $USER->id, $appointgroup, null, null, $returnurl); } +/************************************************ Watching slots ************************************************/ + +if ($action == 'watchslot') { + require_sesskey(); + require_capability('mod/scheduler:watchslots', $context); + + if (!$scheduler->is_watching_enabled()) { + throw new moodle_exception('error'); + } + + $slotid = required_param('slotid', PARAM_INT); + $slot = $scheduler->get_slot($slotid); + if (!$slot) { + throw new moodle_exception('error'); + } else if (!$slot->is_watchable_by_student($USER->id)) { + throw new moodle_exception('nopermissions'); + } + + $watcher = $slot->add_watcher($USER->id); + \mod_scheduler\event\slot_watched::create_from_watcher($watcher)->trigger(); + redirect($returnurl); +} + +if ($action == 'unwatchslot') { + require_sesskey(); + require_capability('mod/scheduler:watchslots', $context); + $slotid = required_param('slotid', PARAM_INT); + + $slot = $scheduler->get_slot($slotid); + if (!$slot) { + throw new moodle_exception('error'); + } + + $watcher = $slot->remove_watcher($USER->id); + if ($watcher) { + \mod_scheduler\event\slot_unwatched::create_from_watcher($watcher)->trigger(); + } + redirect($returnurl); +} + /******************************************** Show details of booking *******************************************/ if ($action == 'viewbooking') { @@ -248,7 +209,7 @@ function scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $mform, $fo if ($mform->is_cancelled()) { redirect($returnurl); } else if ($formdata = $mform->get_data()) { - $mform->save_booking_data($formdata, $appointment); + mod_scheduler_save_booking_data($appointment, $formdata); redirect($returnurl); } else { echo $output->header(); diff --git a/studentview.php b/studentview.php index 3095caff..60498064 100644 --- a/studentview.php +++ b/studentview.php @@ -44,6 +44,7 @@ require_capability('mod/scheduler:viewslots', $context); $canbook = has_capability('mod/scheduler:appoint', $context); $canseefull = has_capability('mod/scheduler:viewfullslots', $context); +$canwatch = has_capability('mod/scheduler:watchslots', $context); if ($scheduler->is_group_scheduling_enabled()) { $mygroupsforscheduling = groups_get_all_groups($scheduler->courseid, $USER->id, $scheduler->bookingrouping, 'g.id, g.name'); @@ -58,6 +59,9 @@ $appointgroup = 0; } +if (!$scheduler->is_watching_enabled()) { + $canwatch = false; +} require_once($CFG->dirroot.'/mod/scheduler/studentview.controller.php'); @@ -161,7 +165,10 @@ } $bookablecnt = $scheduler->count_bookable_appointments($USER->id, false); -$bookableslots = array_values($scheduler->get_slots_available_to_student($USER->id, $canseefull)); +$canbookslots = $canbook && $bookablecnt != 0; +$canwatchslots = $canwatch && $canbookslots && !$appointgroup; +$canwatchmoreslots = $canwatchslots && $scheduler->student_can_watch_more_slots($USER->id); +$bookableslots = array_values($scheduler->get_slots_available_to_student($USER->id, $canseefull || $canwatchslots)); if (!$canseefull && $bookablecnt == 0) { echo html_writer::div(get_string('canbooknofurtherappointments', 'scheduler'), 'studentbookingmessage'); @@ -188,7 +195,7 @@ for ($idx = $start; $idx < $end; $idx++) { $slot = $bookableslots[$idx]; - $canbookthisslot = $canbook && ($bookablecnt != 0); + $canbookthisslot = $canbookslots; if (has_capability('mod/scheduler:seeotherstudentsbooking', $context)) { $others = new scheduler_student_list($scheduler, false); @@ -211,12 +218,15 @@ if ($remaining > 0) { $groupinfo = get_string('limited', 'scheduler', $remaining.'/'.$slot->exclusivity); } else { // Group info should not be visible to students. - $groupinfo = get_string('complete', 'scheduler'); + $groupinfo = get_string('full', 'scheduler'); $canbookthisslot = false; } } - $booker->add_slot($slot, $canbookthisslot, false, $groupinfo, $others); + $isslotwatchable = $slot->is_watchable_by_student($USER->id); + $iswatching = $isslotwatchable && $slot->is_watched_by_student($USER->id); + $canwatchthisslot = ($canwatchmoreslots && $isslotwatchable) || $iswatching; + $booker->add_slot($slot, $canbookthisslot, false, $groupinfo, $others, $canwatchthisslot, $iswatching); } @@ -248,6 +258,15 @@ echo $output->paging_bar($total, $offset, $pagesize, $actionurl, 'offset'); } + if ($canwatchslots) { + $maxwatched = $scheduler->get_maximum_slots_watched(); + if (!$maxwatched) { + echo html_writer::tag('p', get_string('watchslotsintro', 'mod_scheduler')); + } else { + echo html_writer::tag('p', get_string('watchslotsintromax', 'mod_scheduler', $maxwatched)); + } + } + } echo $output->footer(); diff --git a/styles.css b/styles.css index 9dba784e..0646bc5d 100644 --- a/styles.css +++ b/styles.css @@ -10,6 +10,18 @@ font-weight: bold; } +.path-mod-scheduler div.otherstudent a + .icon, +.path-mod-scheduler div.otherstudent a + .action-icon { + margin-left: .5rem; +} + +.path-mod-scheduler div.otherstudent .revoke-student { + display: none; +} +.path-mod-scheduler.jsenabled div.otherstudent .revoke-student { + display: inline; +} + .path-mod-scheduler div.slotnotes { background-color: #e8e9ee; border: solid 1px #a7abbe; diff --git a/teacherview.controller.php b/teacherview.controller.php index 6cdb092e..49946618 100644 --- a/teacherview.controller.php +++ b/teacherview.controller.php @@ -251,7 +251,6 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return $slotid = required_param('slotid', PARAM_INT); $slot = $scheduler->get_slot($slotid); $seen = optional_param_array('seen', array(), PARAM_INT); - if (is_array($seen)) { foreach ($slot->get_appointments() as $app) { $permissions->ensure($permissions->can_edit_attended($app)); @@ -340,8 +339,7 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return case 'markasseennow': $permissions->ensure($permissions->can_edit_own_slots()); - $slot = new stdClass(); - $slot->schedulerid = $scheduler->id; + $slot = $scheduler->create_slot(); $slot->teacherid = $USER->id; $slot->starttime = time(); $slot->duration = $scheduler->defaultslotduration; @@ -352,10 +350,8 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return $slot->appointmentlocation = ''; $slot->emaildate = 0; $slot->timemodified = time(); - $slotid = $DB->insert_record('scheduler_slots', $slot); - $appointment = new stdClass(); - $appointment->slotid = $slotid; + $appointment = $slot->create_appointment(); $appointment->studentid = required_param('studentid', PARAM_INT); $appointment->attended = 1; $appointment->appointmentnote = ''; @@ -364,9 +360,9 @@ function scheduler_action_delete_slots(array $slots, $action, moodle_url $return $appointment->teachernoteformat = FORMAT_HTML; $appointment->timecreated = time(); $appointment->timemodified = time(); - $DB->insert_record('scheduler_appointment', $appointment); - $slot = $scheduler->get_slot($slotid); + $slot->save(); + $slot = $scheduler->get_slot($slot->id); \mod_scheduler\event\slot_added::create_from_slot($slot)->trigger(); redirect($viewurl); diff --git a/teacherview.php b/teacherview.php index d4e7549d..df4cc28d 100644 --- a/teacherview.php +++ b/teacherview.php @@ -26,6 +26,13 @@ use \mod_scheduler\model\scheduler; +$tsort = optional_param('tsort', null, PARAM_ALPHA); +$tdir = optional_param('tdir', null, PARAM_INT); +$tfstarttime = optional_param('tfstarttime', null, PARAM_INT); +$tfstarttimeop = optional_param('tfstarttimeop', null, PARAM_INT); +$tflocation = optional_param('tflocation', null, PARAM_RAW); +$tfteacherid = optional_param('tfteacherid', null, PARAM_INT); + /** * Print a selection box of existing slots to be scheduler in * @@ -131,6 +138,44 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid 'offset' => $offset )); +// Save the sorting in the URL of the page. +if ($tsort !== null) { + $baseurl->param('tsort', $tsort); +} +if ($tdir !== null) { + $baseurl->param('tdir', $tdir); +} + +// Collect the filters received. +$filters = array_filter([ + 'tfstarttime' => $tfstarttime, + 'tflocation' => $tflocation, + 'tfteacherid' => $tfteacherid, +]); +$hasfilters = !empty($filters); +if (!empty($tfstarttime)) { + $filters['tfstarttimeop'] = $tfstarttimeop; +} + +// Display and process the filter form. +$filterform = new mod_scheduler\output\slots_filter_form($baseurl, ['scheduler' => $scheduler, + 'hasfilters' => $hasfilters, 'showteacher' => $subpage === 'allappointments']); +$filterform->set_data($filters); +if ($data = $filterform->get_data()) { + foreach ($data as $key => $value) { + $baseurl->param($key, $value); + } + $baseurl->param('offset', 0); // Force the first page. + redirect($baseurl); // Redirect to keep everything in URL. +} else if ($filterform->is_cancelled()) { + redirect($baseurl); +} + +// Save filters in the URL. +foreach ($filters as $key => $value) { + $baseurl->param($key, $value); +} + // The URL that is used for jumping back to the view (e.g., after an action is performed). $viewurl = new moodle_url($baseurl, array('what' => 'view')); @@ -383,7 +428,6 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid /****************** Standard view ***********************************************/ - // Trigger view event. \mod_scheduler\event\appointment_list_viewed::create_from_scheduler($scheduler)->trigger(); @@ -422,7 +466,6 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid } } - if ($subpage == 'allappointments') { $teacherid = 0; $slotgroup = $currentgroup; @@ -431,12 +474,61 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $slotgroup = 0; $subpage = 'myappointments'; } -$sqlcount = $scheduler->count_slots_for_teacher($teacherid, $slotgroup); + +$qb = $scheduler->get_slots_query_builder(); +$qb->set_teacherid($teacherid); +$qb->set_groupid($slotgroup); + +// Organise order by. +if (empty($tsort)) { + $tsort = 'starttime'; + $tdir = 1; + $qb->add_order_by('teacherid'); + $qb->add_order_by('duration'); + $qb->add_order_by('starttime'); +} else { + $tdir = $tdir !== -1 ? 1 : -1; + if (!in_array($tsort, ['starttime', 'location', 'teacher'])) { + $tsort = 'starttime'; + } + $dir = $tdir === -1 ? SORT_DESC : SORT_ASC; + if ($tsort === 'starttime') { + $qb->add_order_by('duration', $dir); + $qb->add_order_by('starttime', $dir); + } else if ($tsort === 'location') { + $qb->add_order_by('duration'); + $qb->add_order_by('starttime'); + $qb->add_order_by('appointmentlocation', $dir); + } else if ($tsort === 'teacher') { + $qb->add_order_by('duration'); + $qb->add_order_by('starttime'); + $qb->add_order_by_teacher($dir); + } + unset($dir); +} + +// Organise filters. +if (!empty($filters)) { + if (!empty($filters['tflocation'])) { + $qb->filter_location($filters['tflocation']); + } + if (!empty($filters['tfteacherid']) && $permissions->can_see_all_slots() && $subpage === 'allappointments') { + $qb->set_teacherid($filters['tfteacherid']); + } + if (!empty($filters['tfstarttime'])) { + $qb->filter_starttime($filters['tfstarttime'], $filters['tfstarttimeop']); + } +} + +$sqlcount = $scheduler->count_slots_from_query_builder($qb); $pagesize = 25; if ($offset == -1) { + // When the user lands on the page, navigate to the most relevant page first. if ($sqlcount > $pagesize) { - $offsetcount = $scheduler->count_slots_for_teacher($teacherid, $slotgroup, true); + $qbpast = $qb->clone(); + $qbpast->set_timerange(mod_scheduler\slots_query_builder::TIMERANGE_PAST); + $offsetcount = $scheduler->count_slots_from_query_builder($qbpast); $offset = floor($offsetcount / $pagesize); } else { $offset = 0; @@ -446,10 +538,14 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $offset = floor(($sqlcount - 1) / $pagesize); } -$slots = $scheduler->get_slots_for_teacher($teacherid, $slotgroup, $offset * $pagesize, $pagesize); +$qb->set_limit($pagesize, $offset * $pagesize); +$slots = $scheduler->get_slots_from_query_builder($qb); echo $output->heading(get_string('slots', 'scheduler')); +// Print filter form. +$filterform->display(); + // Print instructions and button for creating slots. $key = ($slots) ? 'addslot' : 'welcomenewteacher'; echo html_writer::div(get_string($key, 'scheduler')); @@ -501,6 +597,8 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $slotman = new scheduler_slot_manager($scheduler, $actionurl); $slotman->showteacher = ($subpage == 'allappointments'); + $slotman->sortcolumn = $tsort; + $slotman->sortdir = $tdir; foreach ($slots as $slot) { diff --git a/templates/mobile_book_slot.mustache b/templates/mobile_book_slot.mustache new file mode 100644 index 00000000..399a29ed --- /dev/null +++ b/templates/mobile_book_slot.mustache @@ -0,0 +1,68 @@ +{{=<% %>=}} + +
    + + + +

    <% slot.timeformatted.date %>

    +

    <% slot.timeformatted.starttime %> - <% slot.timeformatted.endtime %>

    +
    + + <%# scheduler.hasbookinginstructions %> + +

    <%# str %>bookinginstructions, mod_scheduler<%/ str %>

    + +
    + <%/ scheduler.hasbookinginstructions %> + + <%# scheduler.isgroupbookingenabled %> + + +

    <%# str %>appointfor, mod_scheduler<%/ str %>

    +
    + + <%# scheduler.isindividualbookingenabled %> + <%# str %>myself, mod_scheduler<%/ str %> + <%/ scheduler.isindividualbookingenabled %> + <%# groups %> + <% name %> + <%/ groups %> + +
    + <%/ scheduler.isgroupbookingenabled %> + + <%# scheduler.usesstudentnotes %> + + +

    [core-mark-required]="true"<%/ isstudentnotesrequired %> + > + <%# str %>yourstudentnote, mod_scheduler<%/ str %> +

    +
    + +
    + <%/ scheduler.usesstudentnotes %> + + + + + + + + + +

    <%# str %>visitwebtouploadfiles, mod_scheduler, <% scheduler.weburl %><%/ str %>

    +
    + +
    +
    diff --git a/templates/mobile_noguests.mustache b/templates/mobile_noguests.mustache new file mode 100644 index 00000000..6a75eecc --- /dev/null +++ b/templates/mobile_noguests.mustache @@ -0,0 +1,8 @@ +{{=<% %>=}} +
    + + +

    <%# str %>guestscantdoanything, mod_scheduler<%/ str %>

    +
    +
    +
    diff --git a/templates/mobile_student_bookable_slots.mustache b/templates/mobile_student_bookable_slots.mustache new file mode 100644 index 00000000..ab8e815b --- /dev/null +++ b/templates/mobile_student_bookable_slots.mustache @@ -0,0 +1,38 @@ +{{=<% %>=}} + +
    + + + <%#hasprev%> + + + + <%/hasprev%> + <%#slots%> + + <%/slots%> + <%#hasnext%> + + + + <%/hasnext%> + + + + +
    diff --git a/templates/mobile_student_landing_page.mustache b/templates/mobile_student_landing_page.mustache new file mode 100644 index 00000000..471c77b9 --- /dev/null +++ b/templates/mobile_student_landing_page.mustache @@ -0,0 +1,75 @@ +{{=<% %>=}} + + + + + +
    + + <%# haspastslots %> +

    <%# str %>attendedslots, mod_scheduler<%/ str %>

    + + <%#pastslots%> + + <%/pastslots%> + + <%/ haspastslots %> + + <%# hasupcomingslots %> +

    <%# str %>upcomingslots, mod_scheduler<%/ str %>

    + + <%#upcomingslots%> + + <%/upcomingslots%> + + <%/ hasupcomingslots %> + + <%^ hasbookableslots %> + <%# nobookingmessage %> +

    <% . %>

    + <%/ nobookingmessage %> + <%/ hasbookableslots %> + + <%# hasbookableslots %> +

    <%# str %>availableslots, mod_scheduler<%/ str %>

    + <%# bookingmessage %> +

    <% . %>

    + <%/ bookingmessage %> + + <%#bookableslots%> + + <%/bookableslots%> + <%#hasmorebookableslots%> + + + + <%/hasmorebookableslots%> + + <%/ hasbookableslots %> + + + +
    diff --git a/templates/mobile_student_slot.mustache b/templates/mobile_student_slot.mustache new file mode 100644 index 00000000..3c7b711a --- /dev/null +++ b/templates/mobile_student_slot.mustache @@ -0,0 +1,151 @@ +{{=<% %>=}} + +
    + + + +

    <% slot.timeformatted.date %>

    +

    <% slot.timeformatted.starttime %> - <% slot.timeformatted.endtime %>

    +
    + + +

    <% scheduler.teachername %>

    +

    <% slot.teacher.fullname %>

    +
    + + <%# slot.hasappointmentlocation %> + +

    <%# str %>location, mod_scheduler<%/ str %>

    +

    <% slot.appointmentlocation %>

    +
    + <%/ slot.hasappointmentlocation %> + + <%# slot.hasnotes %> + +

    <%# str %>comments, mod_scheduler<%/ str %>

    + +
    + <%/ slot.hasnotes %> + + <%# appointment %> + + <%# hasappointmentnote %> + +

    <%# str %>appointmentnotes, mod_scheduler<%/ str %>

    + +
    + <%/ hasappointmentnote %> + + <%# hasstudentnote %> + +

    <%# str %>yourstudentnote, mod_scheduler<%/ str %>

    + +
    + <%/ hasstudentnote %> + + <%# scheduler.usesgrades %> + +

    <%# str %>grade, mod_scheduler<%/ str %>

    +

    <% gradeformatted %>

    +
    + <%/ scheduler.usesgrades %> + + <%^ slot.isexclusive %> + <%# slot.hasappointments %> + + <%# str %>students, mod_scheduler<%/ str %> + + <%# slot.appointments %> + + +

    <% student.fullname %>

    +
    + <%/ slot.appointments %> + <%/ slot.hasappointments %> + <%/ slot.isexclusive %> + + <%# slot.iseditable %> + +

    <%# str %>visitwebtoeditcancel, mod_scheduler, <% scheduler.weburl %><%/ str %>

    +
    + <%/ slot.iseditable %> + + <%/ appointment %> + + <%^ appointment %> + <%# slot.canbookslot %> + <%^ scheduler.isinappbookingsupported %> + + + <%# str %>visitwebtobook, mod_scheduler<%/ str %> + + + <%/ scheduler.isinappbookingsupported %> + <%# scheduler.isinappbookingsupported %> + + <%# scheduler.usesbookingform %> + + <%/ scheduler.usesbookingform %> + <%^ scheduler.usesbookingform %> + <%# scheduler.isgroupbookingenabled %> + + + <%/ scheduler.isgroupbookingenabled %> + <%^ scheduler.isgroupbookingenabled %> + + <%/ scheduler.isgroupbookingenabled %> + <%/ scheduler.usesbookingform %> + + <%/ scheduler.isinappbookingsupported %> + <%/ slot.canbookslot %> + + <%# slot.canwatchslot %> + <%^ slot.iswatching %> + + + + <%/ slot.iswatching %> + <%# slot.iswatching %> + + + + <%/ slot.iswatching %> + <%/ slot.canwatchslot %> + <%/ appointment %> + +
    +
    diff --git a/templates/mobile_teacher_appointment.mustache b/templates/mobile_teacher_appointment.mustache new file mode 100644 index 00000000..e8df332a --- /dev/null +++ b/templates/mobile_teacher_appointment.mustache @@ -0,0 +1,105 @@ +{{=<% %>=}} + +
    + + +

    <% slot.timeformatted.date %>

    +

    <% slot.timeformatted.starttime %> - <% slot.timeformatted.endtime %>

    +
    + + + +

    <% app.student.fullname %>

    +
    + + <%# app.hasstudentnote %> + +

    <%# str %>studentnote, mod_scheduler<%/ str %>

    + +
    + <%/ app.hasstudentnote %> + + <%# app.hasstudentfiles %> + +

    <%# str %>studentfiles, mod_scheduler<%/ str %>

    +
    + <%# app.studentfiles %> + + <%/ app.studentfiles %> +
    +
    + <%/ app.hasstudentfiles %> + + <%# app.hasappointmentnote %> + +

    <%# str %>appointmentnote, mod_scheduler<%/ str %>

    + +
    + <%/ app.hasappointmentnote %> + + <%# app.hasteachernote %> + +

    <%# str %>teachernote, mod_scheduler<%/ str %>

    + +
    + <%/ app.hasteachernote %> + +
    + + + + +

    <%# str %>attended, mod_scheduler<%/ str %>

    +
    + disabled="true"<%/ app.caneditattended %> + > +
    + + <%# scheduler.usesgrades %> + + +

    <%# str %>grade, mod_scheduler<%/ str %>

    +
    + disabled="true"<%/ app.caneditgrade %> + aria-labelledby="mod-scheduler-app-grade"> + <%# gradeoptions %> + <% name %> + <%/ gradeoptions %> + +
    + <%/ scheduler.usesgrades %> + + + + + + <%# caneditany %> + + + + <%/ caneditany %> + + <%# app.caneditnotes %> + +

    <%# str %>visitwebtoaddnotes, mod_scheduler, <% app.weburl %><%/ str %>

    +
    + <%/ app.caneditnotes %> +
    + +
    diff --git a/templates/mobile_teacher_landing_page.mustache b/templates/mobile_teacher_landing_page.mustache new file mode 100644 index 00000000..a3629cde --- /dev/null +++ b/templates/mobile_teacher_landing_page.mustache @@ -0,0 +1,55 @@ +{{=<% %>=}} + + + +
    + + <%^ hasslots %> +

    <%# str %>noappointmentsyet, mod_scheduler<%/ str %>

    + <%/ hasslots %> + + <%# hasslots %> +

    + <%# isrelevantslots %> + <%# str %>recentandupcomingslots, mod_scheduler<%/ str %> + <%/ isrelevantslots %> + <%^ isrelevantslots %> + <%# str %>mostrecentslots, mod_scheduler<%/ str %> + <%/ isrelevantslots %> +

    + + <%^ hasslots %> +

    <%# str %>noappointments, mod_scheduler<%/ str %>

    + <%/ hasslots %> + <%# hasslots %> + <%# slots %> + + <%/ slots %> + <%# hasmore %> + + + + <%/ hasmore %> + <%/ hasslots %> +
    + <%/ hasslots %> + + + +
    diff --git a/templates/mobile_teacher_slot.mustache b/templates/mobile_teacher_slot.mustache new file mode 100644 index 00000000..7d220002 --- /dev/null +++ b/templates/mobile_teacher_slot.mustache @@ -0,0 +1,51 @@ +{{=<% %>=}} + +
    + + +

    <% slot.timeformatted.date %>

    +

    <% slot.timeformatted.starttime %> - <% slot.timeformatted.endtime %>

    +
    + + +

    <% scheduler.teachername %>

    +

    <% slot.teacher.fullname %>

    +
    + + <%# slot.hasappointmentlocation %> + +

    <%# str %>location, mod_scheduler<%/ str %>

    +

    <% slot.appointmentlocation %>

    +
    + <%/ slot.hasappointmentlocation %> + + <%# slot.hasnotes %> + +

    <%# str %>comments, mod_scheduler<%/ str %>

    + +
    + <%/ slot.hasnotes %> + + <%# slot.hasappointments %> + + <%# str %>students, mod_scheduler<%/ str %> + + + <%# slot.appointments %> + + +

    + <% student.fullname %> + <%# hasstudentdata %><%# pix %>attachment, mod_scheduler<%/ pix %><%/ hasstudentdata %> +

    + <%# isattended %> + + + + <%/ isattended %> +
    + <%/ slot.appointments %> + <%/ slot.hasappointments %> + +
    +
    diff --git a/templates/mobile_teacher_slots.mustache b/templates/mobile_teacher_slots.mustache new file mode 100644 index 00000000..02ddf2ce --- /dev/null +++ b/templates/mobile_teacher_slots.mustache @@ -0,0 +1,40 @@ +{{=<% %>=}} + +
    + + <%#hasprev%> + + + + <%/hasprev%> + <%#slots%> + + <%/slots%> + <%#hasnext%> + + + + <%/hasnext%> + + + + +
    diff --git a/tests/fixtures/slots.csv b/tests/fixtures/slots.csv new file mode 100644 index 00000000..b0df9037 --- /dev/null +++ b/tests/fixtures/slots.csv @@ -0,0 +1,4 @@ +date,time,duration,maxstudents,location,teacher,displayfrom,comment +2020-02-19,17:00,15,0,Office 101,username1,2020-02-18,"A 15 min slot starting at 5pm on Feb 19th 2020, teacher’s username is username1, an unlimited number of students can register." +19-02-2020,10:00,30,1,Meeting Room A,,18-02-2020,A 30 min slot starting at 10am on Feb 19th 2020 allowing a single student. Teacher will be the user importing the slots. +2/19/2020,2:15pm,60,10,Online,staff123,2/18/2020,A 1h slot starting at 2:15pm on Feb 19th 2020 allowing up to 10 students diff --git a/tests/generator/lib.php b/tests/generator/lib.php index e1276424..124c6969 100644 --- a/tests/generator/lib.php +++ b/tests/generator/lib.php @@ -114,6 +114,13 @@ public function create_instance($record = null, array $options = null) { $appointmentid = $DB->insert_record('scheduler_appointment', $appointment); } } + + if (isset($options['slotwatchers'][$slotkey])) { + $userids = (array) $options['slotwatchers'][$slotkey]; + foreach ($userids as $userid) { + $DB->insert_record('scheduler_watcher', (object) ['userid' => $userid, 'slotid' => $slotid]); + } + } } } diff --git a/tests/permissions_test.php b/tests/permissions_test.php index 619c75a6..4efffe9b 100644 --- a/tests/permissions_test.php +++ b/tests/permissions_test.php @@ -258,6 +258,26 @@ public function test_can_edit_all_slots() { } + /** + * Tests whether appointments can be scheduled for other teachers. + * + * @coversNothing + */ + public function test_can_schedule_slots_to_other_teachers() { + + // Editing teachers can schedule for others. + $p = new scheduler_permissions($this->context, $this->edteacher); + $this->assertTrue($p->can_schedule_slot_to_other_teachers()); + + // Admins and students cannot schedule for others. + $p = new scheduler_permissions($this->context, $this->nonedteacher); + $this->assertFalse($p->can_schedule_slot_to_other_teachers()); + $p = new scheduler_permissions($this->context, $this->administ); + $this->assertFalse($p->can_schedule_slot_to_other_teachers()); + $p = new scheduler_permissions($this->context, $this->students[1]); + $this->assertFalse($p->can_schedule_slot_to_other_teachers()); + } + /** * Tests whether appointments can be seen. * diff --git a/tests/privacy_test.php b/tests/privacy_test.php index dd4abf4e..0c0c5969 100644 --- a/tests/privacy_test.php +++ b/tests/privacy_test.php @@ -97,11 +97,13 @@ protected function setUp(): void { $this->student1 = $this->getDataGenerator()->create_user(); $this->student2 = $this->getDataGenerator()->create_user(); - $this->allstudents = [$this->student1->id, $this->student2->id]; + $this->student3 = $this->getDataGenerator()->create_user(); + $this->allstudents = [$this->student1->id, $this->student2->id, $this->student3->id]; $options = array(); $options['slottimes'] = array(); $options['slotstudents'] = array(); + $options['slotwatchers'] = []; for ($c = 0; $c < 4; $c++) { $options['slottimes'][$c] = time() + ($c + 1) * DAYSECS; $stud = $this->getDataGenerator()->create_user()->id; @@ -114,6 +116,8 @@ protected function setUp(): void { $this->student1->id, $this->student2->id ); + $options['slotwatchers'][4] = [$this->student1->id]; + $options['slotwatchers'][0] = [$this->student3->id]; $scheduler = $this->getDataGenerator()->create_module('scheduler', array('course' => $course->id), $options); $coursemodule = $DB->get_record('course_modules', array('id' => $scheduler->cmid)); @@ -157,6 +161,10 @@ public function test_get_contexts_for_userid() { // Get contexts for the first user. $contextids = provider::get_contexts_for_userid($this->student1->id)->get_contextids(); $this->assertEquals([$this->context->id], $contextids, '', 0.0, 10, true); + + // Get contexts for the watcher user. + $contextids = provider::get_contexts_for_userid($this->student3->id)->get_contextids(); + $this->assertEquals([$this->context->id], $contextids); } /** @@ -219,10 +227,12 @@ public function test_export_user_data1() { * @covers \mod_scheduler\privacy\provider::delete_data_for_all_users_in_context */ public function test_delete_data_for_all_users_in_context() { + global $DB; provider::delete_data_for_all_users_in_context($this->context); foreach ($this->allstudents as $u) { $this->assert_appointment_status($this->schedulerid, $u, false); + $this->assertFalse($DB->record_exists('scheduler_watcher', ['userid' => $u])); } } @@ -232,12 +242,15 @@ public function test_delete_data_for_all_users_in_context() { * @covers \mod_scheduler\privacy\provider::delete_data_for_user */ public function test_delete_data_for_user() { + global $DB; $appctx = new approved_contextlist($this->student1, 'mod_scheduler', [$this->context->id]); provider::delete_data_for_user($appctx); $this->assert_appointment_status($this->schedulerid, $this->student1->id, false); $this->assert_appointment_status($this->schedulerid, $this->student2->id, true); + $this->assertFalse($DB->record_exists('scheduler_watcher', ['userid' => $this->student1->id])); + $this->assertTrue($DB->record_exists('scheduler_watcher', ['userid' => $this->student3->id])); } /** @@ -246,6 +259,7 @@ public function test_delete_data_for_user() { * @covers \mod_scheduler\privacy\provider::delete_data_for_users */ public function test_delete_data_for_users() { + global $DB; $component = 'mod_scheduler'; $approveduserids = [$this->student1->id, $this->student2->id]; @@ -254,5 +268,8 @@ public function test_delete_data_for_users() { $this->assert_appointment_status($this->schedulerid, $this->student1->id, false); $this->assert_appointment_status($this->schedulerid, $this->student2->id, false); + + $this->assertFalse($DB->record_exists('scheduler_watcher', ['userid' => $this->student1->id])); + $this->assertTrue($DB->record_exists('scheduler_watcher', ['userid' => $this->student3->id])); } } diff --git a/version.php b/version.php index 732509de..1c65cc4e 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ */ $plugin->component = 'mod_scheduler'; // Full name of the plugin (used for diagnostics). -$plugin->version = 2022120200; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050816; // The current module version (Date: YYYYMMDDXX). $plugin->release = '4.x dev'; // Human-friendly version name. $plugin->requires = 2022041900; // Requires Moodle 4.0. $plugin->maturity = MATURITY_ALPHA; // Development release - not for production use. diff --git a/view.php b/view.php index 1edfed23..c1690d1b 100644 --- a/view.php +++ b/view.php @@ -71,9 +71,8 @@ // Route to screen. -$teachercaps = ['mod/scheduler:manage', 'mod/scheduler:manageallappointments', 'mod/scheduler:canseeotherteachersbooking']; -$isteacher = has_any_capability($teachercaps, $context); -$isstudent = has_capability('mod/scheduler:viewslots', $context); +$isteacher = $permissions->is_teacher(); +$isstudent = $permissions->is_student(); if ($isteacher) { // Teacher side. if ($action == 'viewstatistics') { @@ -84,6 +83,8 @@ include($CFG->dirroot.'/mod/scheduler/export.php'); } else if ($action == 'datelist') { include($CFG->dirroot.'/mod/scheduler/datelist.php'); + } else if ($action == 'import') { + include($CFG->dirroot.'/mod/scheduler/import.php'); } else { include($CFG->dirroot.'/mod/scheduler/teacherview.php'); }