From 4c1145009a65bf0d6a6e309ece978931e39f3231 Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Mon, 18 Sep 2017 03:52:10 +0000 Subject: [PATCH 1/7] MDL-60058 assign: show due date calendar event for teachers --- mod/assign/lib.php | 5 ++--- mod/assign/tests/lib_test.php | 6 +++--- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/mod/assign/lib.php b/mod/assign/lib.php index 4e7c7ec1fde..757264588d9 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -1823,8 +1823,7 @@ function assign_check_updates_since(cm_info $cm, $from, $filter = array()) { * Is the event visible? * * This is used to determine global visibility of an event in all places throughout Moodle. For example, - * the ASSIGN_EVENT_TYPE_GRADINGDUE event will not be shown to students on their calendar, and - * ASSIGN_EVENT_TYPE_DUE events will not be shown to teachers. + * the ASSIGN_EVENT_TYPE_GRADINGDUE event will not be shown to students on their calendar. * * @param calendar_event $event * @return bool Returns true if the event is visible to the current user, false otherwise. @@ -1842,7 +1841,7 @@ function mod_assign_core_calendar_is_event_visible(calendar_event $event) { if ($event->eventtype == ASSIGN_EVENT_TYPE_GRADINGDUE) { return $assign->can_grade(); } else { - return !$assign->can_grade() && $assign->can_view_submission($USER->id); + return true; } } diff --git a/mod/assign/tests/lib_test.php b/mod/assign/tests/lib_test.php index 322cb85366c..c59504fadb1 100644 --- a/mod/assign/tests/lib_test.php +++ b/mod/assign/tests/lib_test.php @@ -108,7 +108,7 @@ class mod_assign_lib_testcase extends mod_assign_base_testcase { $this->setAdminUser(); $courses = $DB->get_records('course', array('id' => $this->course->id)); // Past assignments should not show up. - $pastassign = $this->create_instance(array('duedate' => time(), + $pastassign = $this->create_instance(array('duedate' => time() - 370001, 'cutoffdate' => time() - 370000, 'nosubmissions' => 0, 'assignsubmission_onlinetext_enabled' => 1)); @@ -413,8 +413,8 @@ class mod_assign_lib_testcase extends mod_assign_base_testcase { // Set the user to a teacher. $this->setUser($this->editingteachers[0]); - // The teacher should not care about the due date event. - $this->assertFalse(mod_assign_core_calendar_is_event_visible($event)); + // The teacher should see the due date event. + $this->assertTrue(mod_assign_core_calendar_is_event_visible($event)); } public function test_assign_core_calendar_is_event_visible_duedate_event_as_student() { From f4c21561897ba15a915ffbc27ba8db16b0c6d308 Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Mon, 18 Sep 2017 03:53:36 +0000 Subject: [PATCH 2/7] MDL-60058 assign: allow update of assign calendar action events --- mod/assign/lib.php | 144 ++++++ mod/assign/locallib.php | 121 +++++ mod/assign/tests/lib_test.php | 865 ++++++++++++++++++++++++++++++++++ 3 files changed, 1130 insertions(+) diff --git a/mod/assign/lib.php b/mod/assign/lib.php index 757264588d9..34accd3dcc0 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -1920,3 +1920,147 @@ function mod_assign_core_calendar_event_action_shows_item_count(calendar_event $ // For mod_assign, item count should be shown if the event type is 'gradingdue' and there is one or more item count. return in_array($event->eventtype, $eventtypesshowingitemcount) && $itemcount > 0; } + +/** + * This function calculates the minimum and maximum cutoff values for the timestart of + * the given event. + * + * It will return an array with two values, the first being the minimum cutoff value and + * the second being the maximum cutoff value. Either or both values can be null, which + * indicates there is no minimum or maximum, respectively. + * + * If a cutoff is required then the function must return an array containing the cutoff + * timestamp and error string to display to the user if the cutoff value is violated. + * + * A minimum and maximum cutoff return value will look like: + * [ + * [1505704373, 'The due date must be after the sbumission start date'], + * [1506741172, 'The due date must be before the cutoff date'] + * ] + * + * @param calendar_event $event The calendar event to get the time range for + * @param stdClass|null $instance The module instance to get the range from + */ +function mod_assign_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $instance = null) { + global $DB; + + if (!$instance) { + $instance = $DB->get_record('assign', ['id' => $event->instance]); + } + + $coursemodule = get_coursemodule_from_instance('assign', + $event->instance, + $event->courseid, + false, + MUST_EXIST); + + if (empty($coursemodule)) { + // If we don't have a course module yet then it likely means + // the activity is still being set up. In this case there is + // nothing for us to do anyway. + return; + } + + $context = context_module::instance($coursemodule->id); + $assign = new assign($context, null, null); + $assign->set_instance($instance); + + return $assign->get_valid_calendar_event_timestart_range($event); +} + +/** + * This function will check that the given event is valid for it's + * corresponding assign module instance. + * + * An exception is thrown if the event fails validation. + * + * @throws \moodle_exception + * @param \calendar_event $event + * @return bool + */ +function mod_assign_core_calendar_validate_event_timestart(\calendar_event $event) { + global $DB; + + if (!isset($event->instance)) { + return; + } + + // We need to read from the DB directly because course module may + // currently be getting created so it won't be in mod info yet. + $instance = $DB->get_record('assign', ['id' => $event->instance], '*', MUST_EXIST); + $timestart = $event->timestart; + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event, $instance); + + if ($min && $timestart < $min[0]) { + throw new \moodle_exception($min[1]); + } + + if ($max && $timestart > $max[0]) { + throw new \moodle_exception($max[1]); + } +} + +/** + * This function will update the assign module according to the + * event that has been modified. + * + * @throws \moodle_exception + * @param \calendar_event $event + */ +function mod_assign_core_calendar_event_timestart_updated(\calendar_event $event) { + global $DB; + + if (empty($event->instance) || $event->modulename != 'assign') { + return; + } + + $coursemodule = get_coursemodule_from_instance('assign', + $event->instance, + $event->courseid, + false, + MUST_EXIST); + + if (empty($coursemodule)) { + // If we don't have a course module yet then it likely means + // the activity is still being set up. In this case there is + // nothing for us to do anyway. + return; + } + + $context = context_module::instance($coursemodule->id); + + // The user does not have the capability to modify this activity. + if (!has_capability('moodle/course:manageactivities', $context)) { + return; + } + + $assign = new assign($context, $coursemodule, null); + $modified = false; + + if ($event->eventtype == ASSIGN_EVENT_TYPE_DUE) { + // This check is in here because due date events are currently + // the only events that can be overridden, so we can save a DB + // query if we don't bother checking other events. + if ($assign->is_override_calendar_event($event)) { + // This is an override event so we should ignore it. + return; + } + + $instance = $assign->get_instance(); + $newduedate = $event->timestart; + + if ($newduedate != $instance->duedate) { + $instance->duedate = $newduedate; + $instance->timemodified = time(); + $modified = true; + } + } + + if ($modified) { + // Persist the assign instance changes. + $DB->update_record('assign', $instance); + $assign->update_calendar($coursemodule->id); + $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context); + $event->trigger(); + } +} diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php index 0ae808ed15c..4f84e772494 100644 --- a/mod/assign/locallib.php +++ b/mod/assign/locallib.php @@ -943,6 +943,127 @@ class assign { ); } + /** + * Check if the given calendar_event is either a user or group override + * event. + * + * @return bool + */ + public function is_override_calendar_event(\calendar_event $event) { + global $DB; + + if (!isset($event->modulename)) { + return false; + } + + if ($event->modulename != 'assign') { + return false; + } + + if (!isset($event->instance)) { + return false; + } + + if (!isset($event->userid) && !isset($event->groupid)) { + return false; + } + + $overrideparams = [ + 'assignid' => $event->instance + ]; + + if (isset($event->groupid)) { + $overrideparams['groupid'] = $event->groupid; + } else if (isset($event->userid)) { + $overrideparams['userid'] = $event->userid; + } + + if ($DB->get_record('assign_overrides', $overrideparams)) { + return true; + } else { + return false; + } + } + + /** + * This function calculates the minimum and maximum cutoff values for the timestart of + * the given event. + * + * It will return an array with two values, the first being the minimum cutoff value and + * the second being the maximum cutoff value. Either or both values can be null, which + * indicates there is no minimum or maximum, respectively. + * + * If a cutoff is required then the function must return an array containing the cutoff + * timestamp and error string to display to the user if the cutoff value is violated. + * + * A minimum and maximum cutoff return value will look like: + * [ + * [1505704373, 'The due date must be after the sbumission start date'], + * [1506741172, 'The due date must be before the cutoff date'] + * ] + * + * @param calendar_event $event The calendar event to get the time range for + */ + function get_valid_calendar_event_timestart_range(\calendar_event $event) { + $instance = $this->get_instance(); + $submissionsfromdate = $instance->allowsubmissionsfromdate; + $cutoffdate = $instance->cutoffdate; + $duedate = $instance->duedate; + $gradingduedate = $instance->gradingduedate; + $mindate = null; + $maxdate = null; + + if ($event->eventtype == ASSIGN_EVENT_TYPE_DUE) { + // This check is in here because due date events are currently + // the only events that can be overridden, so we can save a DB + // query if we don't bother checking other events. + if ($this->is_override_calendar_event($event)) { + // This is an override event so we should ignore it. + return [null, null]; + } + + if ($submissionsfromdate) { + $mindate = [ + $submissionsfromdate, + get_string('duedatevalidation', 'assign'), + ]; + } + + if ($cutoffdate) { + $maxdate = [ + $cutoffdate, + get_string('cutoffdatevalidation', 'assign'), + ]; + } + + if ($gradingduedate) { + // If we don't have a cutoff date or we've got a grading due date + // that is earlier than the cutoff then we should use that as the + // upper limit for the due date. + if (!$cutoffdate || $gradingduedate < $cutoffdate) { + $maxdate = [ + $gradingduedate, + get_string('gradingdueduedatevalidation', 'assign'), + ]; + } + } + } else if ($event->eventtype == ASSIGN_EVENT_TYPE_GRADINGDUE) { + if ($duedate) { + $mindate = [ + $duedate, + get_string('gradingdueduedatevalidation', 'assign'), + ]; + } else if ($submissionsfromdate) { + $mindate = [ + $submissionsfromdate, + get_string('gradingduefromdatevalidation', 'assign'), + ]; + } + } + + return [$mindate, $maxdate]; + } + /** * Actual implementation of the reset course functionality, delete all the * assignment submissions for course $data->courseid. diff --git a/mod/assign/tests/lib_test.php b/mod/assign/tests/lib_test.php index c59504fadb1..976d182c940 100644 --- a/mod/assign/tests/lib_test.php +++ b/mod/assign/tests/lib_test.php @@ -31,6 +31,9 @@ require_once($CFG->dirroot . '/mod/assign/lib.php'); require_once($CFG->dirroot . '/mod/assign/locallib.php'); require_once($CFG->dirroot . '/mod/assign/tests/base_test.php'); +use \core_calendar\local\api as calendar_local_api; +use \core_calendar\local\event\container as calendar_event_container; + /** * Unit tests for (some of) mod/assign/lib.php. * @@ -684,4 +687,866 @@ class mod_assign_lib_testcase extends mod_assign_base_testcase { $this->assertEquals($student0grade->grade, 5); $this->assertEquals($student1grade->grade, ASSIGN_GRADE_NOT_SET); } + + /** + * Return false when there are not overrides for this assign instance. + */ + public function test_assign_is_override_calendar_event_no_override() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $userid = 1234; + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + + $instance = $assign->get_instance(); + $event = new \calendar_event((object)[ + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid + ]); + + $this->assertFalse($assign->is_override_calendar_event($event)); + } + + /** + * Return false if the given event isn't an assign module event. + */ + public function test_assign_is_override_calendar_event_no_nodule_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $userid = $this->students[0]->id; + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + + $instance = $assign->get_instance(); + $event = new \calendar_event((object)[ + 'userid' => $userid + ]); + + $this->assertFalse($assign->is_override_calendar_event($event)); + } + + /** + * Return false if there is overrides for this use but they belong to another assign + * instance. + */ + public function test_assign_is_override_calendar_event_different_assign_instance() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $userid = 1234; + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + $assign2 = $this->create_instance(['duedate' => $duedate]); + + $instance = $assign->get_instance(); + $event = new \calendar_event((object) [ + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid + ]); + + $record = (object) [ + 'assignid' => $assign2->get_instance()->id, + 'userid' => $userid + ]; + + $DB->insert_record('assign_overrides', $record); + + $this->assertFalse($assign->is_override_calendar_event($event)); + } + + /** + * Return true if there is a user override for this event and assign instance. + */ + public function test_assign_is_override_calendar_event_user_override() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $userid = 1234; + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + + $instance = $assign->get_instance(); + $event = new \calendar_event((object) [ + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid + ]); + + $record = (object) [ + 'assignid' => $instance->id, + 'userid' => $userid + ]; + + $DB->insert_record('assign_overrides', $record); + + $this->assertTrue($assign->is_override_calendar_event($event)); + } + + /** + * Return true if there is a group override for the event and assign instance. + */ + public function test_assign_is_override_calendar_event_group_override() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + $instance = $assign->get_instance(); + $group = $this->getDataGenerator()->create_group(array('courseid' => $instance->course)); + $groupid = $group->id; + + $event = new \calendar_event((object) [ + 'modulename' => 'assign', + 'instance' => $instance->id, + 'groupid' => $groupid + ]); + + $record = (object) [ + 'assignid' => $instance->id, + 'groupid' => $groupid + ]; + + $DB->insert_record('assign_overrides', $record); + + $this->assertTrue($assign->is_override_calendar_event($event)); + } + + /** + * Unknown event types should not have any limit restrictions returned. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_unkown_event_type() { + global $CFG; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => 'SOME RANDOM EVENT' + ]); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * Override events should not have any limit restrictions returned. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_override_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $assign = $this->create_instance(['duedate' => $duedate]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + $record = (object) [ + 'assignid' => $instance->id, + 'userid' => $userid + ]; + + $DB->insert_record('assign_overrides', $record); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * Assignments configured without a submissions from and cutoff date should not have + * any limits applied. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_due_no_limit() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => 0, + 'cutoffdate' => 0, + ]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * Assignments should be bottom and top bound by the submissions from date and cutoff date + * respectively. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_due_with_limits() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertEquals($submissionsfromdate, $min[0]); + $this->assertNotEmpty($min[1]); + $this->assertEquals($cutoffdate, $max[0]); + $this->assertNotEmpty($max[1]); + } + + /** + * Assignment grading due date should not have any limits of no due date and cutoff date is set. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_gradingdue_no_limit() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $assign = $this->create_instance([ + 'duedate' => 0, + 'allowsubmissionsfromdate' => 0, + 'cutoffdate' => 0, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_GRADINGDUE + ]); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * Assignment grading due event is minimum bound by the due date, if it is set. + */ + public function test_mod_assign_core_calendar_get_valid_event_timestart_range_gradingdue_with_due_date() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $assign = $this->create_instance([ + 'duedate' => $duedate + ]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_GRADINGDUE + ]); + + list($min, $max) = mod_assign_core_calendar_get_valid_event_timestart_range($event); + $this->assertEquals($duedate, $min[0]); + $this->assertNotEmpty($min[1]); + $this->assertNull($max); + } + + /** + * Calendar events without and instance id should be ignored by the validate + * event function. + */ + public function test_mod_assign_core_calendar_validate_event_timestart_no_instance_id() { + global $CFG; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $event = new \calendar_event((object) [ + 'modulename' => 'assign', + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + mod_assign_core_calendar_validate_event_timestart($event); + // The function above throws an exception so all we need to do is make sure + // it gets here and that is considered success. + $this->assertTrue(true); + } + + /** + * Calendar events for an unknown instance should throw an exception. + */ + public function test_mod_assign_core_calendar_validate_event_timestart_no_instance_found() { + global $CFG; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $event = new \calendar_event((object) [ + 'modulename' => 'assign', + 'instance' => 1234, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + $this->expectException('moodle_exception'); + mod_assign_core_calendar_validate_event_timestart($event); + } + + /** + * Assignments configured without any limits on the due date should not + * throw an exception. + */ + public function test_mod_assign_core_calendar_validate_event_timestart_no_limit() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $assign = $this->create_instance([ + 'duedate' => 0, + 'allowsubmissionsfromdate' => 0, + 'cutoffdate' => 0, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => time() + ]); + + mod_assign_core_calendar_validate_event_timestart($event); + // The function above throws an exception so all we need to do is make sure + // it gets here and that is considered success. + $this->assertTrue(true); + } + + /** + * Due date events with a timestart equal to or greater than the minimum limit + * should not throw an exception. Timestart values below the minimum limit should + * throw an exception. + */ + public function test_mod_assign_core_calendar_validate_due_event_min_limit() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => $submissionsfromdate + 1, + ]); + + // No exception when new time is above minimum cutoff. + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // No exception when new time is equal to minimum cutoff. + $event->timestart = $submissionsfromdate; + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // Exception when new time is earlier than minimum cutoff. + $event->timestart = $submissionsfromdate - 1; + $this->expectException('moodle_exception'); + mod_assign_core_calendar_validate_event_timestart($event); + } + + /** + * A due date event with a timestart less than or equal to the max limit should + * not throw an exception. A timestart greater than the max limit should throw + * an exception. + */ + public function test_mod_assign_core_calendar_validate_due_event_max_limit() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => $cutoffdate - 1, + ]); + + // No exception when new time is below maximum cutoff. + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // No exception when new time is equal to maximum cutoff. + $event->timestart = $cutoffdate; + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // Exception when new time is later than maximum cutoff. + $event->timestart = $submissionsfromdate - 1; + $this->expectException('moodle_exception'); + mod_assign_core_calendar_validate_event_timestart($event); + } + + /** + * Due date override events should not throw an exception. + */ + public function test_mod_assign_core_calendar_validate_due_event_override() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => $duedate + (2 * DAYSECS) + ]); + + $record = (object) [ + 'assignid' => $instance->id, + 'userid' => $userid, + 'duedate' => $duedate + (2 * DAYSECS) + ]; + + $DB->insert_record('assign_overrides', $record); + + // No exception when dealing with an override. + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + } + + /** + * Grading due date event should throw an exception if it's timestart is less than the + * assignment due date. + */ + public function test_mod_assign_core_calendar_validate_gradingdue_event_min_limit_duedate() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_GRADINGDUE, + 'timestart' => $duedate + 1, + ]); + + // No exception when new time is above minimum cutoff. + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // No exception when new time is equal to minimum cutoff. + $event->timestart = $duedate; + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // Exception when new time is earlier than minimum cutoff. + $event->timestart = $duedate - 1; + $this->expectException('moodle_exception'); + mod_assign_core_calendar_validate_event_timestart($event); + } + + /** + * Grading due date event should throw an exception if it's timestart is less than the + * submissions allowed from date if there is no due date set. + */ + public function test_mod_assign_core_calendar_validate_gradingdue_event_min_limit_submissionsfromdate() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = 0; + $submissionsfromdate = time() - DAYSECS; + $cutoffdate = time() + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_GRADINGDUE, + 'timestart' => $submissionsfromdate + 1, + ]); + + // No exception when new time is above minimum cutoff. + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // No exception when new time is equal to minimum cutoff. + $event->timestart = $submissionsfromdate; + mod_assign_core_calendar_validate_event_timestart($event); + $this->assertTrue(true); + + // Exception when new time is earlier than minimum cutoff. + $event->timestart = $submissionsfromdate - 1; + $this->expectException('moodle_exception'); + mod_assign_core_calendar_validate_event_timestart($event); + } + + /** + * Non due date events should not update the assignment due date. + */ + public function test_mod_assign_core_calendar_event_timestart_updated_non_due_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_GRADINGDUE, + 'timestart' => $duedate + 1 + ]); + + mod_assign_core_calendar_event_timestart_updated($event); + + $newinstance = $DB->get_record('assign', ['id' => $instance->id]); + $this->assertEquals($duedate, $newinstance->duedate); + } + + /** + * Due date override events should not change the assignment due date. + */ + public function test_mod_assign_core_calendar_event_timestart_updated_due_event_override() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + $userid = $this->students[0]->id; + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'userid' => $userid, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => $duedate + 1 + ]); + + $record = (object) [ + 'assignid' => $instance->id, + 'userid' => $userid, + 'duedate' => $duedate + 1 + ]; + + $DB->insert_record('assign_overrides', $record); + + mod_assign_core_calendar_event_timestart_updated($event); + + $newinstance = $DB->get_record('assign', ['id' => $instance->id]); + $this->assertEquals($duedate, $newinstance->duedate); + } + + /** + * Due date events should update the assignment due date. + */ + public function test_mod_assign_core_calendar_event_timestart_updated_due_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $duedate = time(); + $newduedate = $duedate + 1; + $submissionsfromdate = $duedate - DAYSECS; + $cutoffdate = $duedate + DAYSECS; + $assign = $this->create_instance([ + 'duedate' => $duedate, + 'allowsubmissionsfromdate' => $submissionsfromdate, + 'cutoffdate' => $cutoffdate, + ]); + $instance = $assign->get_instance(); + + $event = new \calendar_event((object) [ + 'courseid' => $instance->course, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE, + 'timestart' => $newduedate + ]); + + mod_assign_core_calendar_event_timestart_updated($event); + + $newinstance = $DB->get_record('assign', ['id' => $instance->id]); + $this->assertEquals($newduedate, $newinstance->duedate); + } + + /** + * If a student somehow finds a way to update the due date calendar event + * then the callback should not be executed to update the assignment due + * date as well otherwise that would be a security issue. + */ + public function test_student_role_cant_update_due_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $mapper = calendar_event_container::get_event_mapper(); + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $course = $generator->create_course(); + $context = context_course::instance($course->id); + $roleid = $generator->create_role(); + $now = time(); + $duedate = (new DateTime())->setTimestamp($now); + $newduedate = (new DateTime())->setTimestamp($now)->modify('+1 day'); + $assign = $this->create_instance([ + 'course' => $course->id, + 'duedate' => $duedate->getTimestamp(), + ]); + $instance = $assign->get_instance(); + + $generator->enrol_user($user->id, $course->id, 'student'); + $generator->role_assign($roleid, $user->id, $context->id); + + $record = $DB->get_record('event', [ + 'courseid' => $course->id, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + $event = new \calendar_event($record); + + assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true); + assign_capability('moodle/course:manageactivities', CAP_PROHIBIT, $roleid, $context, true); + + $this->setUser($user); + + calendar_local_api::update_event_start_day( + $mapper->from_legacy_event_to_event($event), + $newduedate + ); + + $newinstance = $DB->get_record('assign', ['id' => $instance->id]); + $newevent = \calendar_event::load($event->id); + // The due date shouldn't have changed even though we updated the calendar + // event. + $this->assertEquals($duedate->getTimestamp(), $newinstance->duedate); + $this->assertEquals($newduedate->getTimestamp(), $newevent->timestart); + } + + /** + * A teacher with the capability to modify an assignment module should be + * able to update the assignment due date by changing the due date calendar + * event. + */ + public function test_teacher_role_can_update_due_event() { + global $CFG, $DB; + require_once($CFG->dirroot . '/calendar/lib.php'); + + $this->resetAfterTest(); + $this->setAdminUser(); + + $mapper = calendar_event_container::get_event_mapper(); + $generator = $this->getDataGenerator(); + $user = $generator->create_user(); + $course = $generator->create_course(); + $context = context_course::instance($course->id); + $roleid = $generator->create_role(); + $now = time(); + $duedate = (new DateTime())->setTimestamp($now); + $newduedate = (new DateTime())->setTimestamp($now)->modify('+1 day'); + $assign = $this->create_instance([ + 'course' => $course->id, + 'duedate' => $duedate->getTimestamp(), + ]); + $instance = $assign->get_instance(); + + $generator->enrol_user($user->id, $course->id, 'teacher'); + $generator->role_assign($roleid, $user->id, $context->id); + + $record = $DB->get_record('event', [ + 'courseid' => $course->id, + 'modulename' => 'assign', + 'instance' => $instance->id, + 'eventtype' => ASSIGN_EVENT_TYPE_DUE + ]); + + $event = new \calendar_event($record); + + assign_capability('moodle/calendar:manageentries', CAP_ALLOW, $roleid, $context, true); + assign_capability('moodle/course:manageactivities', CAP_ALLOW, $roleid, $context, true); + + $this->setUser($user); + // Trigger and capture the event when adding a contact. + $sink = $this->redirectEvents(); + + calendar_local_api::update_event_start_day( + $mapper->from_legacy_event_to_event($event), + $newduedate + ); + + $triggeredevents = $sink->get_events(); + $moduleupdatedevents = array_filter($triggeredevents, function($e) { + return is_a($e, 'core\event\course_module_updated'); + }); + + $newinstance = $DB->get_record('assign', ['id' => $instance->id]); + $newevent = \calendar_event::load($event->id); + // The due date shouldn't have changed even though we updated the calendar + // event. + $this->assertEquals($newduedate->getTimestamp(), $newinstance->duedate); + $this->assertEquals($newduedate->getTimestamp(), $newevent->timestart); + // Confirm that a module updated event is fired when the module + // is changed. + $this->assertNotEmpty($moduleupdatedevents); + } } From c56dd950ebb95ef1f739df2d2bd168bbc9b9185f Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Mon, 18 Sep 2017 03:56:20 +0000 Subject: [PATCH 3/7] MDL-60058 calendar: add visual indicator to UI for valid drop zones --- .../amd/build/drag_drop_data_store.min.js | 2 +- .../build/month_navigation_drag_drop.min.js | 2 +- .../amd/build/month_view_drag_drop.min.js | 2 +- calendar/amd/src/drag_drop_data_store.js | 114 ++++++++++ .../amd/src/month_navigation_drag_drop.js | 15 ++ calendar/amd/src/month_view_drag_drop.js | 199 ++++++++++++++++-- .../external/calendar_event_exporter.php | 130 +++++++++++- calendar/templates/month_detailed.mustache | 12 ++ .../tests/calendar_event_exporter_test.php | 150 +++++++++++++ .../bootstrapbase/less/moodle/bs4-compat.less | 4 + theme/bootstrapbase/style/moodle.css | 3 + 11 files changed, 613 insertions(+), 20 deletions(-) create mode 100644 calendar/tests/calendar_event_exporter_test.php diff --git a/calendar/amd/build/drag_drop_data_store.min.js b/calendar/amd/build/drag_drop_data_store.min.js index e2cc7decf47..5185c8b54d2 100644 --- a/calendar/amd/build/drag_drop_data_store.min.js +++ b/calendar/amd/build/drag_drop_data_store.min.js @@ -1 +1 @@ -define([],function(){var a=null,b=null,c=function(b){a=b},d=function(){return a},e=function(){return null!==a},f=function(a){b=a},g=function(){return b},h=function(){c(null),f(null)};return{setEventId:c,getEventId:d,hasEventId:e,setDurationDays:f,getDurationDays:g,clearAll:h}}); \ No newline at end of file +define([],function(){var a=null,b=null,c=null,d=null,e=null,f=null,g=function(b){a=b},h=function(){return a},i=function(){return null!==a},j=function(a){b=a},k=function(){return b},l=function(a){c=a},m=function(){return c},n=function(){return null!==c},o=function(a){d=a},p=function(){return d},q=function(){return null!==d},r=function(a){e=a},s=function(){return e},t=function(a){f=a},u=function(){return f},v=function(){g(null),j(null),l(null),o(null),r(null),t(null)};return{setEventId:g,getEventId:h,hasEventId:i,setDurationDays:j,getDurationDays:k,setMinTimestart:l,getMinTimestart:m,hasMinTimestart:n,setMaxTimestart:o,getMaxTimestart:p,hasMaxTimestart:q,setMinError:r,getMinError:s,setMaxError:t,getMaxError:u,clearAll:v}}); \ No newline at end of file diff --git a/calendar/amd/build/month_navigation_drag_drop.min.js b/calendar/amd/build/month_navigation_drag_drop.min.js index a8888bf3f49..6e6cbc371e1 100644 --- a/calendar/amd/build/month_navigation_drag_drop.min.js +++ b/calendar/amd/build/month_navigation_drag_drop.min.js @@ -1 +1 @@ -define(["jquery","core_calendar/drag_drop_data_store"],function(a,b){var c={DRAGGABLE:'[draggable="true"][data-region="event-item"]',DROP_ZONE:'[data-drop-zone="nav-link"]'},d="bg-primary text-white",e="drop-target",f=1e3,g=!1,h=null,i=null,j=function(a,b){b?a.addClass(d):a.removeClass(d)},k=function(){i.find(c.DROP_ZONE).addClass(e)},l=function(){i.find(c.DROP_ZONE).removeClass(e)},m=function(b){var d=a(b.target).closest(c.DROP_ZONE);return d.length?d:null},n=function(b){var d=a(b.target).closest(c.DRAGGABLE);d.length&&k()},o=function(a){a.preventDefault();var c=m(a);c&&b.hasEventId()&&(h||(h=setTimeout(function(){c.click(),h=null},f)),j(c,!0),l())},p=function(a){var b=m(a);b&&(h&&(clearTimeout(h),h=null),j(b,!1),k(),a.preventDefault())},q=function(a){l();var b=m(a);b&&(j(b,!1),a.preventDefault())};return{init:function(c){g||(document.addEventListener("dragstart",n,!1),document.addEventListener("dragover",o,!1),document.addEventListener("dragleave",p,!1),document.addEventListener("drop",q,!1),document.addEventListener("dragend",l,!1),g=!0),i=a(c),b.hasEventId()&&k()}}}); \ No newline at end of file +define(["jquery","core_calendar/drag_drop_data_store"],function(a,b){var c={DRAGGABLE:'[draggable="true"][data-region="event-item"]',DROP_ZONE:'[data-drop-zone="nav-link"]'},d="bg-primary text-white",e="drop-target",f=1e3,g=!1,h=null,i=null,j=function(a,b){b?a.addClass(d):a.removeClass(d)},k=function(){i.find(c.DROP_ZONE).addClass(e)},l=function(){i.find(c.DROP_ZONE).removeClass(e)},m=function(b){var d=a(b.target).closest(c.DROP_ZONE);return d.length?d:null},n=function(b){var d=a(b.target).closest(c.DRAGGABLE);d.length&&k()},o=function(a){if(b.hasEventId()){a.preventDefault();var c=m(a);c&&b.hasEventId()&&(h||(h=setTimeout(function(){c.click(),h=null},f)),j(c,!0),l())}},p=function(a){if(b.hasEventId()){var c=m(a);c&&(h&&(clearTimeout(h),h=null),j(c,!1),k(),a.preventDefault())}},q=function(a){if(b.hasEventId()){l();var c=m(a);c&&(j(c,!1),a.preventDefault())}};return{init:function(c){g||(document.addEventListener("dragstart",n,!1),document.addEventListener("dragover",o,!1),document.addEventListener("dragleave",p,!1),document.addEventListener("drop",q,!1),document.addEventListener("dragend",l,!1),g=!0),i=a(c),b.hasEventId()&&k()}}}); \ No newline at end of file diff --git a/calendar/amd/build/month_view_drag_drop.min.js b/calendar/amd/build/month_view_drag_drop.min.js index b2ee13dec67..53812700409 100644 --- a/calendar/amd/build/month_view_drag_drop.min.js +++ b/calendar/amd/build/month_view_drag_drop.min.js @@ -1 +1 @@ -define(["jquery","core_calendar/events","core_calendar/drag_drop_data_store"],function(a,b,c){var d={ROOT:"[data-region='calendar']",DRAGGABLE:'[draggable="true"][data-region="event-item"]',DROP_ZONE:'[data-drop-zone="month-view-day"]',WEEK:'[data-region="month-view-week"]'},e="bg-primary text-white",f=!1,g=function(b){var c=a(b.target).closest(d.DROP_ZONE);return c.length?c:null},h=function(a,b,f){if("undefined"==typeof f&&(f=c.getDurationDays()),b?a.addClass(e):a.removeClass(e),f--,f>0){var g=a.next();if(!g.length){var i=a.closest(d.WEEK).next();i.length&&(g=i.children(d.DROP_ZONE).first())}g.length&&h(g,b,f)}},i=function(b){var e=a(b.target).closest(d.DRAGGABLE);if(e.length){e=e.find("[data-event-id]");var f=e.attr("data-event-id"),g=d.ROOT+' [data-event-id="'+f+'"]',h=a(g).length;c.setEventId(f),c.setDurationDays(h),b.dataTransfer.effectAllowed="move",b.dataTransfer.dropEffect="move",b.dataTransfer.setData("text/plain",f),b.dropEffect="move"}},j=function(a){a.preventDefault();var b=g(a);b&&h(b,!0)},k=function(a){var b=g(a);b&&(h(b,!1),a.preventDefault())},l=function(e){var f=g(e);if(!f)return void c.clearAll();var i=c.getEventId(),j=d.ROOT+' [data-event-id="'+i+'"]',k=a(j),l=null,m=a(e.target).closest(d.DROP_ZONE);k.length&&(l=k.closest(d.DROP_ZONE)),h(f,!1),a("body").trigger(b.moveEvent,[i,l,m]),c.clearAll(),e.preventDefault()};return{init:function(){f||(document.addEventListener("dragstart",i,!1),document.addEventListener("dragover",j,!1),document.addEventListener("dragleave",k,!1),document.addEventListener("drop",l,!1),f=!0)}}}); \ No newline at end of file +define(["jquery","core/notification","core/str","core_calendar/events","core_calendar/drag_drop_data_store"],function(a,b,c,d,e){var f={ROOT:"[data-region='calendar']",DRAGGABLE:'[draggable="true"][data-region="event-item"]',DROP_ZONE:'[data-drop-zone="month-view-day"]',WEEK:'[data-region="month-view-week"]'},g="bg-faded",h="bg-danger text-white",i="bg-primary text-white",j=g+" "+h+" "+i,k=!1,l=function(b){var c=a(b.target).closest(f.DROP_ZONE);return c.length?c:null},m=function(a){var b=a.attr("data-day-timestamp"),c=e.getMinTimestart(),d=e.getMaxTimestart();return!(c&&c>b)&&!(d&&db?e.getMinError():d&&d0){var k=a.next();if(!k.length){var l=a.closest(f.WEEK).next();l.length&&(k=l.children(f.DROP_ZONE).first())}k.length&&p(k,b,c)}},q=function(){a(f.ROOT).find(f.DROP_ZONE).each(function(b,c){c=a(c),m(c)||p(c,!1)})},r=function(b){var c=a(b.target),d=c.closest(f.DRAGGABLE);if(d.length){var g=d.find("[data-event-id]"),h=g.attr("data-event-id"),i=d.attr("data-min-day-timestamp"),j=d.attr("data-max-day-timestamp"),k=d.attr("data-min-day-error"),l=d.attr("data-max-day-error"),m=f.ROOT+' [data-event-id="'+h+'"]',n=a(m).length;e.setEventId(h),e.setDurationDays(n),i&&e.setMinTimestart(i),j&&e.setMaxTimestart(j),k&&e.setMinError(k),l&&e.setMaxError(l),b.dataTransfer.effectAllowed="move",b.dataTransfer.dropEffect="move",b.dataTransfer.setData("text/plain",h),b.dropEffect="move",q()}},s=function(a){if(e.hasEventId()){a.preventDefault();var b=l(a);b&&p(b,!0)}},t=function(a){if(e.hasEventId()){var b=l(a);b&&(p(b,!1),a.preventDefault())}},u=function(g){if(e.hasEventId()){var h=l(g);if(!h)return e.clearAll(),void o();if(m(h)){var i=e.getEventId(),j=f.ROOT+' [data-event-id="'+i+'"]',k=a(j),p=null;k.length&&(p=k.closest(f.DROP_ZONE)),a("body").trigger(d.moveEvent,[i,p,h])}else{var q=n(h);c.get_string("errorinvaliddate","calendar").then(function(a){b.exception({name:a,message:q||a})})}e.clearAll(),o(),g.preventDefault()}},v=function(){e.clearAll(),o()},w=function(){q()};return{init:function(){k||(document.addEventListener("dragstart",r,!1),document.addEventListener("dragover",s,!1),document.addEventListener("dragleave",t,!1),document.addEventListener("drop",u,!1),document.addEventListener("dragend",v,!1),a("body").on(d.monthChanged,w),k=!0)}}}); \ No newline at end of file diff --git a/calendar/amd/src/drag_drop_data_store.js b/calendar/amd/src/drag_drop_data_store.js index 6414f8ef88e..893d11abd65 100644 --- a/calendar/amd/src/drag_drop_data_store.js +++ b/calendar/amd/src/drag_drop_data_store.js @@ -30,6 +30,14 @@ define([], function() { var eventId = null; /* @var {int|null} durationDays How many days the event spans */ var durationDays = null; + /* @var {int|null} minTimestart The earliest valid timestart */ + var minTimestart = null; + /* @var {int|null} maxTimestart The latest valid tiemstart */ + var maxTimestart = null; + /* @var {string|null} minError Error message for min timestamp violation */ + var minError = null; + /* @var {string|null} maxError Error message for max timestamp violation */ + var maxError = null; /** * Store the id of the event being dragged. @@ -76,12 +84,108 @@ define([], function() { return durationDays; }; + /** + * Store the minimum timestart valid for an event being dragged. + * + * @param {int} timestamp The unix timstamp + */ + var setMinTimestart = function(timestamp) { + minTimestart = timestamp; + }; + + /** + * Get the minimum valid timestart. + * + * @return {int|null} + */ + var getMinTimestart = function() { + return minTimestart; + }; + + /** + * Check if a minimum timestamp is set. + * + * @return {bool} + */ + var hasMinTimestart = function() { + return minTimestart !== null; + }; + + /** + * Store the maximum timestart valid for an event being dragged. + * + * @param {int} timestamp The unix timstamp + */ + var setMaxTimestart = function(timestamp) { + maxTimestart = timestamp; + }; + + /** + * Get the maximum valid timestart. + * + * @return {int|null} + */ + var getMaxTimestart = function() { + return maxTimestart; + }; + + /** + * Check if a maximum timestamp is set. + * + * @return {bool} + */ + var hasMaxTimestart = function() { + return maxTimestart !== null; + }; + + /** + * Store the error string to display if trying to drag an event + * earlier than the minimum allowed date. + * + * @param {string} message The error message + */ + var setMinError = function(message) { + minError = message; + }; + + /** + * Get the error message for a minimum time start violation. + * + * @return {string|null} + */ + var getMinError = function() { + return minError; + }; + + /** + * Store the error string to display if trying to drag an event + * later than the maximum allowed date. + * + * @param {string} message The error message + */ + var setMaxError = function(message) { + maxError = message; + }; + + /** + * Get the error message for a maximum time start violation. + * + * @return {string|null} + */ + var getMaxError = function() { + return maxError; + }; + /** * Reset all of the stored values. */ var clearAll = function() { setEventId(null); setDurationDays(null); + setMinTimestart(null); + setMaxTimestart(null); + setMinError(null); + setMaxError(null); }; return { @@ -90,6 +194,16 @@ define([], function() { hasEventId: hasEventId, setDurationDays: setDurationDays, getDurationDays: getDurationDays, + setMinTimestart: setMinTimestart, + getMinTimestart: getMinTimestart, + hasMinTimestart: hasMinTimestart, + setMaxTimestart: setMaxTimestart, + getMaxTimestart: getMaxTimestart, + hasMaxTimestart: hasMaxTimestart, + setMinError: setMinError, + getMinError: getMinError, + setMaxError: setMaxError, + getMaxError: getMaxError, clearAll: clearAll }; }); diff --git a/calendar/amd/src/month_navigation_drag_drop.js b/calendar/amd/src/month_navigation_drag_drop.js index 0e192df437c..dca8f6178e0 100644 --- a/calendar/amd/src/month_navigation_drag_drop.js +++ b/calendar/amd/src/month_navigation_drag_drop.js @@ -119,6 +119,11 @@ define([ * @param {event} e The dragover event */ var dragoverHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + e.preventDefault(); var target = getTargetFromEvent(e); @@ -153,6 +158,11 @@ define([ * @param {event} e The dragstart event */ var dragleaveHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + var target = getTargetFromEvent(e); if (!target) { @@ -176,6 +186,11 @@ define([ * @param {event} e The drop event */ var dropHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + removeDropZoneIndicator(); var target = getTargetFromEvent(e); diff --git a/calendar/amd/src/month_view_drag_drop.js b/calendar/amd/src/month_view_drag_drop.js index 05b37331215..b94af90d155 100644 --- a/calendar/amd/src/month_view_drag_drop.js +++ b/calendar/amd/src/month_view_drag_drop.js @@ -25,11 +25,15 @@ */ define([ 'jquery', + 'core/notification', + 'core/str', 'core_calendar/events', 'core_calendar/drag_drop_data_store' ], function( $, + Notification, + Str, CalendarEvents, DataStore ) { @@ -40,7 +44,10 @@ define([ DROP_ZONE: '[data-drop-zone="month-view-day"]', WEEK: '[data-region="month-view-week"]', }; - var HOVER_CLASS = 'bg-primary text-white'; + var INVALID_DROP_ZONE_CLASS = 'bg-faded'; + var INVALID_HOVER_CLASS = 'bg-danger text-white'; + var VALID_HOVER_CLASS = 'bg-primary text-white'; + var ALL_CLASSES = INVALID_DROP_ZONE_CLASS + ' ' + INVALID_HOVER_CLASS + ' ' + VALID_HOVER_CLASS; /* @var {bool} registered If the event listeners have been added */ var registered = false; @@ -56,10 +63,73 @@ define([ return (dropZone.length) ? dropZone : null; }; + /** + * Determine if the given dropzone element is within the acceptable + * time range. + * + * The drop zone timestamp is midnight on that day so we should check + * that the event's acceptable timestart value + * + * @param {object} dropZone The drop zone day from the calendar + * @return {bool} + */ + var isValidDropZone = function(dropZone) { + var dropTimestamp = dropZone.attr('data-day-timestamp'); + var minTimestart = DataStore.getMinTimestart(); + var maxTimestart = DataStore.getMaxTimestart(); + + if (minTimestart && minTimestart > dropTimestamp) { + return false; + } + + if (maxTimestart && maxTimestart < dropTimestamp) { + return false; + } + + return true; + }; + + /** + * Get the error string to display for a given drop zone element + * if it is invalid. + * + * @param {object} dropZone The drop zone day from the calendar + * @return {string} + */ + var getDropZoneError = function(dropZone) { + var dropTimestamp = dropZone.attr('data-day-timestamp'); + var minTimestart = DataStore.getMinTimestart(); + var maxTimestart = DataStore.getMaxTimestart(); + + if (minTimestart && minTimestart > dropTimestamp) { + return DataStore.getMinError(); + } + + if (maxTimestart && maxTimestart < dropTimestamp) { + return DataStore.getMaxError(); + } + + return null; + }; + + /** + * Remove all of the styling from each of the drop zones in the calendar. + */ + var clearAllDropZonesState = function() { + $(SELECTORS.ROOT).find(SELECTORS.DROP_ZONE).each(function(index, dropZone) { + dropZone = $(dropZone); + dropZone.removeClass(ALL_CLASSES); + }); + }; + /** * Update the hover state for the event in the calendar to reflect * which days the event will be moved to. * + * If the drop zone is not being hovered then it will apply some + * styling to reflect whether the drop zone is a valid or invalid + * drop place for the current dragging event. + * * This funciton supports events spanning multiple days and will * recurse to highlight (or remove highlight) each of the days * that the event will be moved to. @@ -79,10 +149,22 @@ define([ count = DataStore.getDurationDays(); } + var valid = isValidDropZone(dropZone); + dropZone.removeClass(ALL_CLASSES); + if (hovered) { - dropZone.addClass(HOVER_CLASS); + + if (valid) { + dropZone.addClass(VALID_HOVER_CLASS); + } else { + dropZone.addClass(INVALID_HOVER_CLASS); + } } else { - dropZone.removeClass(HOVER_CLASS); + dropZone.removeClass(VALID_HOVER_CLASS + ' ' + INVALID_HOVER_CLASS); + + if (!valid) { + dropZone.addClass(INVALID_DROP_ZONE_CLASS); + } } count--; @@ -110,6 +192,21 @@ define([ } }; + /** + * Find all of the calendar event drop zones in the calendar and update the display + * for the user to indicate which zones are valid and invalid. + */ + var updateAllDropZonesState = function() { + $(SELECTORS.ROOT).find(SELECTORS.DROP_ZONE).each(function(index, dropZone) { + dropZone = $(dropZone); + + if (!isValidDropZone(dropZone)) { + updateHoverState(dropZone, false); + } + }); + }; + + /** * Set up the module level variables to track which event is being * dragged and how many days it spans. @@ -117,27 +214,49 @@ define([ * @param {event} e The dragstart event */ var dragstartHandler = function(e) { - var eventElement = $(e.target).closest(SELECTORS.DRAGGABLE); + var target = $(e.target); + var draggableElement = target.closest(SELECTORS.DRAGGABLE); - if (!eventElement.length) { + if (!draggableElement.length) { return; } - eventElement = eventElement.find('[data-event-id]'); - + var eventElement = draggableElement.find('[data-event-id]'); var eventId = eventElement.attr('data-event-id'); + var minTimestart = draggableElement.attr('data-min-day-timestamp'); + var maxTimestart = draggableElement.attr('data-max-day-timestamp'); + var minError = draggableElement.attr('data-min-day-error'); + var maxError = draggableElement.attr('data-max-day-error'); var eventsSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]'; var duration = $(eventsSelector).length; DataStore.setEventId(eventId); DataStore.setDurationDays(duration); + if (minTimestart) { + DataStore.setMinTimestart(minTimestart); + } + + if (maxTimestart) { + DataStore.setMaxTimestart(maxTimestart); + } + + if (minError) { + DataStore.setMinError(minError); + } + + if (maxError) { + DataStore.setMaxError(maxError); + } + e.dataTransfer.effectAllowed = "move"; e.dataTransfer.dropEffect = "move"; // Firefox requires a value to be set here or the drag won't // work and the dragover handler won't fire. e.dataTransfer.setData('text/plain', eventId); e.dropEffect = "move"; + + updateAllDropZonesState(); }; /** @@ -150,6 +269,11 @@ define([ * @param {event} e The dragstart event */ var dragoverHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + e.preventDefault(); var dropZone = getDropZoneFromEvent(e); @@ -171,6 +295,11 @@ define([ * @param {event} e The dragstart event */ var dragleaveHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + var dropZone = getDropZoneFromEvent(e); if (!dropZone) { @@ -193,30 +322,66 @@ define([ * @param {event} e The dragstart event */ var dropHandler = function(e) { + // Ignore dragging of non calendar events. + if (!DataStore.hasEventId()) { + return; + } + var dropZone = getDropZoneFromEvent(e); if (!dropZone) { DataStore.clearAll(); + clearAllDropZonesState(); return; } - var eventId = DataStore.getEventId(); - var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]'; - var eventElement = $(eventElementSelector); - var origin = null; - var destination = $(e.target).closest(SELECTORS.DROP_ZONE); + if (isValidDropZone(dropZone)) { + var eventId = DataStore.getEventId(); + var eventElementSelector = SELECTORS.ROOT + ' [data-event-id="' + eventId + '"]'; + var eventElement = $(eventElementSelector); + var origin = null; - if (eventElement.length) { - origin = eventElement.closest(SELECTORS.DROP_ZONE); + if (eventElement.length) { + origin = eventElement.closest(SELECTORS.DROP_ZONE); + } + + $('body').trigger(CalendarEvents.moveEvent, [eventId, origin, dropZone]); + } else { + // If the drop zone is not valid then there is not need for us to + // try to process it. Instead we can just show an error to the user. + var message = getDropZoneError(dropZone); + Str.get_string('errorinvaliddate', 'calendar').then(function(string) { + Notification.exception({ + name: string, + message: message || string + }); + }); } - updateHoverState(dropZone, false); - $('body').trigger(CalendarEvents.moveEvent, [eventId, origin, destination]); DataStore.clearAll(); + clearAllDropZonesState(); e.preventDefault(); }; + /** + * Clear the data store and remove the drag indicators from the UI + * when the drag event has finished. + */ + var dragendHandler = function() { + DataStore.clearAll(); + clearAllDropZonesState(); + }; + + /** + * Re-render the drop zones in the new month to highlight + * which areas are or aren't acceptable to drop the calendar + * event. + */ + var calendarMonthChangedHandler = function() { + updateAllDropZonesState(); + }; + return { /** * Initialise the event handlers for the drag events. @@ -231,6 +396,8 @@ define([ document.addEventListener('dragover', dragoverHandler, false); document.addEventListener('dragleave', dragleaveHandler, false); document.addEventListener('drop', dropHandler, false); + document.addEventListener('dragend', dragendHandler, false); + $('body').on(CalendarEvents.monthChanged, calendarMonthChangedHandler); registered = true; } }, diff --git a/calendar/classes/external/calendar_event_exporter.php b/calendar/classes/external/calendar_event_exporter.php index 423cd92cc75..051d04957e8 100644 --- a/calendar/classes/external/calendar_event_exporter.php +++ b/calendar/classes/external/calendar_event_exporter.php @@ -26,6 +26,7 @@ namespace core_calendar\external; defined('MOODLE_INTERNAL') || die(); +use \core_calendar\local\event\container; use \core_course\external\course_summary_exporter; use \renderer_base; require_once($CFG->dirroot . '/course/lib.php'); @@ -57,6 +58,22 @@ class calendar_event_exporter extends event_exporter_base { $values['popupname'] = [ 'type' => PARAM_RAW, ]; + $values['mindaytimestamp'] = [ + 'type' => PARAM_INT, + 'optional' => true + ]; + $values['mindayerror'] = [ + 'type' => PARAM_TEXT, + 'optional' => true + ]; + $values['maxdaytimestamp'] = [ + 'type' => PARAM_INT, + 'optional' => true + ]; + $values['maxdayerror'] = [ + 'type' => PARAM_TEXT, + 'optional' => true + ]; return $values; } @@ -89,9 +106,9 @@ class calendar_event_exporter extends event_exporter_base { } else { // TODO MDL-58866 We do not have any way to find urls for events outside of course modules. $course = $event->get_course()->get('id') ?: SITEID; - $url = course_get_url($course); } + $values['url'] = $url->out(false); $values['islastday'] = false; $today = $this->related['type']->timestamp_to_date_array($this->related['today']); @@ -153,6 +170,10 @@ class calendar_event_exporter extends event_exporter_base { $values['calendareventtype'] = $this->get_calendar_event_type(); + if ($event->get_course_module()) { + $values = array_merge($values, $this->get_module_timestamp_limits($event)); + } + return $values; } @@ -184,4 +205,111 @@ class calendar_event_exporter extends event_exporter_base { return $type; } + + /** + * Return the set of minimum and maximum date timestamp values + * for the given event. + * + * @param event_interface $event + * @return array + */ + protected function get_module_timestamp_limits($event) { + $values = []; + $mapper = container::get_event_mapper(); + $starttime = $event->get_times()->get_start_time(); + + list($min, $max) = component_callback( + 'mod_' . $event->get_course_module()->get('modname'), + 'core_calendar_get_valid_event_timestart_range', + [$mapper->from_event_to_legacy_event($event)], + [null, null] + ); + + if ($min) { + $values = array_merge($values, $this->get_module_timestamp_min_limit($starttime, $min)); + } + + if ($max) { + $values = array_merge($values, $this->get_module_timestamp_max_limit($starttime, $max)); + } + + return $values; + } + + /** + * Get the correct minimum midnight day limit based on the event start time + * and the module's minimum timestamp limit. + * + * @param DateTimeInterface $starttime The event start time + * @param array $min The module's minimum limit for the event + */ + protected function get_module_timestamp_min_limit(\DateTimeInterface $starttime, $min) { + // We need to check that the minimum valid time is earlier in the + // day than the current event time so that if the user drags and drops + // the event to this day (which changes the date but not the time) it + // will result in a valid time start for the event. + // + // For example: + // An event that starts on 2017-01-10 08:00 with a minimum cutoff + // of 2017-01-05 09:00 means that 2017-01-05 is not a valid start day + // for the drag and drop because it would result in the event start time + // being set to 2017-01-05 08:00, which is invalid. Instead the minimum + // valid start day would be 2017-01-06. + $values = []; + $timestamp = $min[0]; + $errorstring = $min[1]; + $mindate = (new \DateTimeImmutable())->setTimestamp($timestamp); + $minstart = $mindate->setTime( + $starttime->format('H'), + $starttime->format('i'), + $starttime->format('s') + ); + $midnight = usergetmidnight($timestamp); + + if ($mindate <= $minstart) { + $values['mindaytimestamp'] = $midnight; + } else { + $tomorrow = (new \DateTime())->setTimestamp($midnight)->modify('+1 day'); + $values['mindaytimestamp'] = $tomorrow->getTimestamp(); + } + + // Get the human readable error message to display if the min day + // timestamp is violated. + $values['mindayerror'] = $errorstring; + return $values; + } + + /** + * Get the correct maximum midnight day limit based on the event start time + * and the module's maximum timestamp limit. + * + * @param DateTimeInterface $starttime The event start time + * @param array $max The module's maximum limit for the event + */ + protected function get_module_timestamp_max_limit(\DateTimeInterface $starttime, $max) { + // We're doing a similar calculation here as we are for the minimum + // day timestamp. See the explanation above. + $values; + $timestamp = $max[0]; + $errorstring = $max[1]; + $maxdate = (new \DateTimeImmutable())->setTimestamp($timestamp); + $maxstart = $maxdate->setTime( + $starttime->format('H'), + $starttime->format('i'), + $starttime->format('s') + ); + $midnight = usergetmidnight($timestamp); + + if ($maxdate >= $maxstart) { + $values['maxdaytimestamp'] = $midnight; + } else { + $yesterday = (new \DateTime())->setTimestamp($midnight)->modify('-1 day'); + $values['maxdaytimestamp'] = $yesterday->getTimestamp(); + } + + // Get the human readable error message to display if the max day + // timestamp is violated. + $values['maxdayerror'] = $errorstring; + return $values; + } } diff --git a/calendar/templates/month_detailed.mustache b/calendar/templates/month_detailed.mustache index 942e459120a..8f8fca5f29b 100644 --- a/calendar/templates/month_detailed.mustache +++ b/calendar/templates/month_detailed.mustache @@ -91,6 +91,18 @@ {{#canedit}} draggable="true" data-drag-type="move" + {{#mindaytimestamp}} + data-min-day-timestamp="{{.}}" + {{/mindaytimestamp}} + {{#mindayerror}} + data-min-day-error="{{.}}" + {{/mindayerror}} + {{#maxdaytimestamp}} + data-max-day-timestamp="{{.}}" + {{/maxdaytimestamp}} + {{#maxdayerror}} + data-max-day-error="{{.}}" + {{/maxdayerror}} {{/canedit}}> {{name}} diff --git a/calendar/tests/calendar_event_exporter_test.php b/calendar/tests/calendar_event_exporter_test.php new file mode 100644 index 00000000000..5ae3edbfd7b --- /dev/null +++ b/calendar/tests/calendar_event_exporter_test.php @@ -0,0 +1,150 @@ +. + +/** + * Calendar event exporter tests tests. + * + * @package core_calendar + * @copyright 2017 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use core_calendar\external\calendar_event_exporter; +use core_calendar\local\event\container; + +/** + * Calendar event exporter testcase. + * + * @copyright 2017 Ryan Wyllie + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_calendar_event_exporter_testcase extends advanced_testcase { + /** + * Data provider for the module timestamp min limit test case to confirm + * that the minimum time limit is set correctly on the boundary cases. + */ + public function get_module_timestamp_min_limit_test_cases() { + $now = time(); + $todaymidnight = usergetmidnight($now); + $tomorrowmidnight = $todaymidnight + DAYSECS; + $eightam = $todaymidnight + (60 * 60 * 8); + $starttime = (new DateTime())->setTimestamp($eightam); + + return [ + 'before min' => [ + $starttime, + [ + ($starttime->getTimestamp() + 1), + 'some error' + ], + $tomorrowmidnight + ], + 'equal min' => [ + $starttime, + [ + $starttime->getTimestamp(), + 'some error' + ], + $todaymidnight + ], + 'after min' => [ + $starttime, + [ + ($starttime->getTimestamp() - 1), + 'some error' + ], + $todaymidnight + ] + ]; + } + + /** + * @dataProvider get_module_timestamp_min_limit_test_cases() + */ + public function test_get_module_timestamp_min_limit($starttime, $min, $expected) { + $class = \core_calendar\external\calendar_event_exporter::class; + $mock = $this->getMockBuilder($class) + ->disableOriginalConstructor() + ->setMethods(null) + ->getMock(); + $reflector = new ReflectionClass($class); + $method = $reflector->getMethod('get_module_timestamp_min_limit'); + $method->setAccessible(true); + + $result = $method->invoke($mock, $starttime, $min); + $this->assertEquals($expected, $result['mindaytimestamp']); + $this->assertEquals($min[1], $result['mindayerror']); + } + + /** + * Data provider for the module timestamp min limit test case to confirm + * that the minimum time limit is set correctly on the boundary cases. + */ + public function get_module_timestamp_max_limit_test_cases() { + $now = time(); + $todaymidnight = usergetmidnight($now); + $yesterdaymidnight = $todaymidnight - DAYSECS; + $eightam = $todaymidnight + (60 * 60 * 8); + $starttime = (new DateTime())->setTimestamp($eightam); + + return [ + 'before max' => [ + $starttime, + [ + ($starttime->getTimestamp() + 1), + 'some error' + ], + $todaymidnight + ], + 'equal max' => [ + $starttime, + [ + $starttime->getTimestamp(), + 'some error' + ], + $todaymidnight + ], + 'after max' => [ + $starttime, + [ + ($starttime->getTimestamp() - 1), + 'some error' + ], + $yesterdaymidnight + ] + ]; + } + + /** + * @dataProvider get_module_timestamp_max_limit_test_cases() + */ + public function test_get_module_timestamp_max_limit($starttime, $max, $expected) { + $class = \core_calendar\external\calendar_event_exporter::class; + $mock = $this->getMockBuilder($class) + ->disableOriginalConstructor() + ->setMethods(null) + ->getMock(); + $reflector = new ReflectionClass($class); + $method = $reflector->getMethod('get_module_timestamp_max_limit'); + $method->setAccessible(true); + + $result = $method->invoke($mock, $starttime, $max); + $this->assertEquals($expected, $result['maxdaytimestamp']); + $this->assertEquals($max[1], $result['maxdayerror']); + } +} diff --git a/theme/bootstrapbase/less/moodle/bs4-compat.less b/theme/bootstrapbase/less/moodle/bs4-compat.less index ccc168ee750..8d4842e0695 100644 --- a/theme/bootstrapbase/less/moodle/bs4-compat.less +++ b/theme/bootstrapbase/less/moodle/bs4-compat.less @@ -297,3 +297,7 @@ background-color: darken(@blue, 10%); } } + +.bg-faded { + background-color: @grayLighter; +} diff --git a/theme/bootstrapbase/style/moodle.css b/theme/bootstrapbase/style/moodle.css index e178774d2da..a1aad5ce136 100644 --- a/theme/bootstrapbase/style/moodle.css +++ b/theme/bootstrapbase/style/moodle.css @@ -21921,3 +21921,6 @@ ul.indented-list { .bg-primary[href] { background-color: #0378a9; } +.bg-faded { + background-color: #eee; +} From 8c4b939cfec3b6ee5bf90d37bd2c19fa05513009 Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Thu, 28 Sep 2017 06:56:40 +0000 Subject: [PATCH 4/7] MDL-60058 choice: implement timestart range callback for calendar UI --- mod/choice/lib.php | 113 +++++++++++++++++++---- mod/choice/tests/lib_test.php | 164 +++++++++++++++++++++++++++++++++- 2 files changed, 256 insertions(+), 21 deletions(-) diff --git a/mod/choice/lib.php b/mod/choice/lib.php index 85441c326fc..8f8513231a8 100644 --- a/mod/choice/lib.php +++ b/mod/choice/lib.php @@ -1240,6 +1240,55 @@ function mod_choice_core_calendar_provide_event_action(calendar_event $event, ); } +/** + * This function calculates the minimum and maximum cutoff values for the timestart of + * the given event. + * + * It will return an array with two values, the first being the minimum cutoff value and + * the second being the maximum cutoff value. Either or both values can be null, which + * indicates there is no minimum or maximum, respectively. + * + * If a cutoff is required then the function must return an array containing the cutoff + * timestamp and error string to display to the user if the cutoff value is violated. + * + * A minimum and maximum cutoff return value will look like: + * [ + * [1505704373, 'The date must be after this date'], + * [1506741172, 'The date must be before this date'] + * ] + * + * @param calendar_event $event The calendar event to get the time range for + * @param stdClass|null $instance The module instance to get the range from + */ +function mod_choice_core_calendar_get_valid_event_timestart_range(\calendar_event $event, \stdClass $choice = null) { + global $DB; + + if (!$choice) { + $choice = $DB->get_record('choice', ['id' => $event->instance]); + } + + $mindate = null; + $maxdate = null; + + if ($event->eventtype == CHOICE_EVENT_TYPE_OPEN) { + if (!empty($choice->timeclose)) { + $maxdate = [ + $choice->timeclose, + get_string('openafterclose', 'choice') + ]; + } + } else if ($event->eventtype == CHOICE_EVENT_TYPE_CLOSE) { + if (!empty($choice->timeopen)) { + $mindate = [ + $choice->timeopen, + get_string('closebeforeopen', 'choice') + ]; + } + } + + return [$mindate, $maxdate]; +} + /** * This function will check that the given event is valid for it's * corresponding choice module. @@ -1253,23 +1302,23 @@ function mod_choice_core_calendar_provide_event_action(calendar_event $event, function mod_choice_core_calendar_validate_event_timestart(\calendar_event $event) { global $DB; - $record = $DB->get_record('choice', ['id' => $event->instance], '*', MUST_EXIST); - - if ($event->eventtype == CHOICE_EVENT_TYPE_OPEN) { - // The start time of the open event can't be equal to or after the - // close time of the choice activity. - if (!empty($record->timeclose) && $event->timestart > $record->timeclose) { - throw new \moodle_exception('openafterclose', 'choice'); - } - } else if ($event->eventtype == CHOICE_EVENT_TYPE_CLOSE) { - // The start time of the close event can't be equal to or earlier than the - // open time of the choice activity. - if (!empty($record->timeopen) && $event->timestart < $record->timeopen) { - throw new \moodle_exception('closebeforeopen', 'choice'); - } + if (!isset($event->instance)) { + return; } - return true; + // We need to read from the DB directly because course module may + // currently be getting created so it won't be in mod info yet. + $instance = $DB->get_record('choice', ['id' => $event->instance], '*', MUST_EXIST); + $timestart = $event->timestart; + list($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $instance); + + if ($min && $timestart < $min[0]) { + throw new \moodle_exception($min[1]); + } + + if ($max && $timestart > $max[0]) { + throw new \moodle_exception($max[1]); + } } /** @@ -1285,29 +1334,55 @@ function mod_choice_core_calendar_validate_event_timestart(\calendar_event $even function mod_choice_core_calendar_event_timestart_updated(\calendar_event $event) { global $DB; + $courseid = $event->courseid; + $modulename = $event->modulename; + $instanceid = $event->instance; + $modified = false; + + // Something weird going on. The event is for a different module so + // we should ignore it. + if ($modulename != 'choice') { + return; + } + + $coursemodule = get_fast_modinfo($courseid)->instances[$modulename][$instanceid]; + $context = context_module::instance($coursemodule->id); + + // The user does not have the capability to modify this activity. + if (!has_capability('moodle/course:manageactivities', $context)) { + return; + } + if ($event->eventtype == CHOICE_EVENT_TYPE_OPEN) { // If the event is for the choice activity opening then we should // set the start time of the choice activity to be the new start // time of the event. - $record = $DB->get_record('choice', ['id' => $event->instance], '*', MUST_EXIST); + $record = $DB->get_record('choice', ['id' => $instanceid], '*', MUST_EXIST); if ($record->timeopen != $event->timestart) { $record->timeopen = $event->timestart; $record->timemodified = time(); - $DB->update_record('choice', $record); + $modified = true; } } else if ($event->eventtype == CHOICE_EVENT_TYPE_CLOSE) { // If the event is for the choice activity closing then we should // set the end time of the choice activity to be the new start // time of the event. - $record = $DB->get_record('choice', ['id' => $event->instance], '*', MUST_EXIST); + $record = $DB->get_record('choice', ['id' => $instanceid], '*', MUST_EXIST); if ($record->timeclose != $event->timestart) { $record->timeclose = $event->timestart; $record->timemodified = time(); - $DB->update_record('choice', $record); + $modified = true; } } + + if ($modified) { + // Persist the instance changes. + $DB->update_record('choice', $record); + $event = \core\event\course_module_updated::create_from_cm($coursemodule, $context); + $event->trigger(); + } } /** diff --git a/mod/choice/tests/lib_test.php b/mod/choice/tests/lib_test.php index 5d1c19cc841..92ee1bf316e 100644 --- a/mod/choice/tests/lib_test.php +++ b/mod/choice/tests/lib_test.php @@ -554,7 +554,10 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { 'visible' => 1 ]); - $this->assertTrue(mod_choice_core_calendar_validate_event_timestart($event)); + mod_choice_core_calendar_validate_event_timestart($event); + // The function above will throw an exception if the event is + // invalid. + $this->assertTrue(true); } /** @@ -630,7 +633,10 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { 'visible' => 1 ]); - $this->assertTrue(mod_choice_core_calendar_validate_event_timestart($event)); + mod_choice_core_calendar_validate_event_timestart($event); + // The function above will throw an exception if the event isn't + // valid. + $this->assertTrue(true); } /** @@ -754,8 +760,16 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { 'visible' => 1 ]); + // Trigger and capture the event when adding a contact. + $sink = $this->redirectEvents(); + mod_choice_core_calendar_event_timestart_updated($event); + $triggeredevents = $sink->get_events(); + $moduleupdatedevents = array_filter($triggeredevents, function($e) { + return is_a($e, 'core\event\course_module_updated'); + }); + $choice = $DB->get_record('choice', ['id' => $choice->id]); // Ensure the timeopen property matches the event timestart. $this->assertEquals($newtimeopen, $choice->timeopen); @@ -763,6 +777,9 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { $this->assertEquals($timeclose, $choice->timeclose); // Ensure the timemodified property has been changed. $this->assertNotEquals($timemodified, $choice->timemodified); + // Confirm that a module updated event is fired when the module + // is changed. + $this->assertNotEmpty($moduleupdatedevents); } /** @@ -804,8 +821,16 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { 'visible' => 1 ]); + // Trigger and capture the event when adding a contact. + $sink = $this->redirectEvents(); + mod_choice_core_calendar_event_timestart_updated($event); + $triggeredevents = $sink->get_events(); + $moduleupdatedevents = array_filter($triggeredevents, function($e) { + return is_a($e, 'core\event\course_module_updated'); + }); + $choice = $DB->get_record('choice', ['id' => $choice->id]); // Ensure the timeclose property matches the event timestart. $this->assertEquals($newtimeclose, $choice->timeclose); @@ -813,5 +838,140 @@ class mod_choice_lib_testcase extends externallib_advanced_testcase { $this->assertEquals($timeopen, $choice->timeopen); // Ensure the timemodified property has been changed. $this->assertNotEquals($timemodified, $choice->timemodified); + // Confirm that a module updated event is fired when the module + // is changed. + $this->assertNotEmpty($moduleupdatedevents); + } + + /** + * An unkown event type should not have any limits + */ + public function test_mod_choice_core_calendar_get_valid_event_timestart_range_unknown_event() { + global $CFG, $DB; + require_once($CFG->dirroot . "/calendar/lib.php"); + + $this->resetAfterTest(true); + $this->setAdminUser(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $timeopen = time(); + $timeclose = $timeopen + DAYSECS; + $choice = new \stdClass(); + $choice->timeopen = $timeopen; + $choice->timeclose = $timeclose; + + // Create a valid event. + $event = new \calendar_event([ + 'name' => 'Test event', + 'description' => '', + 'format' => 1, + 'courseid' => $course->id, + 'groupid' => 0, + 'userid' => 2, + 'modulename' => 'choice', + 'instance' => 1, + 'eventtype' => CHOICE_EVENT_TYPE_OPEN . "SOMETHING ELSE", + 'timestart' => 1, + 'timeduration' => 86400, + 'visible' => 1 + ]); + + list ($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $choice); + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * The open event should be limited by the choice's timeclose property, if it's set. + */ + public function test_mod_choice_core_calendar_get_valid_event_timestart_range_open_event() { + global $CFG, $DB; + require_once($CFG->dirroot . "/calendar/lib.php"); + + $this->resetAfterTest(true); + $this->setAdminUser(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $timeopen = time(); + $timeclose = $timeopen + DAYSECS; + $choice = new \stdClass(); + $choice->timeopen = $timeopen; + $choice->timeclose = $timeclose; + + // Create a valid event. + $event = new \calendar_event([ + 'name' => 'Test event', + 'description' => '', + 'format' => 1, + 'courseid' => $course->id, + 'groupid' => 0, + 'userid' => 2, + 'modulename' => 'choice', + 'instance' => 1, + 'eventtype' => CHOICE_EVENT_TYPE_OPEN, + 'timestart' => 1, + 'timeduration' => 86400, + 'visible' => 1 + ]); + + // The max limit should be bounded by the timeclose value. + list ($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $choice); + + $this->assertNull($min); + $this->assertEquals($timeclose, $max[0]); + + // No timeclose value should result in no upper limit. + $choice->timeclose = 0; + list ($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $choice); + + $this->assertNull($min); + $this->assertNull($max); + } + + /** + * The close event should be limited by the choice's timeopen property, if it's set. + */ + public function test_mod_choice_core_calendar_get_valid_event_timestart_range_close_event() { + global $CFG, $DB; + require_once($CFG->dirroot . "/calendar/lib.php"); + + $this->resetAfterTest(true); + $this->setAdminUser(); + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); + $timeopen = time(); + $timeclose = $timeopen + DAYSECS; + $choice = new \stdClass(); + $choice->timeopen = $timeopen; + $choice->timeclose = $timeclose; + + // Create a valid event. + $event = new \calendar_event([ + 'name' => 'Test event', + 'description' => '', + 'format' => 1, + 'courseid' => $course->id, + 'groupid' => 0, + 'userid' => 2, + 'modulename' => 'choice', + 'instance' => 1, + 'eventtype' => CHOICE_EVENT_TYPE_CLOSE, + 'timestart' => 1, + 'timeduration' => 86400, + 'visible' => 1 + ]); + + // The max limit should be bounded by the timeclose value. + list ($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $choice); + + $this->assertEquals($timeopen, $min[0]); + $this->assertNull($max); + + // No timeclose value should result in no upper limit. + $choice->timeopen = 0; + list ($min, $max) = mod_choice_core_calendar_get_valid_event_timestart_range($event, $choice); + + $this->assertNull($min); + $this->assertNull($max); } } From a4f949442cb607bf03e987457849e171104c7b82 Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Fri, 29 Sep 2017 03:02:16 +0000 Subject: [PATCH 5/7] MDL-60058 calendar: fix event context issue in month_detailed --- calendar/classes/external/day_exporter.php | 6 ++++++ calendar/templates/month_detailed.mustache | 20 ++++++++++---------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/calendar/classes/external/day_exporter.php b/calendar/classes/external/day_exporter.php index ebe8d8686c8..6600b23f208 100644 --- a/calendar/classes/external/day_exporter.php +++ b/calendar/classes/external/day_exporter.php @@ -130,6 +130,10 @@ class day_exporter extends exporter { 'type' => calendar_event_exporter::read_properties_definition(), 'multiple' => true, ], + 'hasevents' => [ + 'type' => PARAM_BOOL, + 'default' => false, + ], 'calendareventtypes' => [ 'type' => PARAM_RAW, 'multiple' => true, @@ -211,6 +215,8 @@ class day_exporter extends exporter { return $exporter->export($output); }, $eventexporters); + $return['hasevents'] = !empty($return['events']); + $return['calendareventtypes'] = array_map(function($exporter) { return $exporter->get_calendar_event_type(); }, $eventexporters); diff --git a/calendar/templates/month_detailed.mustache b/calendar/templates/month_detailed.mustache index 8f8fca5f29b..41ce4d0abb7 100644 --- a/calendar/templates/month_detailed.mustache +++ b/calendar/templates/month_detailed.mustache @@ -71,13 +71,13 @@ data-region="day" data-new-event-timestamp="{{neweventtimestamp}}">
- {{#events.0}} + {{#hasevents}} {{mday}} - {{/events.0}} - {{^events.0}} + {{/hasevents}} + {{^hasevents}} {{mday}} - {{/events.0}} - {{#events.0}} + {{/hasevents}} + {{#hasevents}}
    {{#events}} @@ -111,17 +111,17 @@ {{/events}}
- {{/events.0}} + {{/hasevents}}
- {{#events.0}} + {{#hasevents}} {{mday}} - {{/events.0}} - {{^events.0}} + {{/hasevents}} + {{^hasevents}}
{{mday}}
- {{/events.0}} + {{/hasevents}}
{{/days}} From 028fa1443619dbbe5e930134d19e355884ec8c29 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Sun, 1 Oct 2017 20:08:07 +0800 Subject: [PATCH 6/7] MDL-60058 assign: Updating gradingduedate on drag/drop --- mod/assign/lib.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/mod/assign/lib.php b/mod/assign/lib.php index 34accd3dcc0..aa623c84c32 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -2054,6 +2054,15 @@ function mod_assign_core_calendar_event_timestart_updated(\calendar_event $event $instance->timemodified = time(); $modified = true; } + } else if ($event->eventtype == ASSIGN_EVENT_TYPE_GRADINGDUE) { + $instance = $assign->get_instance(); + $newduedate = $event->timestart; + + if ($newduedate != $instance->gradingduedate) { + $instance->gradingduedate = $newduedate; + $instance->timemodified = time(); + $modified = true; + } } if ($modified) { From 7ec6d873e19564ad084839f592596255aa6dcb0b Mon Sep 17 00:00:00 2001 From: Ryan Wyllie Date: Wed, 11 Oct 2017 06:26:16 +0000 Subject: [PATCH 7/7] MDL-60058 assign: stop teacher from seeing due date event on dashboard Thanks to Damyon Wiese for the patch. --- mod/assign/lib.php | 8 ++++++++ mod/assign/locallib.php | 22 +++++++++++++++------- mod/assign/tests/lib_test.php | 8 ++------ 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/mod/assign/lib.php b/mod/assign/lib.php index aa623c84c32..9c6c2c1cb27 100644 --- a/mod/assign/lib.php +++ b/mod/assign/lib.php @@ -1886,6 +1886,14 @@ function mod_assign_core_calendar_provide_event_action(calendar_event $event, return null; } + $participant = $assign->get_participant($USER->id); + + if (!$participant) { + // If the user is not a participant in the assignment then they have + // no action to take. This will filter out the events for teachers. + return null; + } + // The user has not yet submitted anything. Show the addsubmission link. $name = get_string('addsubmission', 'assign'); $url = new \moodle_url('/mod/assign/view.php', [ diff --git a/mod/assign/locallib.php b/mod/assign/locallib.php index 4f84e772494..9eb7a772f9c 100644 --- a/mod/assign/locallib.php +++ b/mod/assign/locallib.php @@ -2036,9 +2036,13 @@ class assign { * @return null|stdClass user record */ public function get_participant($userid) { - global $DB; + global $DB, $USER; - $participant = $DB->get_record('user', array('id' => $userid)); + if ($userid == $USER->id) { + $participant = clone ($USER); + } else { + $participant = $DB->get_record('user', array('id' => $userid)); + } if (!$participant) { return null; } @@ -5719,11 +5723,15 @@ class assign { return false; } - if ($userid == $graderid && - $this->submissions_open($userid) && - has_capability('mod/assign:submit', $this->context, $graderid)) { - // User can edit their own submission. - return true; + if ($userid == $graderid) { + if ($this->submissions_open($userid) && + has_capability('mod/assign:submit', $this->context, $graderid)) { + // User can edit their own submission. + return true; + } else { + // We need to return here because editothersubmission should never apply to a users own submission. + return false; + } } if (!has_capability('mod/assign:editothersubmission', $this->context, $graderid)) { diff --git a/mod/assign/tests/lib_test.php b/mod/assign/tests/lib_test.php index 976d182c940..79c924c15ee 100644 --- a/mod/assign/tests/lib_test.php +++ b/mod/assign/tests/lib_test.php @@ -486,12 +486,8 @@ class mod_assign_lib_testcase extends mod_assign_base_testcase { // Decorate action event. $actionevent = mod_assign_core_calendar_provide_event_action($event, $factory); - // Confirm the event was decorated. - $this->assertInstanceOf('\core_calendar\local\event\value_objects\action', $actionevent); - $this->assertEquals(get_string('addsubmission', 'assign'), $actionevent->get_name()); - $this->assertInstanceOf('moodle_url', $actionevent->get_url()); - $this->assertEquals(1, $actionevent->get_item_count()); - $this->assertFalse($actionevent->is_actionable()); + // The teacher should not have an action for a due date event. + $this->assertNull($actionevent); } public function test_assign_core_calendar_provide_event_action_duedate_as_student() {