From 25508e6f1107bfdeb4d08a9632ab6ee32749f975 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Mon, 3 Feb 2020 12:42:34 +0800 Subject: [PATCH 01/29] Setting to require attendance for activity completion --- backup/moodle2/backup_scheduler_stepslib.php | 2 +- classes/model/scheduler.php | 33 ++++++++++++++++++++ db/install.xml | 5 +-- db/upgrade.php | 16 ++++++++++ lang/en/scheduler.php | 3 ++ lib.php | 24 ++++++++++++++ mod_form.php | 26 +++++++++++++++ version.php | 2 +- 8 files changed, 107 insertions(+), 4 deletions(-) diff --git a/backup/moodle2/backup_scheduler_stepslib.php b/backup/moodle2/backup_scheduler_stepslib.php index 3aaf6f27..08574dc6 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')); $slots = new backup_nested_element('slots'); diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index 3a24cff7..9847cf06 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -538,6 +538,39 @@ 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->requiresattended); + } + + /** + * 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 *********************** */ diff --git a/db/install.xml b/db/install.xml index 5df0139f..b5adc1d5 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -31,6 +31,7 @@ + @@ -84,4 +85,4 @@ - \ No newline at end of file + diff --git a/db/upgrade.php b/db/upgrade.php index 8dbee09d..bdc20623 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -367,5 +367,21 @@ 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'); + } + return true; + } diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 3b2b19db..84f1d757 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -179,6 +179,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?'; diff --git a/lib.php b/lib.php index 907ef63b..d86104d4 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(); + $result = $type == COMPLETION_AND ? $result && $hasattended : $result || $hasattended; + } + + return $result; +} diff --git a/mod_form.php b/mod_form.php index 90dcfbf3..352e4e35 100644 --- a/mod_form.php +++ b/mod_form.php @@ -191,6 +191,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. diff --git a/version.php b/version.php index 732509de..205f9200 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 = 2023050800; // 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. From 385cc4c499a56e662d23114e758480dd52ec2179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Mon, 3 Feb 2020 14:27:38 +0800 Subject: [PATCH 02/29] Completion state is updated when students attend appointments --- classes/model/appointment.php | 35 +++++++++++++++++++++++++++++++++++ classes/model/scheduler.php | 27 ++++++++++++++++++++++++++- lib.php | 2 +- teacherview.controller.php | 12 ++++-------- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/classes/model/appointment.php b/classes/model/appointment.php index 977e23e9..d7427fa6 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 is attended value, defaults to false as does the constructor. */ + private $initialisattended = null; + /** * get_table * @@ -65,10 +68,23 @@ 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(); + $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 ($isattendedchanged) { + $this->get_scheduler()->completion_update_has_attended($this->studentid, $this->is_attended()); + } } /** @@ -87,6 +103,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 +168,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 {@link 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/scheduler.php b/classes/model/scheduler.php index 9847cf06..166fb1dc 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -26,8 +26,11 @@ defined('MOODLE_INTERNAL') || die(); +require_once($CFG->libdir . '/completionlib.php'); require_once($CFG->dirroot . '/grade/lib.php'); +use completion_info; + /** * A class for representing a scheduler instance, as an MVC model. * @@ -544,7 +547,29 @@ public function get_gradebook_info($studentid) { * @return bool */ public function completion_requires_attended() { - return !empty($this->data->requiresattended); + 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); + } } /** diff --git a/lib.php b/lib.php index d86104d4..3a7b3f2a 100644 --- a/lib.php +++ b/lib.php @@ -775,7 +775,7 @@ function scheduler_get_completion_state($course, $cm, $userid, $type) { // Check whether the user has been seen. if ($scheduler->completion_requires_attended()) { - $hasattended = $scheduler->has_user_attended_any_slot(); + $hasattended = $scheduler->has_user_attended_any_slot($userid); $result = $type == COMPLETION_AND ? $result && $hasattended : $result || $hasattended; } 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); From 954949b6861039bb90ddda4fa7ae49eece51c579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Tue, 4 Feb 2020 13:10:03 +0800 Subject: [PATCH 03/29] Slots can be imported from a CSV file --- classes/csv_slots_importer.php | 278 ++++++++++++++++++ .../local/iterator/csv_reader_iterator.php | 126 ++++++++ classes/local/iterator/map_iterator.php | 76 +++++ classes/output/import_csv_form.php | 78 +++++ classes/output/import_csv_options_form.php | 60 ++++ classes/permission/permissions_manager.php | 9 + classes/permission/scheduler_permissions.php | 9 + import.php | 214 ++++++++++++++ lang/en/scheduler.php | 32 ++ renderer.php | 1 + tests/fixtures/slots.csv | 4 + tests/permissions_test.php | 20 ++ view.php | 2 + 13 files changed, 909 insertions(+) create mode 100644 classes/csv_slots_importer.php create mode 100644 classes/local/iterator/csv_reader_iterator.php create mode 100644 classes/local/iterator/map_iterator.php create mode 100644 classes/output/import_csv_form.php create mode 100644 classes/output/import_csv_options_form.php create mode 100644 import.php create mode 100644 tests/fixtures/slots.csv diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php new file mode 100644 index 00000000..90b9c0cc --- /dev/null +++ b/classes/csv_slots_importer.php @@ -0,0 +1,278 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +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; + +/** + * 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) { + $date = new DateTime('@0'); + } + $duration = (int) $line['duration']; + + // Optional columns. + $maxstudents = !empty($line['maxstudents']) ? (int) $line['maxstudents'] : 0; + $location = $line['location'] ?: null; + $teacher = $line['teacher'] ?: null; + $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() { + 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 {@link 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 {@link 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/local/iterator/csv_reader_iterator.php b/classes/local/iterator/csv_reader_iterator.php new file mode 100644 index 00000000..e0bef994 --- /dev/null +++ b/classes/local/iterator/csv_reader_iterator.php @@ -0,0 +1,126 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +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; + } + + /** + * 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; + } + } + + /** + * 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() { + $this->ensure_initialised(); + $this->pos++; + $this->current = $this->cir->next(); + } + + /** + * Rewind the reader. + * + * @return void + */ + public function rewind() { + $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() { + 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..641b7281 --- /dev/null +++ b/classes/local/iterator/map_iterator.php @@ -0,0 +1,76 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +/** + * 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; + } + + public function current() { + $cb = $this->callback; + return $cb($this->iterator->current(), $this->iterator->key()); + } + + public function key() { + return $this->iterator->key(); + } + + public function next() { + $this->iterator->next(); + } + + public function rewind() { + $this->iterator->rewind(); + } + + public function valid() { + return $this->iterator->valid(); + } + +} diff --git a/classes/output/import_csv_form.php b/classes/output/import_csv_form.php new file mode 100644 index 00000000..fd1a690c --- /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. + */ + 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..9cb05e03 --- /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. + */ + 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/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..acb8adc2 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 * diff --git a/import.php b/import.php new file mode 100644 index 00000000..426d4f64 --- /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()) { + print_error('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); + print_error($errorkey, '', $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); + print_error($errorkey, '', $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 84f1d757..05e3c46f 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -151,6 +151,7 @@ $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['chooseexisting'] = 'Choose existing'; $string['choosingslotstart'] = 'Choosing the start time'; $string['comments'] = 'Comments'; @@ -192,10 +193,14 @@ $string['confirmrevoke'] = 'Revoke all appointments in the current slot?'; $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.'; @@ -224,6 +229,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'; @@ -303,8 +309,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'; @@ -362,6 +391,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'; @@ -391,6 +422,7 @@ $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) diff --git a/renderer.php b/renderer.php index fc9bca14..1d2c3325 100644 --- a/renderer.php +++ b/renderer.php @@ -368,6 +368,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); } diff --git a/tests/fixtures/slots.csv b/tests/fixtures/slots.csv new file mode 100644 index 00000000..7b0f7ae0 --- /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 2nd 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 2nd 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 2nd 2020 allowing up to 10 students 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/view.php b/view.php index 1edfed23..20f837f2 100644 --- a/view.php +++ b/view.php @@ -84,6 +84,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'); } From 4025fab3af359270d1c845a92c0ffc297be1110b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Tue, 4 Feb 2020 17:52:11 +0800 Subject: [PATCH 04/29] Individual appointments can be revoked in group slots --- amd/build/revoke.min.js | 13 +++++ amd/build/revoke.min.js.map | 1 + amd/src/revoke.js | 103 ++++++++++++++++++++++++++++++++++ classes/external.php | 108 ++++++++++++++++++++++++++++++++++++ db/services.php | 36 ++++++++++++ lang/en/scheduler.php | 1 + renderer.php | 14 ++++- styles.css | 12 ++++ version.php | 2 +- 9 files changed, 288 insertions(+), 2 deletions(-) create mode 100644 amd/build/revoke.min.js create mode 100644 amd/build/revoke.min.js.map create mode 100644 amd/src/revoke.js create mode 100644 classes/external.php create mode 100644 db/services.php 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/classes/external.php b/classes/external.php new file mode 100644 index 00000000..d880df67 --- /dev/null +++ b/classes/external.php @@ -0,0 +1,108 @@ +. + +/** + * 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 core_user; +use external_api; +use external_function_parameters; +use external_value; +use mod_scheduler\model\scheduler; +use mod_scheduler\permission\scheduler_permissions; +use scheduler_messenger; + +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 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 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 null; + } + + /** + * External function return structure. + * + * @return external_value + */ + public function revoke_appointment_returns() { + return new external_value(null); + } + +} diff --git a/db/services.php b/db/services.php new file mode 100644 index 00000000..21705957 --- /dev/null +++ b/db/services.php @@ -0,0 +1,36 @@ +. + +/** + * 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_revoke_appointment' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'revoke_appointment', + 'description' => 'Revoke an appointment', + 'type' => 'write', + 'ajax' => true, + ] +]; \ No newline at end of file diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 05e3c46f..28b4e6d7 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -191,6 +191,7 @@ $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.'; diff --git a/renderer.php b/renderer.php index 1d2c3325..cdc4af10 100644 --- a/renderer.php +++ b/renderer.php @@ -570,6 +570,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'; } @@ -578,7 +587,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) { @@ -711,6 +722,7 @@ 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 = ''; 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/version.php b/version.php index 205f9200..6692810c 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 = 2023050800; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050801; // 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. From d2c7662f94222b0fe4f59cbaa40f9af1083113a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 5 Feb 2020 14:14:39 +0800 Subject: [PATCH 05/29] Add support for sorting the slots by date, location and teacher --- classes/model/scheduler.php | 42 +++++- classes/slots_query_builder.php | 240 ++++++++++++++++++++++++++++++++ renderable.php | 9 ++ renderer.php | 44 +++++- teacherview.php | 58 +++++++- 5 files changed, 381 insertions(+), 12 deletions(-) create mode 100644 classes/slots_query_builder.php diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index 166fb1dc..2072ad65 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -30,6 +30,7 @@ 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. @@ -608,9 +609,11 @@ public function has_user_attended_any_slot($userid) { * @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'; @@ -631,7 +634,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(); @@ -648,9 +651,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'; @@ -660,11 +664,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. @@ -741,6 +755,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 * @@ -960,6 +983,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 ******************** */ diff --git a/classes/slots_query_builder.php b/classes/slots_query_builder.php new file mode 100644 index 00000000..4d588ba0 --- /dev/null +++ b/classes/slots_query_builder.php @@ -0,0 +1,240 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +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; + + /** @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 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; + + /** + * 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; + return $clone; + } + + /** + * 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 = []; + $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/renderable.php b/renderable.php index dfd6d318..52451f11 100644 --- a/renderable.php +++ b/renderable.php @@ -389,6 +389,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 cdc4af10..49956a57 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. * @@ -726,12 +744,32 @@ public function render_scheduler_slot_manager(scheduler_slot_manager $slotman) { $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/teacherview.php b/teacherview.php index d4e7549d..3f28da36 100644 --- a/teacherview.php +++ b/teacherview.php @@ -26,6 +26,9 @@ use \mod_scheduler\model\scheduler; +$tsort = optional_param('tsort', null, PARAM_ALPHA); +$tdir = optional_param('tdir', null, PARAM_INT); + /** * Print a selection box of existing slots to be scheduler in * @@ -131,6 +134,13 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid 'offset' => $offset )); +if ($tsort !== null) { + $baseurl->param('tsort', $tsort); +} +if ($tdir !== null) { + $baseurl->param('tdir', $tdir); +} + // 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')); @@ -422,7 +432,6 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid } } - if ($subpage == 'allappointments') { $teacherid = 0; $slotgroup = $currentgroup; @@ -431,12 +440,48 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $slotgroup = 0; $subpage = 'myappointments'; } -$sqlcount = $scheduler->count_slots_for_teacher($teacherid, $slotgroup); -$pagesize = 25; +$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); +} + +$sqlcount = $scheduler->count_slots_from_query_builder($qb); + +$pagesize = 5; 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,7 +491,8 @@ 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')); @@ -501,6 +547,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) { From 9f83cc30720dc5c50a203ed65d4c3cc73aa78ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 5 Feb 2020 18:45:31 +0800 Subject: [PATCH 06/29] Add support for filtering the slots by date, location and teacher --- classes/output/datetime_filter.php | 110 ++++++++++++++++++++++ classes/output/slots_filter_form.php | 134 +++++++++++++++++++++++++++ classes/slots_query_builder.php | 80 +++++++++++++++- lang/en/scheduler.php | 10 ++ teacherview.php | 54 ++++++++++- 5 files changed, 384 insertions(+), 4 deletions(-) create mode 100644 classes/output/datetime_filter.php create mode 100644 classes/output/slots_filter_form.php diff --git a/classes/output/datetime_filter.php b/classes/output/datetime_filter.php new file mode 100644 index 00000000..b4a43d85 --- /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() { + $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('midnight') + ]); + $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. + */ + function exportValue(&$submitValues, $notused = false) { + // 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/slots_filter_form.php b/classes/output/slots_filter_form.php new file mode 100644 index 00000000..b3e9e483 --- /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 = array_merge([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/slots_query_builder.php b/classes/slots_query_builder.php index 4d588ba0..6afba92a 100644 --- a/classes/slots_query_builder.php +++ b/classes/slots_query_builder.php @@ -48,6 +48,15 @@ class slots_query_builder { /** 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; + /** @var int|null The group ID. */ protected $groupid = 0; /** @var sql_join An array of joins. */ @@ -58,12 +67,16 @@ class slots_query_builder { 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. @@ -112,9 +125,72 @@ public function clone() { $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. + * @return void + */ + public function filter_starttime($timestamp, $operator = self::OPERATOR_ON) { + $timestamp = (int) $timestamp; + if (empty($timestamp)) { + unset($this->wheres['filterstarttime']); + unset($this->params['filterstarttime']); + unset($this->params['filterstarttimeend']); + return; + } + + $sql = '1=1'; + $params = ['filterstarttime' => $timestamp]; + + 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_ON: + default: + $sql = "{$this->prefix}starttime > :filterstarttime AND {$this->prefix}starttime < :filterstarttimeend"; + $startofday = usergetmidnight($timestamp); + $params = [ + 'filterstarttime' => $startofday, + 'filterstarttimeend' => $startofday + DAYSECS + ]; + break; + } + + $this->wheres['filterstarttime'] = $sql; + $this->params = array_merge($this->params, $params); + } + /** * Get the joins. * @@ -155,8 +231,8 @@ public function get_order_by() { * @return string */ public function get_where() { - $wheres = []; - $params = []; + $wheres = $this->wheres; + $params = $this->params; if ($this->teacherid) { $wheres[] = "{$this->prefix}teacherid = :paramtid"; diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 28b4e6d7..8dd209de 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -115,6 +115,7 @@ $string['allononepage'] = 'All slots on one page'; $string['allowgroup'] = 'Exclusive slot - click to change'; $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'; @@ -154,6 +155,7 @@ $string['cannotscheduleslotforothers'] = 'You cannot schedule appointments for other staff members.'; $string['chooseexisting'] = 'Choose existing'; $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}'; @@ -276,6 +278,10 @@ 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'; @@ -473,6 +479,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'; diff --git a/teacherview.php b/teacherview.php index 3f28da36..df4cc28d 100644 --- a/teacherview.php +++ b/teacherview.php @@ -28,6 +28,10 @@ $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 @@ -134,6 +138,7 @@ 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); } @@ -141,6 +146,36 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid $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')); @@ -393,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(); @@ -473,9 +507,22 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid 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 = 5; +$pagesize = 25; if ($offset == -1) { // When the user lands on the page, navigate to the most relevant page first. if ($sqlcount > $pagesize) { @@ -496,6 +543,9 @@ function scheduler_print_schedulebox(scheduler $scheduler, $studentid, $groupid 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')); From 91f3f99861ca88fe6f608435e2449e813d27afc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Thu, 6 Feb 2020 13:28:39 +0800 Subject: [PATCH 07/29] Adding user interface for students to watch slots --- classes/event/slot_unwatched.php | 99 +++++++++++++++++++++++++++++++ classes/event/slot_watched.php | 99 +++++++++++++++++++++++++++++++ classes/model/scheduler.php | 9 +++ classes/model/slot.php | 97 ++++++++++++++++++++++++++++++ classes/model/watcher.php | 88 +++++++++++++++++++++++++++ classes/model/watcher_factory.php | 50 ++++++++++++++++ db/access.php | 10 +++- db/install.xml | 16 +++++ db/messages.php | 8 +++ db/upgrade.php | 68 +++++++++++++++++++++ lang/en/scheduler.php | 11 ++++ mod_form.php | 19 ++++++ renderable.php | 7 ++- renderer.php | 5 ++ studentview.controller.php | 40 +++++++++++++ studentview.php | 20 ++++++- version.php | 2 +- 17 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 classes/event/slot_unwatched.php create mode 100644 classes/event/slot_watched.php create mode 100644 classes/model/watcher.php create mode 100644 classes/model/watcher_factory.php diff --git a/classes/event/slot_unwatched.php b/classes/event/slot_unwatched.php new file mode 100644 index 00000000..f5916b49 --- /dev/null +++ b/classes/event/slot_unwatched.php @@ -0,0 +1,99 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +/** + * 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 $slot + * @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..07fce91e --- /dev/null +++ b/classes/event/slot_watched.php @@ -0,0 +1,99 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +/** + * 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 $slot + * @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/model/scheduler.php b/classes/model/scheduler.php index 2072ad65..e5afb728 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -287,6 +287,15 @@ public function is_individual_scheduling_enabled() { } } + /** + * Whether this scheduler supports watching. + * + * @return bool + */ + public function is_watching_enabled() { + return (bool) $this->data->canwatch && $this->is_individual_scheduling_enabled(); + } + /** * get the last location of a certain teacher in this scheduler * diff --git a/classes/model/slot.php b/classes/model/slot.php index 3404d113..ec22386f 100644 --- a/classes/model/slot.php +++ b/classes/model/slot.php @@ -37,6 +37,9 @@ class slot extends mvc_child_record_model { */ protected $appointments; + /** @var mvc_child_list The list of watchers. */ + protected $watchers; + /** * get_table * @@ -59,6 +62,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)); } /** @@ -194,6 +198,99 @@ 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(); + } + + /** + * 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. + * + * @return bool + */ + public function is_watchable() { + return $this->is_in_bookable_period() + && $this->count_remaining_appointments() === 0; + } + + /** + * Whether this slot can be watched by a student. + * + * Note that this does not check permissions, only whether the slot is in + * in a state that permits the given use to watch it. This also does not + * permform any check regarding the scheduler's settings. + * + * To check whether a student can watch a slot, you should: + * + * - Check that {@link scheduler::is_watching_enabled} + * - Check that they have the permission to mod/scheduler:watchslots + * - Check that the slots is watching (this method) + * + * @param int $studentid The student ID. + * @return bool + */ + public function is_watchable_by_student($userid) { + return $this->is_watchable() && !$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 diff --git a/classes/model/watcher.php b/classes/model/watcher.php new file mode 100644 index 00000000..eb03c242 --- /dev/null +++ b/classes/model/watcher.php @@ -0,0 +1,88 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +/** + * 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; + } + + /** + * save + */ + public function save() { + $this->data->slotid = $this->get_parent()->get_id(); + parent::save(); + } + + /** + * 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(); + } + +} diff --git a/classes/model/watcher_factory.php b/classes/model/watcher_factory.php new file mode 100644 index 00000000..a5bfbee2 --- /dev/null +++ b/classes/model/watcher_factory.php @@ -0,0 +1,50 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + + +/** + * 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/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 b5adc1d5..08492466 100644 --- a/db/install.xml +++ b/db/install.xml @@ -32,6 +32,7 @@ + @@ -84,5 +85,20 @@ + + + + + + + + + + + + + + +
    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/upgrade.php b/db/upgrade.php index bdc20623..f9528319 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -382,6 +382,74 @@ function xmldb_scheduler_upgrade($oldversion=0) { 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); + $table->add_field('notified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); + + // 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'); + } + + if ($oldversion < 2023050806) { + + // Define index notifiedidx (not unique) to be added to scheduler_watcher. + $table = new xmldb_table('scheduler_watcher'); + $index = new xmldb_index('notifiedidx', XMLDB_INDEX_NOTUNIQUE, ['notified']); + + // Conditionally launch add index notifiedidx. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Scheduler savepoint reached. + upgrade_mod_savepoint(true, 2020020306, 'scheduler'); + } + return true; } diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 8dd209de..5907e4b3 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'; @@ -99,6 +103,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'; @@ -513,6 +521,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'] = ''; @@ -539,10 +548,12 @@ $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['watchslotsintro'] = 'To be notified when a fully booked slot becomes available, click the "Watch slot" button for that corresponding slot.'; $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?'; diff --git a/mod_form.php b/mod_form.php index 352e4e35..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(); @@ -246,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 52451f11..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; } diff --git a/renderer.php b/renderer.php index 49956a57..8bc1237a 100644 --- a/renderer.php +++ b/renderer.php @@ -694,6 +694,11 @@ 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[] = ''; } diff --git a/studentview.controller.php b/studentview.controller.php index 1f0b5f06..bfe8b001 100644 --- a/studentview.controller.php +++ b/studentview.controller.php @@ -192,6 +192,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') { diff --git a/studentview.php b/studentview.php index 3095caff..afe5aabb 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,9 @@ } $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; +$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'); @@ -177,6 +183,7 @@ // Show the booking form. $booker = new scheduler_slot_booker($scheduler, $USER->id, $actionurl, $bookablecnt); + $haswatchableslots = false; $pagesize = 25; $total = count($bookableslots); @@ -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); @@ -216,7 +223,10 @@ } } - $booker->add_slot($slot, $canbookthisslot, false, $groupinfo, $others); + $canwatchthisslot = $canwatchslots && $slot->is_watchable_by_student($USER->id); + $iswatching = $canwatchthisslot && $slot->is_watched_by_student($USER->id); + $haswatchableslots = $haswatchableslots || $canwatchthisslot; + $booker->add_slot($slot, $canbookthisslot, false, $groupinfo, $others, $canwatchthisslot, $iswatching); } @@ -248,6 +258,10 @@ echo $output->paging_bar($total, $offset, $pagesize, $actionurl, 'offset'); } + if ($canwatchslots) { + echo html_writer::tag('p', get_string('watchslotsintro', 'mod_scheduler')); + } + } echo $output->footer(); diff --git a/version.php b/version.php index 6692810c..0bdf3505 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 = 2023050801; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050806; // 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. From 80cdd13a3190115675fc6feb015dce6a533688ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Thu, 6 Feb 2020 17:31:01 +0800 Subject: [PATCH 08/29] Logic for detecting when to notify a watcher --- classes/model/appointment.php | 6 ++ classes/model/mvc_child_list.php | 29 ++++++++ classes/model/scheduler.php | 25 ++++++- classes/model/slot.php | 85 +++++++++++++++++++----- classes/model/watcher.php | 43 +++++++++--- classes/task/purge_obsolete_watchers.php | 55 +++++++++++++++ db/install.xml | 2 - db/tasks.php | 8 +++ db/upgrade.php | 16 ----- lang/en/scheduler.php | 10 ++- version.php | 2 +- 11 files changed, 233 insertions(+), 48 deletions(-) create mode 100644 classes/task/purge_obsolete_watchers.php diff --git a/classes/model/appointment.php b/classes/model/appointment.php index d7427fa6..c6e3d410 100644 --- a/classes/model/appointment.php +++ b/classes/model/appointment.php @@ -74,6 +74,7 @@ public function save() { // 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(); @@ -82,6 +83,11 @@ public function save() { $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()); } 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 e5afb728..e88cfa26 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -1148,6 +1148,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' @@ -1165,9 +1170,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; @@ -1323,6 +1326,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 s.id + FROM {scheduler_slots} s + WHERE s.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 ec22386f..0c6f8d46 100644 --- a/classes/model/slot.php +++ b/classes/model/slot.php @@ -37,6 +37,9 @@ 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; @@ -78,13 +81,34 @@ public static function load_by_id($id, scheduler $scheduler) { } /** - * Save any changes to the database + * Save any changes to the database. */ public function save() { + $savewatchers = false; $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(); + } } /** @@ -160,11 +184,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; } /** @@ -223,6 +250,28 @@ 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. * @@ -249,31 +298,32 @@ public function remove_watcher($userid) { /** * 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->is_in_bookable_period() - && $this->count_remaining_appointments() === 0; + 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 permissions, only whether the slot is in - * in a state that permits the given use to watch it. This also does not - * permform any check regarding the scheduler's settings. - * - * To check whether a student can watch a slot, you should: - * - * - Check that {@link scheduler::is_watching_enabled} - * - Check that they have the permission to mod/scheduler:watchslots - * - Check that the slots is watching (this method) + * 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 $studentid The student ID. * @return bool */ public function is_watchable_by_student($userid) { - return $this->is_watchable() && !$this->is_booked_by_student($userid); + return $this->is_watchable() + && $this->count_remaining_appointments() === 0 + && !$this->is_booked_by_student($userid); } /** @@ -414,6 +464,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 index eb03c242..6e822222 100644 --- a/classes/model/watcher.php +++ b/classes/model/watcher.php @@ -59,14 +59,6 @@ public function __construct(slot $slot) { $this->data->notified = 0; } - /** - * save - */ - public function save() { - $this->data->slotid = $this->get_parent()->get_id(); - parent::save(); - } - /** * Retrieve the slot associated with this appointment * @@ -85,4 +77,39 @@ 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. + * + * @param stdClass $teacher The teacher. + * @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/task/purge_obsolete_watchers.php b/classes/task/purge_obsolete_watchers.php new file mode 100644 index 00000000..bde56604 --- /dev/null +++ b/classes/task/purge_obsolete_watchers.php @@ -0,0 +1,55 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +/** + * 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/install.xml b/db/install.xml index 08492466..71c09404 100644 --- a/db/install.xml +++ b/db/install.xml @@ -90,14 +90,12 @@ - - 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 f9528319..ecb811c6 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -391,7 +391,6 @@ function xmldb_scheduler_upgrade($oldversion=0) { $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); - $table->add_field('notified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0'); // Adding keys to table scheduler_watcher. $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']); @@ -435,21 +434,6 @@ function xmldb_scheduler_upgrade($oldversion=0) { upgrade_mod_savepoint(true, 2023050805, 'scheduler'); } - if ($oldversion < 2023050806) { - - // Define index notifiedidx (not unique) to be added to scheduler_watcher. - $table = new xmldb_table('scheduler_watcher'); - $index = new xmldb_index('notifiedidx', XMLDB_INDEX_NOTUNIQUE, ['notified']); - - // Conditionally launch add index notifiedidx. - if (!$dbman->index_exists($table, $index)) { - $dbman->add_index($table, $index); - } - - // Scheduler savepoint reached. - upgrade_mod_savepoint(true, 2020020306, 'scheduler'); - } - return true; } diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 5907e4b3..987b11cc 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -67,7 +67,7 @@ $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.'; +$string['messageprovider:watchedslotopenedup'] = 'Notification when an appointment becomes available in a watched slot'; /* ***** Search areas ***** */ $string['search:activity'] = 'Scheduler - activity information'; @@ -431,6 +431,7 @@ $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['recipients'] = 'Recipients'; $string['registeredlbl'] = 'Student appointed'; @@ -662,6 +663,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/version.php b/version.php index 0bdf3505..d144339c 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 = 2023050806; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050808; // 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. From 2afc761b3a9346dce84c6f02cd9de39f166db338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Thu, 6 Feb 2020 19:11:23 +0800 Subject: [PATCH 09/29] Update the privacy API provider to include watchers --- classes/model/scheduler.php | 24 +++++++++++-- classes/privacy/provider.php | 68 ++++++++++++++++++++++++++++++------ lang/en/scheduler.php | 4 +++ renderer.php | 4 ++- tests/generator/lib.php | 7 ++++ tests/privacy_test.php | 19 +++++++++- 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index e88cfa26..88de2022 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -147,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 * @@ -1335,9 +1353,9 @@ public static function free_obsolete_watchers() { global $DB; $sql = "DELETE FROM {scheduler_watcher} WHERE slotid IN ( - SELECT s.id - FROM {scheduler_slots} s - WHERE s.starttime <= :starttime)"; + SELECT id + FROM {scheduler_slots} + WHERE starttime <= :starttime)"; $params = ['starttime' => time() - DAYSECS]; $DB->execute($sql, $params); } diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 490de14f..99c42d67 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/lang/en/scheduler.php b/lang/en/scheduler.php index 987b11cc..a13174f7 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -96,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'; diff --git a/renderer.php b/renderer.php index 8bc1237a..a45eef81 100644 --- a/renderer.php +++ b/renderer.php @@ -696,7 +696,9 @@ public function render_scheduler_slot_booker(scheduler_slot_booker $booker) { $rowdata[] = $this->render($button); } else if ($slot->canwatch) { $what = $slot->iswatching ? 'unwatchslot' : 'watchslot'; - $bookurl = new moodle_url($booker->actionurl, ['slotid' => $slot->slotid, 'what' => $what]); + $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 { 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/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])); } } From 34b6a9ad4855760c2370f4ab6dce80a4935ecc57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 7 Feb 2020 10:32:04 +0800 Subject: [PATCH 10/29] Include watchers in backups containing user information --- backup/moodle2/backup_scheduler_stepslib.php | 8 ++++++++ backup/moodle2/restore_scheduler_stepslib.php | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/backup/moodle2/backup_scheduler_stepslib.php b/backup/moodle2/backup_scheduler_stepslib.php index 08574dc6..4a7c2942 100644 --- a/backup/moodle2/backup_scheduler_stepslib.php +++ b/backup/moodle2/backup_scheduler_stepslib.php @@ -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 */ From 30c560c33dafd178d04bf0ac7bd0fe72bbab3cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Tue, 11 Feb 2020 21:19:01 +0800 Subject: [PATCH 11/29] Students can view and book slots from the mobile app --- bookingform.php | 24 +- classes/external.php | 633 +++++++++++++++++- classes/model/scheduler.php | 18 + classes/output/datetime_filter.php | 2 +- classes/output/mobile.php | 356 ++++++++++ classes/permission/scheduler_permissions.php | 28 + db/mobile.php | 44 ++ db/services.php | 30 +- lang/en/scheduler.php | 5 + locallib.php | 113 ++++ studentview.controller.php | 87 +-- templates/mobile_book_slot.mustache | 63 ++ templates/mobile_noguests.mustache | 8 + templates/mobile_slot.mustache | 126 ++++ .../mobile_student_bookable_slots.mustache | 38 ++ .../mobile_student_landing_page.mustache | 75 +++ version.php | 2 +- view.php | 5 +- 18 files changed, 1540 insertions(+), 117 deletions(-) create mode 100644 classes/output/mobile.php create mode 100644 db/mobile.php create mode 100644 templates/mobile_book_slot.mustache create mode 100644 templates/mobile_noguests.mustache create mode 100644 templates/mobile_slot.mustache create mode 100644 templates/mobile_student_bookable_slots.mustache create mode 100644 templates/mobile_student_landing_page.mustache 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/external.php b/classes/external.php index d880df67..47cc9d99 100644 --- a/classes/external.php +++ b/classes/external.php @@ -29,9 +29,15 @@ use core_user; use external_api; use external_function_parameters; +use extenral_multiple_structure; +use external_single_structure; use external_value; +use moodle_exception; +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; require_once($CFG->libdir . '/externallib.php'); @@ -52,7 +58,219 @@ class external extends external_api { * * @return external_function_parameters */ - public function revoke_appointment_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 null; + } + + /** + * External function return structure. + * + * @return external_value + */ + public static function booking_form_viewed_returns() { + return new external_value(null); + } + + /** + * 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, []) + ]); + } + + /** + * Trigger booking form viewed event. + * + * @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'); + } + } + + $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) use ($canbookslots, $canwatchslots, $canseeothers) { + return static::serialize_slot($slot, $canbookslots, $canwatchslots, $canseeothers); + }, 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 extenral_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), @@ -66,7 +284,7 @@ public function revoke_appointment_parameters() { * @param int $appointmentid The appointment ID. * @return null */ - public function revoke_appointment($cmid, $appointmentid) { + public static function revoke_appointment($cmid, $appointmentid) { global $USER; $params = self::validate_parameters(self::revoke_appointment_parameters(), @@ -93,7 +311,7 @@ public function revoke_appointment($cmid, $appointmentid) { $slot->save(); - return null; + return true; } /** @@ -101,8 +319,413 @@ public function revoke_appointment($cmid, $appointmentid) { * * @return external_value */ - public function revoke_appointment_returns() { - return new external_value(null); + public static function revoke_appointment_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'); + } + + $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); + } + + /** + * 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); + } + + /** + * Serialize an appointment. + * + * @param appointment $app The appointment. + * @param bool $includeteachernote Whether to include the teacher's note. + * @return array + */ + public static function serialize_appointment(appointment $app, $includeteachernote = false, $renderer = null) { + global $PAGE; + + $context = $app->get_scheduler()->get_context(); + $renderer = $renderer ?: $PAGE->get_renderer('mod_scheduler'); + + $teachernote = null; + $teachernoteformat = null; + $teachernoteformatted = null; + + if ($includeteachernote) { + $teachernote = $app->teachernote; + $teachernoteformat = $app->teachernoteformat; + $teachernoteformatted = external_format_text($app->appointmentnote, $app->appointmentnoteformat, $context->id, + 'mod_scheduler', 'teachernote', $app->id)[0]; + } + + return [ + 'id' => $app->id, + + 'appointmentnote' => $app->appointmentnote, + 'appointmentnoteformat' => $app->appointmentnoteformat, + 'appointmentnoteformatted' => external_format_text($app->appointmentnote, $app->appointmentnoteformat, $context->id, + 'mod_scheduler', 'appointmentnote', $app->id)[0], + 'hasappointmentnote' => !static::is_empty($app->appointmentnote), + + 'gradeformatted' => $renderer->format_grade($app->get_scheduler(), $app->grade), + 'hasgrade' => $app->grade !== null, + 'isattended' => $app->attended, + + 'studentnote' => $app->studentnote, + 'studentnoteformat' => $app->studentnoteformat, + 'studentnoteformatted' => external_format_text($app->studentnote, $app->studentnoteformat, $context->id)[0], + 'hasstudentnote' => !static::is_empty($app->studentnote), + + 'teachernote' => $teachernote, + 'teachernoteformat' => $teachernoteformat, + 'teachernoteformatted' => $teachernoteformatted, + 'hasteachernote' => !static::is_empty($teachernote), + + 'student' => static::serialize_user($app->student) + ]; + } + + /** + * 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), + + '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(), + ]); + } + + /** + * Serialize a scheduler. + * + * @param scheduler $scheduler The scheduler. + * @return array + */ + public static function serialize_scheduler(scheduler $scheduler) { + $context = $scheduler->get_context(); + + $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(), + ]; + + 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(); + $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(); + $canseeothers = has_capability('mod/scheduler:seeotherstudentsbooking', $context); + + $canbookslot = $canbookslots && $nremaining != 0 && $slot->is_in_bookable_period(); + $canwatchslot = $canwatchslots && $slot->is_watchable_by_student($USER->id); + $iswatching = $canwatchslot && $slot->is_watched_by_student($USER->id); + + 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' => array_map(function($app) { + return static::serialize_appointment($app); + }, $slot->get_appointments($canseeothers ? [$USER->id] : null)), + 'appointmentlocation' => $slot->appointmentlocation, + 'hasappointmentlocation' => !static::is_empty($slot->appointmentlocation), + + 'canbookslot' => $canbookslot, + 'canwatchslot' => $canwatchslot, + 'iswatching' => $iswatching, + + 'isunlimited' => $slot->exclusivity == 0, + 'isexclusive' => $slot->exclusivity == 1, + 'isgroupallowed' => !$slot->exclusivity || $slot->exclusivity >= 1, + 'isfull' => $nremaining == 0, + + 'nremaining' => $nremaining, + 'ntaken' => $slot->exclusivity > 0 ? $slot->exclusivity - $nremaining : 0, + '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 extenral_multiple_structure( + static::appointment_structure() + ), + '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), + '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/model/scheduler.php b/classes/model/scheduler.php index 88de2022..a8f30314 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -305,6 +305,24 @@ 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; + } + /** * Whether this scheduler supports watching. * diff --git a/classes/output/datetime_filter.php b/classes/output/datetime_filter.php index b4a43d85..18c31007 100644 --- a/classes/output/datetime_filter.php +++ b/classes/output/datetime_filter.php @@ -70,7 +70,7 @@ public function _createElements() { $datetime = $this->createFormElement('date_time_selector', $this->getName() . '[dt]', '', [ 'optional' => true, - 'defaulttime' => strtotime('midnight') + 'defaulttime' => strtotime('0 min 0 sec') ]); $this->_elements[] = $datetime; diff --git a/classes/output/mobile.php b/classes/output/mobile.php new file mode 100644 index 00000000..2a51388b --- /dev/null +++ b/classes/output/mobile.php @@ -0,0 +1,356 @@ +. + +/** + * 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; +defined('MOODLE_INTERNAL') || die(); + +use moodle_exception; +use mod_scheduler_renderer as renderer; +use mod_scheduler\external; +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 { + + 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, + ]; + } + + 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' => [], + ]; + } + + 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()) { + } 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' => [], + ]; + } + + 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); + + $slot = $scheduler->get_slot($slotid); + $appointment = null; + if ($isstudent) { + $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, + 'isstudent' => $isstudent, + 'isteacher' => $isteacher, + ]); + + return [ + 'templates' => [ + [ + 'id' => 'slot', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_slot', $data) + ] + ], + 'javascript' => '', + '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); + $canwatch = has_capability('mod/scheduler:watchslots', $context) && $scheduler->is_watching_enabled(); + + $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) use ($context, $scheduler, $userid) { + // $appointment = $slot->get_student_appointment($userid); + // $cancancel = $slot->is_in_bookable_period(); + // $canedit = $cancancel && $scheduler->uses_studentdata(); + // $canview = !$cancancel && $scheduler->uses_studentdata(); + // if ($scheduler->is_group_scheduling_enabled()) { + // $cancancel = $cancancel && ($appointgroup >= 0); + // } + 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'); + } 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' => [], + ]; + } + + /** + * Watch a slot. + * + * @param array $args The arguments. + * @return array + */ + public static function watch_slot($args) { + global $USER; + + $args = (object) $args; + $slotid = (int) $args->id; + + $pre = static::pre($args); + + external::watch_slot($args->cmid, $slotid); + + return static::slot($args); + } + + /** + * Unwatch a slot. + * + * @param array $args The arguments. + * @return array + */ + public static function unwatch_slot($args) { + global $USER; + + $args = (object) $args; + $slotid = (int) $args->id; + + $pre = static::pre($args); + external::unwatch_slot($args->cmid, $slotid); + return static::slot($args); + } +} diff --git a/classes/permission/scheduler_permissions.php b/classes/permission/scheduler_permissions.php index acb8adc2..50fb0afe 100644 --- a/classes/permission/scheduler_permissions.php +++ b/classes/permission/scheduler_permissions.php @@ -170,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/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 index 21705957..5c7b92e0 100644 --- a/db/services.php +++ b/db/services.php @@ -26,11 +26,39 @@ defined('MOODLE_INTERNAL') || die(); $functions = [ + '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_watch_slot' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'watch_slot', + 'description' => 'Watch a slot', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] + ], + 'mod_scheduler_unwatch_slot' => [ + 'classname' => 'mod_scheduler\\external', + 'methodname' => 'unwatch_slot', + 'description' => 'Unwatch a slot', + 'type' => 'write', + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] ] -]; \ No newline at end of file +]; diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index a13174f7..b84240ec 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -126,6 +126,7 @@ $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'; @@ -135,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'; @@ -152,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'; @@ -505,6 +508,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'; @@ -553,6 +557,7 @@ $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['watchslotsintro'] = 'To be notified when a fully booked slot becomes available, click the "Watch slot" button for that corresponding slot.'; $string['wednesday'] = 'Wednesday'; $string['welcomebackstudent'] = 'You can book additional slots by clicking on the corresponding "Book slot" button below.'; diff --git a/locallib.php b/locallib.php index 60ce8232..0c9b5b09 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,114 @@ 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 {@link 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) { + 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(); + } + + \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(); +} diff --git a/studentview.controller.php b/studentview.controller.php index bfe8b001..d57f7ae9 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); diff --git a/templates/mobile_book_slot.mustache b/templates/mobile_book_slot.mustache new file mode 100644 index 00000000..0f1dd766 --- /dev/null +++ b/templates/mobile_book_slot.mustache @@ -0,0 +1,63 @@ +{{=<% %>=}} + +
    + + + +

    <% 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 %> + + + + + + + +
    +
    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_slot.mustache b/templates/mobile_slot.mustache new file mode 100644 index 00000000..88a07216 --- /dev/null +++ b/templates/mobile_slot.mustache @@ -0,0 +1,126 @@ +{{=<% %>=}} + +
    + + + +

    <% 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 %> + + <%# isstudent %> + + <%# 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 %> + + <%/ appointment %> + + <%^ appointment %> + <%# slot.canbookslot %> + + <%# scheduler.usesbookingform %> + + <%/ scheduler.usesbookingform %> + <%^ scheduler.usesbookingform %> + <%# scheduler.isgroupbookingenabled %> + + + <%/ scheduler.isgroupbookingenabled %> + <%^ scheduler.isgroupbookingenabled %> + + <%/ scheduler.isgroupbookingenabled %> + <%/ scheduler.usesbookingform %> + + <%/ slot.canbookslot %> + + <%# slot.canwatchslot %> + <%^ slot.iswatching %> + + + + <%/ slot.iswatching %> + <%# slot.iswatching %> + + + + <%/ slot.iswatching %> + <%/ slot.canwatchslot %> + <%/ appointment %> + + <%/ isstudent %> + +
    +
    diff --git a/templates/mobile_student_bookable_slots.mustache b/templates/mobile_student_bookable_slots.mustache new file mode 100644 index 00000000..c7a1733e --- /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..9b9e0a25 --- /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/version.php b/version.php index d144339c..8403e93c 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 = 2023050808; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050813; // 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 20f837f2..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') { From e001a3af290c3283be82001fcf4a124b15c83279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 12 Feb 2020 10:33:44 +0800 Subject: [PATCH 12/29] Suggest to visit the site when booking not supported or to edit/cancel --- classes/external.php | 26 ++++++++- lang/en/scheduler.php | 4 ++ templates/mobile_book_slot.mustache | 5 ++ templates/mobile_slot.mustache | 89 +++++++++++++++++++---------- 4 files changed, 90 insertions(+), 34 deletions(-) diff --git a/classes/external.php b/classes/external.php index 47cc9d99..a943f48c 100644 --- a/classes/external.php +++ b/classes/external.php @@ -33,6 +33,7 @@ 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; @@ -157,6 +158,10 @@ public static function book_slot($cmid, $slotid, $groupid = 0, $bookingdata = [] } } + if (!$scheduler->is_individual_scheduling_enabled() && !$groupid) { + throw new moodle_exception('choosegrouptobook', 'mod_scheduler'); + } + $formdata = (object) []; if ($scheduler->uses_studentnotes()) { $formdata->studentnote_editor = [ @@ -533,6 +538,10 @@ protected static function appointment_structure() { 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(), @@ -555,6 +564,8 @@ public static function serialize_scheduler(scheduler $scheduler) { '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, @@ -586,6 +597,11 @@ public static function serialize_slot(slot $slot) { $canwatchslot = $canwatchslots && $slot->is_watchable_by_student($USER->id); $iswatching = $canwatchslot && $slot->is_watched_by_student($USER->id); + $appointments = array_map(function($app) { + return static::serialize_appointment($app); + }, $slot->get_appointments(($canseeothers && $slot->is_groupslot()) ? null : [$USER->id])); + $hasappointments = !empty($appointments); + return [ 'id' => $slot->id, @@ -599,9 +615,9 @@ public static function serialize_slot(slot $slot) { 'mod_scheduler', 'slotnote', $slot->id)[0], 'hasnotes' => !static::is_empty($slot->notes), - 'appointments' => array_map(function($app) { - return static::serialize_appointment($app); - }, $slot->get_appointments($canseeothers ? [$USER->id] : null)), + 'appointments' => $appointments, + 'hasappointments' => $hasappointments, + 'appointmentlocation' => $slot->appointmentlocation, 'hasappointmentlocation' => !static::is_empty($slot->appointmentlocation), @@ -610,6 +626,7 @@ public static function serialize_slot(slot $slot) { '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, @@ -648,6 +665,8 @@ protected static function slot_structure() { 'appointments' => new extenral_multiple_structure( static::appointment_structure() ), + 'hasappointments' => new external_value(PARAM_BOOL), + 'appointmentlocation' => new external_value(PARAM_RAW), 'hasappointmentlocation' => new external_value(PARAM_BOOL), @@ -655,6 +674,7 @@ protected static function slot_structure() { '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), diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index b84240ec..dae3b9de 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -169,6 +169,7 @@ $string['canbookunlimitedappointments'] = 'You can book any number of appointments in this scheduler.'; $string['cannotscheduleslotforothers'] = 'You cannot schedule appointments for other staff members.'; $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'; @@ -558,6 +559,9 @@ $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['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['wednesday'] = 'Wednesday'; $string['welcomebackstudent'] = 'You can book additional slots by clicking on the corresponding "Book slot" button below.'; diff --git a/templates/mobile_book_slot.mustache b/templates/mobile_book_slot.mustache index 0f1dd766..7864e3bf 100644 --- a/templates/mobile_book_slot.mustache +++ b/templates/mobile_book_slot.mustache @@ -46,6 +46,7 @@ <%/ scheduler.usesstudentnotes %> + @@ -59,5 +60,9 @@ + +

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

    +
    + diff --git a/templates/mobile_slot.mustache b/templates/mobile_slot.mustache index 88a07216..6337defb 100644 --- a/templates/mobile_slot.mustache +++ b/templates/mobile_slot.mustache @@ -46,30 +46,44 @@ <%/ hasstudentnote %> <%# scheduler.usesgrades %> - -

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

    -

    <% gradeformatted %>

    -
    + +

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

    +

    <% gradeformatted %>

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

    <% student.fullname %>

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

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

    +
    + <%/ slot.iseditable %> + <%/ appointment %> <%^ appointment %> <%# slot.canbookslot %> - - <%# scheduler.usesbookingform %> - - <%/ scheduler.usesbookingform %> - <%^ scheduler.usesbookingform %> - <%# scheduler.isgroupbookingenabled %> - + <%^ scheduler.isinappbookingsupported %> + + + <%# str %>visitwebtobook, mod_scheduler<%/ str %> + + + <%/ scheduler.isinappbookingsupported %> + <%# scheduler.isinappbookingsupported %> + + <%# scheduler.usesbookingform %> - <%/ scheduler.isgroupbookingenabled %> - <%^ scheduler.isgroupbookingenabled %> - - <%/ scheduler.isgroupbookingenabled %> - <%/ scheduler.usesbookingform %> - + <%/ scheduler.usesbookingform %> + <%^ scheduler.usesbookingform %> + <%# scheduler.isgroupbookingenabled %> + + + <%/ scheduler.isgroupbookingenabled %> + <%^ scheduler.isgroupbookingenabled %> + + <%/ scheduler.isgroupbookingenabled %> + <%/ scheduler.usesbookingform %> + + <%/ scheduler.isinappbookingsupported %> <%/ slot.canbookslot %> <%# slot.canwatchslot %> From 8ec25713ee068ba07a15bd0a4a30b5a02024af26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 12 Feb 2020 17:22:36 +0800 Subject: [PATCH 13/29] Teachers can view their slots, mark as seen and grade --- classes/external.php | 282 +++++++++++++++--- classes/output/mobile.php | 215 +++++++++++-- classes/slots_query_builder.php | 25 +- db/services.php | 28 +- lang/en/scheduler.php | 2 + locallib.php | 35 ++- studentview.controller.php | 2 +- templates/mobile_slot.mustache | 153 ---------- templates/mobile_student_slot.mustache | 151 ++++++++++ templates/mobile_teacher_appointment.mustache | 105 +++++++ .../mobile_teacher_landing_page.mustache | 31 ++ templates/mobile_teacher_slot.mustache | 51 ++++ templates/mobile_teacher_slots.mustache | 37 +++ version.php | 2 +- 14 files changed, 871 insertions(+), 248 deletions(-) delete mode 100644 templates/mobile_slot.mustache create mode 100644 templates/mobile_student_slot.mustache create mode 100644 templates/mobile_teacher_appointment.mustache create mode 100644 templates/mobile_teacher_landing_page.mustache create mode 100644 templates/mobile_teacher_slot.mustache create mode 100644 templates/mobile_teacher_slots.mustache diff --git a/classes/external.php b/classes/external.php index a943f48c..127d640f 100644 --- a/classes/external.php +++ b/classes/external.php @@ -26,10 +26,11 @@ namespace mod_scheduler; defined('MOODLE_INTERNAL') || die(); +use coding_exception; use core_user; use external_api; use external_function_parameters; -use extenral_multiple_structure; +use external_multiple_structure; use external_single_structure; use external_value; use moodle_exception; @@ -40,6 +41,7 @@ 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'); @@ -54,6 +56,48 @@ */ 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. * @@ -84,7 +128,7 @@ public static function booking_form_viewed($cmid) { $permissions->ensure($permissions->is_student()); \mod_scheduler\event\booking_form_viewed::create_from_scheduler($scheduler)->trigger(); - return null; + return true; } /** @@ -93,7 +137,7 @@ public static function booking_form_viewed($cmid) { * @return external_value */ public static function booking_form_viewed_returns() { - return new external_value(null); + return new external_value(PARAM_BOOL); } /** @@ -114,7 +158,7 @@ public static function book_slot_parameters() { } /** - * Trigger booking form viewed event. + * Book a slot. * * @param int $cmid The cmid. * @param int $slotid The slot ID. @@ -265,7 +309,7 @@ public static function get_available_slots_returns() { '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 extenral_multiple_structure(static::slot_structure(), 'The slots'), + 'slots' => new external_multiple_structure(static::slot_structure(), 'The slots'), 'total' => new external_value(PARAM_INT, 'The total number of slots in the set.'), ]); } @@ -333,7 +377,69 @@ public static function revoke_appointment_returns() { * * @return external_function_parameters */ - public static function watch_slot_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), @@ -347,10 +453,10 @@ public static function watch_slot_parameters() { * @param int $slotid The slot ID.. * @return true */ - public static function watch_slot($cmid, $slotid) { + public static function unwatch_slot($cmid, $slotid) { global $USER; - $params = self::validate_parameters(self::watch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); + $params = self::validate_parameters(self::unwatch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); $cmid = $params['cmid']; $slotid = $params['slotid']; @@ -362,20 +468,14 @@ public static function watch_slot($cmid, $slotid) { $permissions->ensure($permissions->is_student()); require_capability('mod/scheduler:watchslots', $context); - if (!$scheduler->is_watching_enabled()) { - throw new moodle_exception('error'); - } - $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); + $watcher = $slot->remove_watcher($USER->id); if ($watcher) { - \mod_scheduler\event\slot_watched::create_from_watcher($watcher)->trigger(); + \mod_scheduler\event\slot_unwatched::create_from_watcher($watcher)->trigger(); } return true; @@ -386,7 +486,7 @@ public static function watch_slot($cmid, $slotid) { * * @return external_value */ - public static function watch_slot_returns() { + public static function unwatch_slot_returns() { return new external_value(PARAM_BOOL); } @@ -395,7 +495,7 @@ public static function watch_slot_returns() { * * @return external_function_parameters */ - public static function unwatch_slot_parameters() { + public static function watch_slot_parameters() { return new external_function_parameters([ 'cmid' => new external_value(PARAM_INT), 'slotid' => new external_value(PARAM_INT), @@ -409,10 +509,10 @@ public static function unwatch_slot_parameters() { * @param int $slotid The slot ID.. * @return true */ - public static function unwatch_slot($cmid, $slotid) { + public static function watch_slot($cmid, $slotid) { global $USER; - $params = self::validate_parameters(self::unwatch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); + $params = self::validate_parameters(self::watch_slot_parameters(), ['cmid' => $cmid, 'slotid' => $slotid]); $cmid = $params['cmid']; $slotid = $params['slotid']; @@ -424,14 +524,20 @@ public static function unwatch_slot($cmid, $slotid) { $permissions->ensure($permissions->is_student()); require_capability('mod/scheduler:watchslots', $context); + if (!$scheduler->is_watching_enabled()) { + throw new moodle_exception('error'); + } + $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->remove_watcher($USER->id); + $watcher = $slot->add_watcher($USER->id); if ($watcher) { - \mod_scheduler\event\slot_unwatched::create_from_watcher($watcher)->trigger(); + \mod_scheduler\event\slot_watched::create_from_watcher($watcher)->trigger(); } return true; @@ -442,7 +548,7 @@ public static function unwatch_slot($cmid, $slotid) { * * @return external_value */ - public static function unwatch_slot_returns() { + public static function watch_slot_returns() { return new external_value(PARAM_BOOL); } @@ -453,47 +559,88 @@ public static function unwatch_slot_returns() { * @param bool $includeteachernote Whether to include the teacher's note. * @return array */ - public static function serialize_appointment(appointment $app, $includeteachernote = false, $renderer = null) { - global $PAGE; + public static function serialize_appointment(appointment $app) { + global $PAGE, $USER; $context = $app->get_scheduler()->get_context(); - $renderer = $renderer ?: $PAGE->get_renderer('mod_scheduler'); + $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; - if ($includeteachernote) { + // 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->appointmentnote, $app->appointmentnoteformat, $context->id, + $teachernoteformatted = external_format_text($app->teachernote, $app->teachernoteformat, $context->id, 'mod_scheduler', 'teachernote', $app->id)[0]; } return [ 'id' => $app->id, - 'appointmentnote' => $app->appointmentnote, - 'appointmentnoteformat' => $app->appointmentnoteformat, - 'appointmentnoteformatted' => external_format_text($app->appointmentnote, $app->appointmentnoteformat, $context->id, - 'mod_scheduler', 'appointmentnote', $app->id)[0], - 'hasappointmentnote' => !static::is_empty($app->appointmentnote), + '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, - 'studentnote' => $app->studentnote, - 'studentnoteformat' => $app->studentnoteformat, - 'studentnoteformatted' => external_format_text($app->studentnote, $app->studentnoteformat, $context->id)[0], - 'hasstudentnote' => !static::is_empty($app->studentnote), + '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) + 'student' => static::serialize_user($app->student), + 'weburl' => $weburl->out(false) ]; } @@ -515,6 +662,9 @@ protected static function appointment_structure() { '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), @@ -526,6 +676,52 @@ protected static function appointment_structure() { '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), ]); } @@ -587,6 +783,7 @@ 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); @@ -599,7 +796,12 @@ public static function serialize_slot(slot $slot) { $appointments = array_map(function($app) { return static::serialize_appointment($app); - }, $slot->get_appointments(($canseeothers && $slot->is_groupslot()) ? null : [$USER->id])); + }, 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 [ @@ -662,7 +864,7 @@ protected static function slot_structure() { 'notesformatted' => new external_value(PARAM_HTML), 'hasnotes' => new external_value(PARAM_BOOL), - 'appointments' => new extenral_multiple_structure( + 'appointments' => new external_multiple_structure( static::appointment_structure() ), 'hasappointments' => new external_value(PARAM_BOOL), diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 2a51388b..ca8d62ab 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -29,6 +29,7 @@ use moodle_exception; use mod_scheduler_renderer as renderer; use mod_scheduler\external; +use mod_scheduler\slots_query_builder; use mod_scheduler\model\scheduler; use mod_scheduler\permission\scheduler_permissions; @@ -42,6 +43,12 @@ */ 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; @@ -70,6 +77,64 @@ protected static function get_common_data(scheduler $scheduler) { ]; } + /** + * 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; @@ -118,6 +183,12 @@ public static function book_slot($args) { ]; } + /** + * Landing page. + * + * @param array $args The args. + * @return array + */ public static function landing_page($args) { $args = (object) $args; @@ -127,6 +198,7 @@ public static function landing_page($args) { $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); } @@ -144,6 +216,12 @@ public static function landing_page($args) { ]; } + /** + * Slot view. + * + * @param array $args The arguments. + * @return array + */ public static function slot($args) { global $USER; @@ -161,24 +239,24 @@ public static function slot($args) { $isteacher = $permissions->is_teacher(); $permissions->ensure($isstudent || $isteacher); - $slot = $scheduler->get_slot($slotid); $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, - 'isstudent' => $isstudent, - 'isteacher' => $isteacher, ]); return [ 'templates' => [ [ - 'id' => 'slot', - 'html' => $renderer->render_from_template('mod_scheduler/mobile_slot', $data) + 'id' => $template, + 'html' => $renderer->render_from_template('mod_scheduler/' . $template, $data) ] ], 'javascript' => '', @@ -205,7 +283,6 @@ public static function student_bookable_slots($args) { $permissions->ensure($permissions->is_student()); require_capability('mod/scheduler:viewslots', $context); require_capability('mod/scheduler:appoint', $context); - $canwatch = has_capability('mod/scheduler:watchslots', $context) && $scheduler->is_watching_enabled(); $page = isset($args->page) ? (int) $args->page : 1; $result = (object) external::get_available_slots($scheduler->get_cmid(), $page); @@ -257,14 +334,7 @@ public static function student_landing_page($args, $renderer, scheduler $schedul // Find the upcoming slots. $upcomingslotsraw = $scheduler->get_upcoming_slots_for_student($userid); - $upcomingslots = array_map(function($slot) use ($context, $scheduler, $userid) { - // $appointment = $slot->get_student_appointment($userid); - // $cancancel = $slot->is_in_bookable_period(); - // $canedit = $cancancel && $scheduler->uses_studentdata(); - // $canview = !$cancancel && $scheduler->uses_studentdata(); - // if ($scheduler->is_group_scheduling_enabled()) { - // $cancancel = $cancancel && ($appointgroup >= 0); - // } + $upcomingslots = array_map(function($slot) { return external::serialize_slot($slot); }, $upcomingslotsraw); @@ -319,38 +389,123 @@ public static function student_landing_page($args, $renderer, scheduler $schedul } /** - * Watch a slot. + * Teacher landing page. * - * @param array $args The arguments. + * @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 watch_slot($args) { + public static function teacher_landing_page($args, $renderer, scheduler $scheduler, scheduler_permissions $permissions) { global $USER; - $args = (object) $args; - $slotid = (int) $args->id; - - $pre = static::pre($args); + $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); + + $totalslots = $scheduler->count_slots_for_teacher($userid); + $slots = array_map('mod_scheduler\external::serialize_slot', array_merge( + array_reverse($scheduler->get_slots_from_query_builder($recentslotsqb)), + $scheduler->get_slots_from_query_builder($upcomingslotsqb) + )); - external::watch_slot($args->cmid, $slotid); + $data = array_merge(static::get_common_data($scheduler), [ + 'hasslots' => !empty($slots), + 'slots' => $slots, + 'hasmore' => count($slots) < $totalslots, + ]); - return static::slot($args); + return [ + 'templates' => [ + [ + 'id' => 'teacher_landing_page', + 'html' => $renderer->render_from_template('mod_scheduler/mobile_teacher_landing_page', $data) + ] + ], + 'javascript' => '', + 'otherdata' => '', + 'files' => [], + ]; } /** - * Unwatch a slot. + * Teacher slots. * - * @param array $args The arguments. + * @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 unwatch_slot($args) { + public static function teacher_slots($args) { global $USER; $args = (object) $args; - $slotid = (int) $args->id; - $pre = static::pre($args); - external::unwatch_slot($args->cmid, $slotid); - return static::slot($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/slots_query_builder.php b/classes/slots_query_builder.php index 6afba92a..7568a111 100644 --- a/classes/slots_query_builder.php +++ b/classes/slots_query_builder.php @@ -26,6 +26,7 @@ namespace mod_scheduler; defined('MOODLE_INTERNAL') || die(); +use coding_exception; use core\dml\sql_join; use mod_scheduler\model\scheduler; @@ -56,6 +57,8 @@ class slots_query_builder { 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; @@ -152,10 +155,13 @@ public function filter_location($query) { * * @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) { + 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']); @@ -166,6 +172,13 @@ public function filter_starttime($timestamp, $operator = self::OPERATOR_ON) { $sql = '1=1'; $params = ['filterstarttime' => $timestamp]; + // Convert the operator ON to BETWEEN. + if ($operator === self::OPERATOR_ON) { + $operator = self::OPREATOR_BETWEEN; + $timestamp = usergetmidnight($timestamp); + $timestampend = $timestamp + DAYSECS; + } + switch ($operator) { case self::OPERATOR_AT: $sql = "{$this->prefix}starttime = :filterstarttime"; @@ -176,15 +189,15 @@ public function filter_starttime($timestamp, $operator = self::OPERATOR_ON) { case self::OPERATOR_BEFORE: $sql = "{$this->prefix}starttime < :filterstarttime"; break; - case self::OPERATOR_ON: - default: + case self::OPERATOR_BETWEEN: $sql = "{$this->prefix}starttime > :filterstarttime AND {$this->prefix}starttime < :filterstarttimeend"; - $startofday = usergetmidnight($timestamp); $params = [ - 'filterstarttime' => $startofday, - 'filterstarttimeend' => $startofday + DAYSECS + 'filterstarttime' => $timestamp, + 'filterstarttimeend' => $timestampend ]; break; + default: + throw new coding_exception('Unexpected operator'); } $this->wheres['filterstarttime'] = $sql; diff --git a/db/services.php b/db/services.php index 5c7b92e0..f690372e 100644 --- a/db/services.php +++ b/db/services.php @@ -26,6 +26,13 @@ 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', @@ -47,18 +54,25 @@ 'type' => 'write', 'ajax' => true, ], - 'mod_scheduler_watch_slot' => [ + 'mod_scheduler_unwatch_slot' => [ 'classname' => 'mod_scheduler\\external', - 'methodname' => 'watch_slot', - 'description' => 'Watch a slot', + 'methodname' => 'unwatch_slot', + 'description' => 'Unwatch a slot', 'type' => 'write', 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE, 'local_mobile'] ], - 'mod_scheduler_unwatch_slot' => [ + 'mod_scheduler_update_appointment' => [ 'classname' => 'mod_scheduler\\external', - 'methodname' => 'unwatch_slot', - 'description' => 'Unwatch a slot', + '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/lang/en/scheduler.php b/lang/en/scheduler.php index dae3b9de..4e03daa7 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -441,6 +441,7 @@ $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'; @@ -559,6 +560,7 @@ $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.'; diff --git a/locallib.php b/locallib.php index 0c9b5b09..41a2aae0 100644 --- a/locallib.php +++ b/locallib.php @@ -424,16 +424,7 @@ function mod_scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $formda $appointment->save(); if ($studentid == $userid && $formdata) { - 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(); + mod_scheduler_save_booking_data($appointment, $formdata); } \mod_scheduler\event\booking_added::create_from_slot($slot)->trigger(); @@ -449,3 +440,27 @@ function mod_scheduler_book_slot($scheduler, $slotid, $userid, $groupid, $formda $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/studentview.controller.php b/studentview.controller.php index d57f7ae9..1de0d0bb 100644 --- a/studentview.controller.php +++ b/studentview.controller.php @@ -209,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/templates/mobile_slot.mustache b/templates/mobile_slot.mustache deleted file mode 100644 index 6337defb..00000000 --- a/templates/mobile_slot.mustache +++ /dev/null @@ -1,153 +0,0 @@ -{{=<% %>=}} - -
    - - - -

    <% 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 %> - - <%# isstudent %> - - <%# 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.hasappointments %> - - <%# str %>students, mod_scheduler<%/ str %> - - <%# slot.appointments %> - - -

    <% student.fullname %>

    -
    - <%/ slot.appointments %> - <%/ slot.hasappointments %> - - <%# 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 %> - - <%/ isstudent %> - -
    -
    diff --git a/templates/mobile_student_slot.mustache b/templates/mobile_student_slot.mustache new file mode 100644 index 00000000..d7eca515 --- /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..35ca4235 --- /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..3e7c3389 --- /dev/null +++ b/templates/mobile_teacher_landing_page.mustache @@ -0,0 +1,31 @@ +{{=<% %>=}} + + + +
    + +

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

    + + <%^ hasslots %> +

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

    + <%/ hasslots %> + <%# hasslots %> + <%# slots %> + + <%/ slots %> + <%# hasmore %> + + + + <%/ hasmore %> + <%/ 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..210e2e53 --- /dev/null +++ b/templates/mobile_teacher_slots.mustache @@ -0,0 +1,37 @@ +{{=<% %>=}} + +
    + + <%#hasprev%> + + + + <%/hasprev%> + <%#slots%> + + <%/slots%> + <%#hasnext%> + + + + <%/hasnext%> + + + + +
    diff --git a/version.php b/version.php index 8403e93c..6330ff6a 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 = 2023050813; // The current module version (Date: YYYYMMDDXX). +$plugin->version = 2023050815; // 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. From 46803b7bc7a5190e8987cc0f5af49ff552a2986a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 12 Feb 2020 17:44:51 +0800 Subject: [PATCH 14/29] Refresh the slot page when it loads to update data --- classes/output/mobile.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/classes/output/mobile.php b/classes/output/mobile.php index ca8d62ab..b02f3bd2 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -259,7 +259,14 @@ public static function slot($args) { 'html' => $renderer->render_from_template('mod_scheduler/' . $template, $data) ] ], - 'javascript' => '', + // 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' => [], ]; From 553031013ca86bffc584e0c971fd61b791de1e49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 11:42:08 +0800 Subject: [PATCH 15/29] Teachers can browse past slots when none are upcoming --- classes/output/mobile.php | 18 ++++++++++++++---- lang/en/scheduler.php | 1 + templates/mobile_teacher_landing_page.mustache | 10 +++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/classes/output/mobile.php b/classes/output/mobile.php index b02f3bd2..3e7341a8 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -425,15 +425,25 @@ public static function teacher_landing_page($args, $renderer, scheduler $schedul $upcomingslotsqb->add_order_by('starttime', SORT_ASC); $upcomingslotsqb->set_limit(7, 0); - $totalslots = $scheduler->count_slots_for_teacher($userid); - $slots = array_map('mod_scheduler\external::serialize_slot', array_merge( + $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' => $slots, + 'slots' => array_map('mod_scheduler\external::serialize_slot', $slots), 'hasmore' => count($slots) < $totalslots, ]); diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 4e03daa7..2836f825 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -387,6 +387,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'; diff --git a/templates/mobile_teacher_landing_page.mustache b/templates/mobile_teacher_landing_page.mustache index 3e7c3389..f360eb58 100644 --- a/templates/mobile_teacher_landing_page.mustache +++ b/templates/mobile_teacher_landing_page.mustache @@ -4,7 +4,15 @@
    -

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

    +

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

    + <%^ hasslots %>

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

    From 879e7969d749e9f16d6443e81612520b6349ed54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 11:51:32 +0800 Subject: [PATCH 16/29] Display a better message when teacher does not have any slots --- lang/en/scheduler.php | 1 + .../mobile_teacher_landing_page.mustache | 59 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 2836f825..9a03f4d3 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -398,6 +398,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. '; diff --git a/templates/mobile_teacher_landing_page.mustache b/templates/mobile_teacher_landing_page.mustache index f360eb58..afada4d8 100644 --- a/templates/mobile_teacher_landing_page.mustache +++ b/templates/mobile_teacher_landing_page.mustache @@ -4,35 +4,40 @@
    -

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

    + <%^ hasslots %> +

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

    + <%/ hasslots %> - - <%^ hasslots %> -

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

    - <%/ hasslots %> - <%# hasslots %> - <%# slots %> - - <%/ slots %> - <%# hasmore %> - - - - <%/ hasmore %> - <%/ hasslots %> -
    + <%/ slots %> + <%# hasmore %> + + + + <%/ hasmore %> + <%/ hasslots %> + + <%/ hasslots %> From e5b4250a416a1a2a4e8244a2572ab5a62c9d128e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 11:51:57 +0800 Subject: [PATCH 17/29] Loosen check for whether teacher can import for others --- classes/csv_slots_importer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php index 90b9c0cc..3c8ecd01 100644 --- a/classes/csv_slots_importer.php +++ b/classes/csv_slots_importer.php @@ -263,7 +263,7 @@ protected function validate_data($data) { } 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()) { + 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())) { From 64bb531dd3cfccc71f4786257092836f7736e7d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 11:53:12 +0800 Subject: [PATCH 18/29] Update description of slots in example CSV import file --- tests/fixtures/slots.csv | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/fixtures/slots.csv b/tests/fixtures/slots.csv index 7b0f7ae0..b0df9037 100644 --- a/tests/fixtures/slots.csv +++ b/tests/fixtures/slots.csv @@ -1,4 +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 2nd 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 2nd 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 2nd 2020 allowing up to 10 students +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 From 260963f853e3fe00c7e3eb172704caf8fe0a954e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 12:05:29 +0800 Subject: [PATCH 19/29] Fix issue with filtering 'on' start time and by teacher --- classes/output/slots_filter_form.php | 2 +- classes/slots_query_builder.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/classes/output/slots_filter_form.php b/classes/output/slots_filter_form.php index b3e9e483..6943fc43 100644 --- a/classes/output/slots_filter_form.php +++ b/classes/output/slots_filter_form.php @@ -68,7 +68,7 @@ public function definition() { return fullname($user); }, $scheduler->get_teachers()); core_collator::asort($teacheroptions); - $teacheroptions = array_merge([0 => get_string('choosedots')], $teacheroptions); + $teacheroptions = [0 => get_string('choosedots')] + $teacheroptions; $mform->addElement('select', 'tfteacherid', $scheduler->get_teacher_name(), $teacheroptions); } diff --git a/classes/slots_query_builder.php b/classes/slots_query_builder.php index 7568a111..ac447a3b 100644 --- a/classes/slots_query_builder.php +++ b/classes/slots_query_builder.php @@ -174,7 +174,7 @@ public function filter_starttime($timestamp, $operator = self::OPERATOR_ON, $tim // Convert the operator ON to BETWEEN. if ($operator === self::OPERATOR_ON) { - $operator = self::OPREATOR_BETWEEN; + $operator = self::OPERATOR_BETWEEN; $timestamp = usergetmidnight($timestamp); $timestampend = $timestamp + DAYSECS; } From 7cfc619e6f9025a25d8ae2156c6d020a37a70ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 13:11:15 +0800 Subject: [PATCH 20/29] Include canwatch property when backing up the activity --- backup/moodle2/backup_scheduler_stepslib.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backup/moodle2/backup_scheduler_stepslib.php b/backup/moodle2/backup_scheduler_stepslib.php index 4a7c2942..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', 'completionattended')); + 'usecaptcha', 'timemodified', 'completionattended', 'canwatch')); $slots = new backup_nested_element('slots'); From 72b418b9e1cf2db52ec541e956c928547d0d0294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 13:12:07 +0800 Subject: [PATCH 21/29] Proper handling of missing or invalid CSV column data --- classes/csv_slots_importer.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php index 3c8ecd01..fa62d9c0 100644 --- a/classes/csv_slots_importer.php +++ b/classes/csv_slots_importer.php @@ -85,15 +85,15 @@ protected function convert_line($line) { try { $time = new DateTime($line['time']); } catch (\Exception $e) { - $date = new DateTime('@0'); + $time = new DateTime('@0'); } $duration = (int) $line['duration']; // Optional columns. $maxstudents = !empty($line['maxstudents']) ? (int) $line['maxstudents'] : 0; - $location = $line['location'] ?: null; - $teacher = $line['teacher'] ?: null; - $comment = $line['comment'] ?: null; + $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. From cf532e9f549bd775b4ec350daee51cb9cb9b5afb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 13:12:46 +0800 Subject: [PATCH 22/29] Student notes were mistakenly reported as being required --- classes/model/scheduler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index a8f30314..70941884 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -320,7 +320,7 @@ public function is_studentfiles_required() { * @return bool */ public function is_studentnotes_required() { - return $this->uses_studentnotes() && $this->usestudentnotes; + return $this->uses_studentnotes() && $this->usestudentnotes == 2; } /** From f64db89359c19f924d968be60efedf307a444949 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 14 Feb 2020 13:19:11 +0800 Subject: [PATCH 23/29] Various stylistic changes to please some code checkers --- classes/csv_slots_importer.php | 3 +-- classes/event/slot_unwatched.php | 2 +- classes/event/slot_watched.php | 2 +- classes/external.php | 1 - classes/model/appointment.php | 4 ++-- classes/model/scheduler.php | 2 +- classes/model/slot.php | 3 +-- classes/model/watcher.php | 1 - classes/output/datetime_filter.php | 6 +++--- classes/output/import_csv_form.php | 2 +- classes/output/import_csv_options_form.php | 2 +- classes/output/mobile.php | 3 --- classes/privacy/provider.php | 2 +- 13 files changed, 13 insertions(+), 20 deletions(-) diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php index fa62d9c0..57fb2845 100644 --- a/classes/csv_slots_importer.php +++ b/classes/csv_slots_importer.php @@ -94,7 +94,7 @@ protected function convert_line($line) { $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(); + $displayfrom = !empty($line['displayfrom']) ? new DateTime($line['displayfrom']) : new DateTime(); // Massaging the data. $date->setTime($time->format('H'), $time->format('i'), 0, 0); @@ -271,7 +271,6 @@ protected function validate_data($data) { } } - return $errors; } diff --git a/classes/event/slot_unwatched.php b/classes/event/slot_unwatched.php index f5916b49..e8a39c2c 100644 --- a/classes/event/slot_unwatched.php +++ b/classes/event/slot_unwatched.php @@ -39,7 +39,7 @@ class slot_unwatched extends \core\event\base { /** * Create this event from a watcher. * - * @param \mod_scheduler\model\watcher $slot + * @param \mod_scheduler\model\watcher $watcher The watcher. * @return \core\event\base */ public static function create_from_watcher(\mod_scheduler\model\watcher $watcher) { diff --git a/classes/event/slot_watched.php b/classes/event/slot_watched.php index 07fce91e..fa8bd061 100644 --- a/classes/event/slot_watched.php +++ b/classes/event/slot_watched.php @@ -39,7 +39,7 @@ class slot_watched extends \core\event\base { /** * Create this event from a watcher. * - * @param \mod_scheduler\model\watcher $slot + * @param \mod_scheduler\model\watcher $watcher The watcher. * @return \core\event\base */ public static function create_from_watcher(\mod_scheduler\model\watcher $watcher) { diff --git a/classes/external.php b/classes/external.php index 127d640f..0aff96e1 100644 --- a/classes/external.php +++ b/classes/external.php @@ -556,7 +556,6 @@ public static function watch_slot_returns() { * Serialize an appointment. * * @param appointment $app The appointment. - * @param bool $includeteachernote Whether to include the teacher's note. * @return array */ public static function serialize_appointment(appointment $app) { diff --git a/classes/model/appointment.php b/classes/model/appointment.php index c6e3d410..3a6d1c19 100644 --- a/classes/model/appointment.php +++ b/classes/model/appointment.php @@ -37,7 +37,7 @@ */ class appointment extends mvc_child_record_model { - /** @var bool Initial is attended value, defaults to false as does the constructor. */ + /** @var bool Initial 'isattended' value. */ private $initialisattended = null; /** @@ -70,7 +70,7 @@ public function __construct(slot $slot) { 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 + // 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(); diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index 70941884..aa33154e 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -603,7 +603,7 @@ public function completion_requires_attended() { * @param bool $hasattended Whether the user just attended a slot. * @return void */ - public function completion_update_has_attended($userid, $hasattended=false) { + public function completion_update_has_attended($userid, $hasattended = false) { if (!$this->completion_requires_attended()) { return; } diff --git a/classes/model/slot.php b/classes/model/slot.php index 0c6f8d46..16dbff35 100644 --- a/classes/model/slot.php +++ b/classes/model/slot.php @@ -84,7 +84,6 @@ public static function load_by_id($id, scheduler $scheduler) { * Save any changes to the database. */ public function save() { - $savewatchers = false; $this->data->schedulerid = $this->get_parent()->get_id(); // Compute whether children were removed while the slot was full, @@ -317,7 +316,7 @@ public function is_watchable() { * a requirement, but also that the student does not already have a * booking in this slot. * - * @param int $studentid The student ID. + * @param int $userid The student ID. * @return bool */ public function is_watchable_by_student($userid) { diff --git a/classes/model/watcher.php b/classes/model/watcher.php index 6e822222..781d9f26 100644 --- a/classes/model/watcher.php +++ b/classes/model/watcher.php @@ -89,7 +89,6 @@ public function get_user() { /** * Notify. * - * @param stdClass $teacher The teacher. * @return void */ public function notify() { diff --git a/classes/output/datetime_filter.php b/classes/output/datetime_filter.php index 18c31007..2737baff 100644 --- a/classes/output/datetime_filter.php +++ b/classes/output/datetime_filter.php @@ -84,15 +84,15 @@ public function _createElements() { /** * Export value. * - * @param array $submitValues The values. + * @param array $submitvalues The values. * @param bool $notused Not used. * @return array field name => value. The value is the time interval in seconds. */ - function exportValue(&$submitValues, $notused = false) { + function exportValue(&$submitvalues, $notused = false) { // Get the values from all the child elements. $values = []; foreach ($this->_elements as $element) { - $thisexport = $element->exportValue($submitValues[$this->getName()], true); + $thisexport = $element->exportValue($submitvalues[$this->getName()], true); if ($thisexport !== null && !empty($thisexport[$this->getName()])) { $values += $thisexport[$this->getName()]; } diff --git a/classes/output/import_csv_form.php b/classes/output/import_csv_form.php index fd1a690c..66acc5a3 100644 --- a/classes/output/import_csv_form.php +++ b/classes/output/import_csv_form.php @@ -46,7 +46,7 @@ class import_csv_form extends moodleform { /** * Definition. */ - function definition() { + public function definition() { $mform = $this->_form; $mform->addElement('header', 'settingsheader', get_string('csvfile', 'mod_scheduler')); diff --git a/classes/output/import_csv_options_form.php b/classes/output/import_csv_options_form.php index 9cb05e03..b971e168 100644 --- a/classes/output/import_csv_options_form.php +++ b/classes/output/import_csv_options_form.php @@ -49,7 +49,7 @@ class import_csv_options_form extends moodleform { /** * Definition. */ - function definition() { + public function definition() { $mform = $this->_form; $mform->addElement('hidden', 'iid'); diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 3e7341a8..bdf2aad4 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -464,9 +464,6 @@ public static function teacher_landing_page($args, $renderer, scheduler $schedul * Teacher slots. * * @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_slots($args) { diff --git a/classes/privacy/provider.php b/classes/privacy/provider.php index 99c42d67..d39458e1 100644 --- a/classes/privacy/provider.php +++ b/classes/privacy/provider.php @@ -210,7 +210,7 @@ public static function get_users_in_context(userlist $userlist) { WHERE cm.id = :cmid"; $params = [ 'modname' => 'scheduler', - 'cmid'=> $context->instanceid + 'cmid' => $context->instanceid ]; $userlist->add_from_sql('userid', $sql, $params); From 67582824f5276768b3e16a3e1969bd5c469867e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 19 Feb 2020 16:04:16 +0800 Subject: [PATCH 24/29] Forgetting string parameter caused a rendering error in mobile --- classes/output/mobile.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/output/mobile.php b/classes/output/mobile.php index bdf2aad4..390a5c9b 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -363,7 +363,7 @@ public static function student_landing_page($args, $renderer, scheduler $schedul $msgkey = ($scheduler->schedulermode == 'oneonly') ? 'canbooksingleappointment' : 'canbook1appointment'; $bookingmessage = get_string($msgkey, 'mod_scheduler'); } else if ($nobookingsremaining > 1) { - $bookingmessage = get_string('canbooknappointments', 'mod_scheduler'); + $bookingmessage = get_string('canbooknappointments', 'mod_scheduler', $nobookingsremaining); } else if ($nobookingsremaining < 0) { $bookingmessage = get_string('canbookunlimitedappointments', 'mod_scheduler'); } From 0dc53bd67cd724a5399ada309979863a5a786eb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Wed, 19 Feb 2020 16:24:31 +0800 Subject: [PATCH 25/29] On landing page display to teachers the number of slots taken --- classes/external.php | 2 +- templates/mobile_teacher_landing_page.mustache | 11 +++++++++++ templates/mobile_teacher_slots.mustache | 3 +++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/classes/external.php b/classes/external.php index 0aff96e1..9310008e 100644 --- a/classes/external.php +++ b/classes/external.php @@ -833,7 +833,7 @@ public static function serialize_slot(slot $slot) { 'isfull' => $nremaining == 0, 'nremaining' => $nremaining, - 'ntaken' => $slot->exclusivity > 0 ? $slot->exclusivity - $nremaining : 0, + 'ntaken' => $slot->exclusivity > 0 ? $slot->exclusivity - $nremaining : $slot->get_appointment_count(), 'maxappointments' => $slot->exclusivity, 'teacher' => static::serialize_user($slot->teacher) diff --git a/templates/mobile_teacher_landing_page.mustache b/templates/mobile_teacher_landing_page.mustache index afada4d8..a3629cde 100644 --- a/templates/mobile_teacher_landing_page.mustache +++ b/templates/mobile_teacher_landing_page.mustache @@ -26,6 +26,17 @@ <%/ slots %> <%# hasmore %> diff --git a/templates/mobile_teacher_slots.mustache b/templates/mobile_teacher_slots.mustache index 210e2e53..02ddf2ce 100644 --- a/templates/mobile_teacher_slots.mustache +++ b/templates/mobile_teacher_slots.mustache @@ -13,6 +13,9 @@ - +

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

    diff --git a/templates/mobile_student_slot.mustache b/templates/mobile_student_slot.mustache index d7eca515..3c7b711a 100644 --- a/templates/mobile_student_slot.mustache +++ b/templates/mobile_student_slot.mustache @@ -14,7 +14,7 @@
    <%# slot.hasappointmentlocation %> - +

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

    <% slot.appointmentlocation %>

    @@ -65,7 +65,7 @@ <%/ slot.isexclusive %> <%# slot.iseditable %> - +

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

    <%/ slot.iseditable %> @@ -75,7 +75,7 @@ <%^ appointment %> <%# slot.canbookslot %> <%^ scheduler.isinappbookingsupported %> - + <%# str %>visitwebtobook, mod_scheduler<%/ str %> diff --git a/templates/mobile_teacher_appointment.mustache b/templates/mobile_teacher_appointment.mustache index 35ca4235..e8df332a 100644 --- a/templates/mobile_teacher_appointment.mustache +++ b/templates/mobile_teacher_appointment.mustache @@ -96,7 +96,7 @@ <%/ caneditany %> <%# app.caneditnotes %> - +

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

    <%/ app.caneditnotes %> From d8b2bdada71ff3562280242b5145c314d71d47e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Tue, 25 Feb 2020 10:10:02 +0800 Subject: [PATCH 27/29] Use 'Full' instead of 'Booked' for students viewing fully booked slots --- lang/en/scheduler.php | 1 + studentview.php | 2 +- templates/mobile_student_bookable_slots.mustache | 2 +- templates/mobile_student_landing_page.mustache | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 9a03f4d3..6f5ad82c 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -304,6 +304,7 @@ $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'; diff --git a/studentview.php b/studentview.php index afe5aabb..d49d2308 100644 --- a/studentview.php +++ b/studentview.php @@ -218,7 +218,7 @@ 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; } } diff --git a/templates/mobile_student_bookable_slots.mustache b/templates/mobile_student_bookable_slots.mustache index c7a1733e..ab8e815b 100644 --- a/templates/mobile_student_bookable_slots.mustache +++ b/templates/mobile_student_bookable_slots.mustache @@ -19,7 +19,7 @@ <% ntaken %> / <% maxappointments %> <%/ isfull %> <%# isfull %> - <%# str %>complete, mod_scheduler<%/ str %> + <%# str %>full, mod_scheduler<%/ str %> <%/ isfull %> <%/ isunlimited %> diff --git a/templates/mobile_student_landing_page.mustache b/templates/mobile_student_landing_page.mustache index 9b9e0a25..471c77b9 100644 --- a/templates/mobile_student_landing_page.mustache +++ b/templates/mobile_student_landing_page.mustache @@ -55,7 +55,7 @@ <% ntaken %> / <% maxappointments %> <%/ isfull %> <%# isfull %> - <%# str %>complete, mod_scheduler<%/ str %> + <%# str %>full, mod_scheduler<%/ str %> <%/ isfull %> <%/ isunlimited %> From f18ce5c32a34bdde818ab801f1e1c8540f2ae600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Fri, 6 Mar 2020 18:02:04 +0800 Subject: [PATCH 28/29] Support for limiting the number of slots students can watch --- classes/external.php | 15 +++++++++----- classes/model/scheduler.php | 40 +++++++++++++++++++++++++++++++++++++ lang/en/scheduler.php | 4 ++++ settings.php | 5 +++++ studentview.php | 15 +++++++++----- version.php | 2 +- 6 files changed, 70 insertions(+), 11 deletions(-) diff --git a/classes/external.php b/classes/external.php index 9310008e..8401a53f 100644 --- a/classes/external.php +++ b/classes/external.php @@ -279,8 +279,8 @@ public static function get_available_slots($cmid, $page = 1, $perpage = 25) { if ($canbookslots) { $bookableslotsraw = array_values($scheduler->get_slots_available_to_student($userid, $canwatchslots)); $totalbookableslots = count($bookableslotsraw); - $bookableslots = array_map(function($slot) use ($canbookslots, $canwatchslots, $canseeothers) { - return static::serialize_slot($slot, $canbookslots, $canwatchslots, $canseeothers); + $bookableslots = array_map(function($slot) { + return static::serialize_slot($slot); }, array_slice($bookableslotsraw, ($page - 1) * $perpage, $perpage)); } @@ -526,6 +526,8 @@ public static function watch_slot($cmid, $slotid) { 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); @@ -786,12 +788,15 @@ public static function serialize_slot(slot $slot) { $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(); + $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(); - $canwatchslot = $canwatchslots && $slot->is_watchable_by_student($USER->id); - $iswatching = $canwatchslot && $slot->is_watched_by_student($USER->id); + $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); diff --git a/classes/model/scheduler.php b/classes/model/scheduler.php index aa33154e..af017622 100644 --- a/classes/model/scheduler.php +++ b/classes/model/scheduler.php @@ -323,6 +323,16 @@ 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. * @@ -332,6 +342,36 @@ 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 * diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 6f5ad82c..55f258d3 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -168,6 +168,7 @@ $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'; @@ -367,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'; @@ -568,6 +571,7 @@ $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.'; 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.php b/studentview.php index d49d2308..60498064 100644 --- a/studentview.php +++ b/studentview.php @@ -167,6 +167,7 @@ $bookablecnt = $scheduler->count_bookable_appointments($USER->id, false); $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) { @@ -183,7 +184,6 @@ // Show the booking form. $booker = new scheduler_slot_booker($scheduler, $USER->id, $actionurl, $bookablecnt); - $haswatchableslots = false; $pagesize = 25; $total = count($bookableslots); @@ -223,9 +223,9 @@ } } - $canwatchthisslot = $canwatchslots && $slot->is_watchable_by_student($USER->id); - $iswatching = $canwatchthisslot && $slot->is_watched_by_student($USER->id); - $haswatchableslots = $haswatchableslots || $canwatchthisslot; + $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); } @@ -259,7 +259,12 @@ } if ($canwatchslots) { - echo html_writer::tag('p', get_string('watchslotsintro', 'mod_scheduler')); + $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)); + } } } diff --git a/version.php b/version.php index 6330ff6a..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 = 2023050815; // 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. From 0dc2c86dfb84715005895116b1719c3e8a206ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Massart?= Date: Mon, 8 May 2023 15:20:12 +0800 Subject: [PATCH 29/29] Minor changes for styling and compatibility with Moodle 4.x --- classes/csv_slots_importer.php | 8 ++--- classes/event/slot_unwatched.php | 1 - classes/event/slot_watched.php | 1 - .../local/iterator/csv_reader_iterator.php | 9 +++--- classes/local/iterator/map_iterator.php | 30 ++++++++++++++++--- classes/model/appointment.php | 2 +- classes/model/watcher.php | 1 - classes/model/watcher_factory.php | 2 -- classes/output/datetime_filter.php | 4 +-- classes/output/mobile.php | 2 -- classes/slots_query_builder.php | 1 - classes/task/purge_obsolete_watchers.php | 1 - import.php | 6 ++-- lang/en/scheduler.php | 18 +++++------ locallib.php | 2 +- 15 files changed, 51 insertions(+), 37 deletions(-) diff --git a/classes/csv_slots_importer.php b/classes/csv_slots_importer.php index 57fb2845..43237964 100644 --- a/classes/csv_slots_importer.php +++ b/classes/csv_slots_importer.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler; -defined('MOODLE_INTERNAL') || die(); use core_user; use csv_import_reader; @@ -33,6 +32,7 @@ use mod_scheduler\local\iterator\map_iterator; use mod_scheduler\model\scheduler; use mod_scheduler\permission\scheduler_permissions; +use Traversable; /** * Slots importer from CSV. @@ -130,7 +130,7 @@ protected function get_allowed_teachers() { * * @return \Iterator */ - public function getIterator() { + public function getIterator(): Traversable { // @codingStandardsIgnoreLine return new map_iterator( new csv_reader_iterator($this->cir), function($line, $lineno) { @@ -142,7 +142,7 @@ function($line, $lineno) { /** * Make a slot from a processed line. * - * @param object $info Result from {@link self::process_line}. + * @param object $info Result from {@see self::process_line}. * @return slot */ public function make_slot_from_processed_line($info) { @@ -243,7 +243,7 @@ public function validate_csv() { /** * Validates the slot data. * - * @param object $data Data from {@link self::convert_line}. + * @param object $data Data from {@see self::convert_line}. * @return string[] */ protected function validate_data($data) { diff --git a/classes/event/slot_unwatched.php b/classes/event/slot_unwatched.php index e8a39c2c..48f7167b 100644 --- a/classes/event/slot_unwatched.php +++ b/classes/event/slot_unwatched.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\event; -defined('MOODLE_INTERNAL') || die(); /** * Slot unwatched. diff --git a/classes/event/slot_watched.php b/classes/event/slot_watched.php index fa8bd061..620e57b1 100644 --- a/classes/event/slot_watched.php +++ b/classes/event/slot_watched.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\event; -defined('MOODLE_INTERNAL') || die(); /** * Slot watched. diff --git a/classes/local/iterator/csv_reader_iterator.php b/classes/local/iterator/csv_reader_iterator.php index e0bef994..1c48df31 100644 --- a/classes/local/iterator/csv_reader_iterator.php +++ b/classes/local/iterator/csv_reader_iterator.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\local\iterator; -defined('MOODLE_INTERNAL') || die(); use csv_import_reader; @@ -56,6 +55,7 @@ public function __construct(csv_import_reader $cir) { $this->cir = $cir; } + #[\ReturnTypeWillChange] /** * Return current value. * @@ -81,6 +81,7 @@ protected function ensure_initialised() { } } + #[\ReturnTypeWillChange] /** * Return the line number. * @@ -97,7 +98,7 @@ public function key() { * * @return void */ - public function next() { + public function next(): void { $this->ensure_initialised(); $this->pos++; $this->current = $this->cir->next(); @@ -108,7 +109,7 @@ public function next() { * * @return void */ - public function rewind() { + public function rewind(): void { $this->pos = 0; $this->initialised = false; $this->current = null; @@ -120,7 +121,7 @@ public function rewind() { * * @return bool */ - public function valid() { + public function valid(): bool { return $this->current !== false; } } diff --git a/classes/local/iterator/map_iterator.php b/classes/local/iterator/map_iterator.php index 641b7281..c38d786b 100644 --- a/classes/local/iterator/map_iterator.php +++ b/classes/local/iterator/map_iterator.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\local\iterator; -defined('MOODLE_INTERNAL') || die(); /** * Iterator map. @@ -52,24 +51,47 @@ public function __construct(\Iterator $iterator, callable $callback) { $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(); } - public function next() { + /** + * Next. + */ + public function next(): void { $this->iterator->next(); } - public function rewind() { + /** + * Rewing. + */ + public function rewind(): void { $this->iterator->rewind(); } - public function valid() { + /** + * Valid. + * + * @return bool + */ + public function valid(): bool { return $this->iterator->valid(); } diff --git a/classes/model/appointment.php b/classes/model/appointment.php index 3a6d1c19..3d110792 100644 --- a/classes/model/appointment.php +++ b/classes/model/appointment.php @@ -178,7 +178,7 @@ public function count_studentfiles() { * Set attended. * * This method is protected as it currently is only meant to be used from - * the {@link mvc_record_model::__set} method. + * 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 diff --git a/classes/model/watcher.php b/classes/model/watcher.php index 781d9f26..2ef68a22 100644 --- a/classes/model/watcher.php +++ b/classes/model/watcher.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\model; -defined('MOODLE_INTERNAL') || die(); /** * Watcher. diff --git a/classes/model/watcher_factory.php b/classes/model/watcher_factory.php index a5bfbee2..17816b5c 100644 --- a/classes/model/watcher_factory.php +++ b/classes/model/watcher_factory.php @@ -24,8 +24,6 @@ */ namespace mod_scheduler\model; -defined('MOODLE_INTERNAL') || die(); - /** * Watcher factory. diff --git a/classes/output/datetime_filter.php b/classes/output/datetime_filter.php index 2737baff..60837ef8 100644 --- a/classes/output/datetime_filter.php +++ b/classes/output/datetime_filter.php @@ -57,7 +57,7 @@ public function __construct($elementname = null, $elementlabel = null, $elements * * @return void */ - public function _createElements() { + public function _createElements() { // @codingStandardsIgnoreLine $this->_elements = []; $operator = $this->createFormElement('select', $this->getName() . '[op]', '', [ @@ -88,7 +88,7 @@ public function _createElements() { * @param bool $notused Not used. * @return array field name => value. The value is the time interval in seconds. */ - function exportValue(&$submitvalues, $notused = false) { + public function exportValue(&$submitvalues, $notused = false) { // @codingStandardsIgnoreLine // Get the values from all the child elements. $values = []; foreach ($this->_elements as $element) { diff --git a/classes/output/mobile.php b/classes/output/mobile.php index 390a5c9b..fef629eb 100644 --- a/classes/output/mobile.php +++ b/classes/output/mobile.php @@ -24,10 +24,8 @@ */ namespace mod_scheduler\output; -defined('MOODLE_INTERNAL') || die(); use moodle_exception; -use mod_scheduler_renderer as renderer; use mod_scheduler\external; use mod_scheduler\slots_query_builder; use mod_scheduler\model\scheduler; diff --git a/classes/slots_query_builder.php b/classes/slots_query_builder.php index ac447a3b..47b00338 100644 --- a/classes/slots_query_builder.php +++ b/classes/slots_query_builder.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler; -defined('MOODLE_INTERNAL') || die(); use coding_exception; use core\dml\sql_join; diff --git a/classes/task/purge_obsolete_watchers.php b/classes/task/purge_obsolete_watchers.php index bde56604..4587a7ba 100644 --- a/classes/task/purge_obsolete_watchers.php +++ b/classes/task/purge_obsolete_watchers.php @@ -24,7 +24,6 @@ */ namespace mod_scheduler\task; -defined('MOODLE_INTERNAL') || die(); /** * Purge obsolete watchers. diff --git a/import.php b/import.php index 426d4f64..2ed0351c 100644 --- a/import.php +++ b/import.php @@ -38,7 +38,7 @@ // Check permissions and whether we have teachers. $permissions->ensure($permissions->can_edit_own_slots()); if (!$scheduler->has_available_teachers()) { - print_error('needteachers', 'scheduler', $returnurl); + throw new moodle_exception('needteachers', 'scheduler', $returnurl); } // While we don't yet have a valid file. @@ -64,7 +64,7 @@ if (!empty($errors)) { $errorkey = array_keys($errors)[0]; $error = reset($errors); - print_error($errorkey, '', $baseurl, $error); + throw new moodle_exception($errorkey, 'core_error', $baseurl, $error); } $table = new flexible_table('import-slot-preview'); @@ -141,7 +141,7 @@ if (!empty($errors)) { $errorkey = array_keys($errors)[0]; $error = reset($errors); - print_error($errorkey, '', $baseurl, $error); + throw new moodle_exception($errorkey, 'core_error', $baseurl, $error); } $imported = 0; diff --git a/lang/en/scheduler.php b/lang/en/scheduler.php index 55f258d3..aa2604f6 100644 --- a/lang/en/scheduler.php +++ b/lang/en/scheduler.php @@ -343,19 +343,19 @@ $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 +- 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. +- 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`. +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'; diff --git a/locallib.php b/locallib.php index 41a2aae0..2b0a8551 100644 --- a/locallib.php +++ b/locallib.php @@ -356,7 +356,7 @@ function mod_scheduler_get_student_upload_options(scheduler $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 {@link scheduler_booking_form}. + * @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) {