Merge branch 'MDL-64119-master' of git://github.com/sarjona/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 22 Nov 2018 01:52:56 +0000 (09:52 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 22 Nov 2018 01:52:56 +0000 (09:52 +0800)
56 files changed:
backup/util/helper/backup_cron_helper.class.php
blocks/site_main_menu/styles.css
blocks/timeline/amd/build/calendar_events_repository.min.js
blocks/timeline/amd/src/calendar_events_repository.js
calendar/classes/local/api.php
calendar/classes/local/event/data_access/event_vault.php
calendar/classes/local/event/data_access/event_vault_interface.php
calendar/externallib.php
calendar/tests/event_vault_test.php
calendar/tests/externallib_test.php
grade/classes/privacy/provider.php
grade/report/grader/lib.php
grade/tests/privacy_test.php
lang/en/badges.php
lang/en/deprecated.txt
lang/en/grades.php
lib/classes/oauth2/api.php
lib/classes/oauth2/client.php
lib/db/services.php
lib/grade/grade_grade.php
lib/grade/grade_item.php
lib/grade/grade_object.php
lib/grade/tests/grade_grade_test.php
lib/grade/tests/grade_item_test.php
lib/tests/oauth2_test.php
message/amd/build/message_drawer_view_contacts_section.min.js [deleted file]
message/amd/build/message_drawer_view_conversation.min.js
message/amd/build/message_drawer_view_conversation_patcher.min.js
message/amd/build/message_drawer_view_conversation_renderer.min.js
message/amd/build/message_drawer_view_settings.min.js
message/amd/build/message_repository.min.js
message/amd/src/message_drawer_view_contacts_section.js [deleted file]
message/amd/src/message_drawer_view_conversation.js
message/amd/src/message_drawer_view_conversation_patcher.js
message/amd/src/message_drawer_view_conversation_renderer.js
message/amd/src/message_drawer_view_settings.js
message/amd/src/message_repository.js
message/externallib.php
message/lib.php
message/templates/message_drawer_view_conversation_header_content_type_private.mustache
message/templates/message_drawer_view_conversation_header_content_type_public.mustache
message/templates/message_drawer_view_settings_body.mustache
message/templates/message_drawer_view_settings_body_content.mustache [new file with mode: 0644]
message/templates/message_drawer_view_settings_body_content_notification_preferences.mustache [moved from message/templates/message_drawer_view_contacts_section.mustache with 55% similarity]
message/templates/message_drawer_view_settings_body_placeholder.mustache [new file with mode: 0644]
mod/assign/backup/moodle2/restore_assign_stepslib.php
mod/assign/feedback/comments/classes/privacy/provider.php
theme/bootstrapbase/less/bootstrap/sprites.less
theme/bootstrapbase/less/moodle/message.less
theme/bootstrapbase/readme_moodle.txt
theme/bootstrapbase/style/editor.css
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/core_message/message_drawer_lazy_load_list.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_private.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_conversation_header_content_type_public.mustache
theme/bootstrapbase/templates/core_message/message_drawer_view_overview_section.mustache

index e61b89a..683c446 100644 (file)
@@ -234,13 +234,13 @@ abstract class backup_cron_automated_helper {
             // Summary.
             $message .= get_string('summary') . "\n";
             $message .= "==================================================\n";
-            $message .= '  ' . get_string('courses') . '; ' . array_sum($count) . "\n";
-            $message .= '  ' . get_string('ok') . '; ' . $count[self::BACKUP_STATUS_OK] . "\n";
-            $message .= '  ' . get_string('skipped') . '; ' . $count[self::BACKUP_STATUS_SKIPPED] . "\n";
-            $message .= '  ' . get_string('error') . '; ' . $count[self::BACKUP_STATUS_ERROR] . "\n";
-            $message .= '  ' . get_string('unfinished') . '; ' . $count[self::BACKUP_STATUS_UNFINISHED] . "\n";
-            $message .= '  ' . get_string('warning') . '; ' . $count[self::BACKUP_STATUS_WARNING] . "\n";
-            $message .= '  ' . get_string('backupnotyetrun') . '; ' . $count[self::BACKUP_STATUS_NOTYETRUN]."\n\n";
+            $message .= '  ' . get_string('courses') . ': ' . array_sum($count) . "\n";
+            $message .= '  ' . get_string('ok') . ': ' . $count[self::BACKUP_STATUS_OK] . "\n";
+            $message .= '  ' . get_string('skipped') . ': ' . $count[self::BACKUP_STATUS_SKIPPED] . "\n";
+            $message .= '  ' . get_string('error') . ': ' . $count[self::BACKUP_STATUS_ERROR] . "\n";
+            $message .= '  ' . get_string('unfinished') . ': ' . $count[self::BACKUP_STATUS_UNFINISHED] . "\n";
+            $message .= '  ' . get_string('warning') . ': ' . $count[self::BACKUP_STATUS_WARNING] . "\n";
+            $message .= '  ' . get_string('backupnotyetrun') . ': ' . $count[self::BACKUP_STATUS_NOTYETRUN]."\n\n";
 
             //Reference
             if ($haserrors) {
index 9f4747a..226b934 100644 (file)
@@ -2,7 +2,7 @@
     clear: both;
 }
 
-.block_site_main_menu.block.list_block .unlist > li > .column {
+.block_site_main_menu.block .content > .unlist > li > .column {
     /* Made specific to win over .block.list_block .unlist > li > .column. */
     width: 100%;
     display: table;
index 5c6e35a..d9f1640 100644 (file)
Binary files a/blocks/timeline/amd/build/calendar_events_repository.min.js and b/blocks/timeline/amd/build/calendar_events_repository.min.js differ
index ace7909..dfe8c47 100644 (file)
@@ -145,6 +145,8 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
             args.timesortto = args.endtime;
             delete args.endtime;
         }
+        // Don't show events related to courses that the user is suspended in.
+        args.limittononsuspendedevents = true;
 
         var request = {
             methodname: 'core_calendar_get_action_events_by_timesort',
index ac0f613..c182c55 100644 (file)
@@ -118,6 +118,7 @@ class api {
      * @param int|null $timesortto The end timesort value (inclusive)
      * @param int|null $aftereventid Only return events after this one
      * @param int $limitnum Limit results to this amount (between 1 and 50)
+     * @param bool $lmittononsuspendedevents Limit course events to courses the user is active in (not suspended).
      * @return array A list of action_event_interface objects
      * @throws \moodle_exception
      */
@@ -125,7 +126,8 @@ class api {
         $timesortfrom = null,
         $timesortto = null,
         $aftereventid = null,
-        $limitnum = 20
+        $limitnum = 20,
+        $limittononsuspendedevents = false
     ) {
         global $USER;
 
@@ -144,7 +146,8 @@ class api {
             $afterevent = $event;
         }
 
-        return $vault->get_action_events_by_timesort($USER, $timesortfrom, $timesortto, $afterevent, $limitnum);
+        return $vault->get_action_events_by_timesort($USER, $timesortfrom, $timesortto, $afterevent, $limitnum,
+                $limittononsuspendedevents);
     }
 
     /**
index 121a113..3d59208 100644 (file)
@@ -197,11 +197,12 @@ class event_vault implements event_vault_interface {
         $timesortfrom = null,
         $timesortto = null,
         event_interface $afterevent = null,
-        $limitnum = 20
+        $limitnum = 20,
+        $limittononsuspendedevents = false
     ) {
         $courseids = array_map(function($course) {
             return $course->id;
-        }, enrol_get_all_users_courses($user->id));
+        }, enrol_get_all_users_courses($user->id, $limittononsuspendedevents));
 
         $groupids = array_reduce($courseids, function($carry, $courseid) use ($user) {
             $groupings = groups_get_user_groups($courseid, $user->id);
index 15c5a0c..28b9815 100644 (file)
@@ -93,6 +93,7 @@ interface event_vault_interface {
      * @param int             $timesortto   Events with timesort until this value (inclusive)
      * @param event_interface $afterevent   Only return events after this one
      * @param int             $limitnum     Return at most this number of events
+     * @param bool            $lmittononsuspendedevents Limit course events to courses the user is active in (not suspended).
      * @return event_interface
      */
     public function get_action_events_by_timesort(
@@ -100,7 +101,8 @@ interface event_vault_interface {
         $timesortfrom,
         $timesortto,
         event_interface $afterevent,
-        $limitnum
+        $limitnum,
+        $limittononsuspendedevents
     );
 
     /**
index a7d7840..abf2c20 100644 (file)
@@ -404,7 +404,9 @@ class core_calendar_external extends external_api {
                 'timesortfrom' => new external_value(PARAM_INT, 'Time sort from', VALUE_DEFAULT, 0),
                 'timesortto' => new external_value(PARAM_INT, 'Time sort to', VALUE_DEFAULT, null),
                 'aftereventid' => new external_value(PARAM_INT, 'The last seen event id', VALUE_DEFAULT, 0),
-                'limitnum' => new external_value(PARAM_INT, 'Limit number', VALUE_DEFAULT, 20)
+                'limitnum' => new external_value(PARAM_INT, 'Limit number', VALUE_DEFAULT, 20),
+                'limittononsuspendedevents' => new external_value(PARAM_BOOL,
+                        'Limit the events to courses the user is not suspended in', VALUE_DEFAULT, false)
             )
         );
     }
@@ -420,7 +422,7 @@ class core_calendar_external extends external_api {
      * @return array
      */
     public static function get_calendar_action_events_by_timesort($timesortfrom = 0, $timesortto = null,
-                                                       $aftereventid = 0, $limitnum = 20) {
+                                                       $aftereventid = 0, $limitnum = 20, $limittononsuspendedevents = false) {
         global $CFG, $PAGE, $USER;
 
         require_once($CFG->dirroot . '/calendar/lib.php');
@@ -433,6 +435,7 @@ class core_calendar_external extends external_api {
                 'timesortto' => $timesortto,
                 'aftereventid' => $aftereventid,
                 'limitnum' => $limitnum,
+                'limittononsuspendedevents' => $limittononsuspendedevents
             ]
         );
         $context = \context_user::instance($USER->id);
@@ -447,7 +450,8 @@ class core_calendar_external extends external_api {
             $params['timesortfrom'],
             $params['timesortto'],
             $params['aftereventid'],
-            $params['limitnum']
+            $params['limitnum'],
+            $params['limittononsuspendedevents']
         );
 
         $exportercache = new events_related_objects_cache($events);
index e4a1b21..5d90f93 100644 (file)
@@ -557,6 +557,37 @@ class core_calendar_event_vault_testcase extends advanced_testcase {
         $this->assertEquals('Assignment 1 due date', $usersevents['For user in no groups'][0]->get_name());
     }
 
+    /**
+     * Test that if a user is suspended that events related to that course are not shown.
+     * User 1 is suspended. User 2 is active.
+     */
+    public function test_get_action_events_by_timesort_with_suspended_user() {
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $this->setAdminuser();
+        $lesson = $this->getDataGenerator()->create_module('lesson', [
+                'name' => 'Lesson 1',
+                'course' => $course->id,
+                'available' => time(),
+                'deadline' => (time() + (60 * 60 * 24 * 5))
+            ]
+        );
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, null, 'manual', 0, 0, ENROL_USER_SUSPENDED);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $factory = new action_event_test_factory();
+        $strategy = new raw_event_retrieval_strategy();
+        $vault = new event_vault($factory, $strategy);
+
+        $user1events = $vault->get_action_events_by_timesort($user1, null, null, null, 20, true);
+        $this->assertEmpty($user1events);
+        $user2events = $vault->get_action_events_by_timesort($user2, null, null, null, 20, true);
+        $this->assertCount(1, $user2events);
+        $this->assertEquals('Lesson 1 closes', $user2events[0]->get_name());
+    }
+
     /**
      * Test that get_action_events_by_course returns events after the
      * provided timesort value.
index ed623d0..9edbc69 100644 (file)
@@ -937,6 +937,34 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         $this->assertNull($result['lastid']);
     }
 
+    /**
+     * Check that it is possible to restrict the calendar events to events where the user is not suspended in the course.
+     */
+    public function test_get_calendar_action_events_by_timesort_suspended_course() {
+        $this->resetAfterTest();
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $this->setAdminUser();
+        $lesson = $this->getDataGenerator()->create_module('lesson', [
+                'name' => 'Lesson 1',
+                'course' => $course->id,
+                'available' => time(),
+                'deadline' => (time() + (60 * 60 * 24 * 5))
+            ]
+        );
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, null, 'manual', 0, 0, ENROL_USER_SUSPENDED);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id);
+
+        $this->setUser($user1);
+        $result = core_calendar_external::get_calendar_action_events_by_timesort(0, null, 0, 20, true);
+        $this->assertEmpty($result->events);
+        $this->setUser($user2);
+        $result = core_calendar_external::get_calendar_action_events_by_timesort(0, null, 0, 20, true);
+        $this->assertCount(1, $result->events);
+        $this->assertEquals('Lesson 1 closes', $result->events[0]->name);
+    }
+
     /**
      * Requesting calendar events from a given course and time should return all
      * events with a sort time at or after the requested time. All events prior
index b5ea2e4..e9d9e95 100644 (file)
@@ -129,6 +129,8 @@ class provider implements
             'importonlyfeedback' => 'privacy:metadata:grade_import_values:importonlyfeedback'
         ], 'privacy:metadata:grade_import_values');
 
+        $collection->link_subsystem('core_files', 'privacy:metadata:filepurpose');
+
         return $collection;
     }
 
@@ -482,11 +484,24 @@ class provider implements
         static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
             $context = context_course::instance($record->gi_courseid);
             $gg = static::extract_grade_grade_from_record($record);
-            $carry[] = static::transform_grade($gg, $context);
+            $carry[] = static::transform_grade($gg, $context, false);
+
             return $carry;
 
         }, function($courseid, $data) use ($rootpath) {
             $context = context_course::instance($courseid);
+
+            $pathtofiles = [
+                get_string('grades', 'core_grades'),
+                get_string('feedbackfiles', 'core_grades')
+            ];
+            foreach ($data as $key => $grades) {
+                $gg = $grades['gradeobject'];
+                writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
+                    GRADE_FEEDBACK_FILEAREA, $gg->id);
+                unset($data[$key]['gradeobject']); // Do not want to export this later.
+            }
+
             writer::with_context($context)->export_data($rootpath, (object) ['grades' => $data]);
         });
 
@@ -508,13 +523,25 @@ class provider implements
         static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
             $context = context_course::instance($record->gi_courseid);
             $gg = static::extract_grade_grade_from_record($record, true);
-            $carry[] = array_merge(static::transform_grade($gg, $context), [
+            $carry[] = array_merge(static::transform_grade($gg, $context, true), [
                 'action' => static::transform_history_action($record->ggh_action)
             ]);
             return $carry;
 
         }, function($courseid, $data) use ($rootpath) {
             $context = context_course::instance($courseid);
+
+            $pathtofiles = [
+                get_string('grades', 'core_grades'),
+                get_string('feedbackhistoryfiles', 'core_grades')
+            ];
+            foreach ($data as $key => $grades) {
+                $gg = $grades['gradeobject'];
+                writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
+                    GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid);
+                unset($data[$key]['gradeobject']); // Do not want to export this later.
+            }
+
             writer::with_context($context)->export_related_data($rootpath, 'history', (object) ['grades' => $data]);
         });
 
@@ -585,7 +612,7 @@ class provider implements
         static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) {
             $context = context_course::instance($record->gi_courseid);
             $gg = static::extract_grade_grade_from_record($record);
-            $carry[] = array_merge(static::transform_grade($gg, $context), [
+            $carry[] = array_merge(static::transform_grade($gg, $context, false), [
                 'userid' => transform::user($gg->userid),
                 'created_or_modified_by_you' => transform::yesno(true),
             ]);
@@ -593,6 +620,18 @@ class provider implements
 
         }, function($courseid, $data) use ($relatedtomepath) {
             $context = context_course::instance($courseid);
+
+            $pathtofiles = [
+                get_string('grades', 'core_grades'),
+                get_string('feedbackfiles', 'core_grades')
+            ];
+            foreach ($data as $key => $grades) {
+                $gg = $grades['gradeobject'];
+                writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
+                    GRADE_FEEDBACK_FILEAREA, $gg->id);
+                unset($data[$key]['gradeobject']); // Do not want to export this later.
+            }
+
             writer::with_context($context)->export_related_data($relatedtomepath, 'grades', (object) ['grades' => $data]);
         });
 
@@ -614,7 +653,7 @@ class provider implements
         static::recordset_loop_and_export($recordset, 'gi_courseid', [], function($carry, $record) use ($userid) {
             $context = context_course::instance($record->gi_courseid);
             $gg = static::extract_grade_grade_from_record($record, true);
-            $carry[] = array_merge(static::transform_grade($gg, $context), [
+            $carry[] = array_merge(static::transform_grade($gg, $context, true), [
                 'userid' => transform::user($gg->userid),
                 'logged_in_user_was_you' => transform::yesno($userid == $record->loggeduser),
                 'author_of_change_was_you' => transform::yesno($userid == $gg->usermodified),
@@ -624,6 +663,18 @@ class provider implements
 
         }, function($courseid, $data) use ($relatedtomepath) {
             $context = context_course::instance($courseid);
+
+            $pathtofiles = [
+                get_string('grades', 'core_grades'),
+                get_string('feedbackhistoryfiles', 'core_grades')
+            ];
+            foreach ($data as $key => $grades) {
+                $gg = $grades['gradeobject'];
+                writer::with_context($gg->get_context())->export_area_files($pathtofiles, GRADE_FILE_COMPONENT,
+                    GRADE_HISTORY_FEEDBACK_FILEAREA, $gg->historyid);
+                unset($data[$key]['gradeobject']); // Do not want to export this later.
+            }
+
             writer::with_context($context)->export_related_data($relatedtomepath, 'grades_history',
                 (object) ['modified_records' => $data]);
         });
@@ -649,6 +700,10 @@ class provider implements
                 if (empty($itemids)) {
                     return;
                 }
+
+                self::delete_files($itemids, true);
+                self::delete_files($itemids, false);
+
                 list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
                 $DB->delete_records_select('grade_grades', "itemid $insql", $inparams);
                 $DB->delete_records_select('grade_grades_history', "itemid $insql", $inparams);
@@ -684,9 +739,14 @@ class provider implements
             return;
         }
 
+        // Delete all the files.
+        self::delete_files($itemids, true, [$userid]);
+        self::delete_files($itemids, false, [$userid]);
+
         // Delete all the grades.
         list($insql, $inparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
         $params = array_merge($inparams, ['userid' => $userid]);
+
         $DB->delete_records_select('grade_grades', "itemid $insql AND userid = :userid", $params);
         $DB->delete_records_select('grade_grades_history', "itemid $insql AND userid = :userid", $params);
     }
@@ -720,10 +780,15 @@ class provider implements
             return;
         }
 
+        // Delete all the files.
+        self::delete_files($itemids, true, $userids);
+        self::delete_files($itemids, false, $userids);
+
         // Delete all the grades.
         list($itemsql, $itemparams) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
         list($usersql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
         $params = array_merge($itemparams, $userparams);
+
         $DB->delete_records_select('grade_grades', "itemid $itemsql AND userid $usersql", $params);
         $DB->delete_records_select('grade_grades_history', "itemid $itemsql AND userid $usersql", $params);
     }
@@ -748,6 +813,21 @@ class provider implements
             return;
         }
         list($insql, $inparams) = $DB->get_in_or_equal($ids, SQL_PARAMS_NAMED);
+
+        // First, let's delete their files.
+        $sql = "
+            SELECT gi.id
+              FROM {grade_grades_history} ggh
+              JOIN {grade_items} gi
+                ON gi.id = ggh.itemid
+             WHERE ggh.userid = :userid";
+        $params = ['userid' => $userid];
+        $gradeitems = $DB->get_records_sql($sql, $params);
+        if ($gradeitems) {
+            $itemids = array_keys($gradeitems);
+            self::delete_files($itemids, true, [$userid]);
+        }
+
         $DB->delete_records_select('grade_grades_history', "id $insql", $inparams);
     }
 
@@ -951,6 +1031,7 @@ class provider implements
         $ggrecord = static::extract_record($record, $prefix);
         if ($ishistory) {
             // The grade history is not a real grade_grade so we remove the ID.
+            $historyid = $ggrecord->id;
             unset($ggrecord->id);
         }
         $gg = new grade_grade($ggrecord, false);
@@ -968,6 +1049,10 @@ class provider implements
             $gi->scale->load_items();
         }
 
+        if ($ishistory) {
+            $gg->historyid = $historyid;
+        }
+
         return $gg;
     }
 
@@ -1097,13 +1182,32 @@ class provider implements
      *
      * @param grade_grade $gg The grade object.
      * @param context $context The context.
+     * @param bool $ishistory Whether we're extracting a historical grade.
      * @return array
      */
-    protected static function transform_grade(grade_grade $gg, context $context) {
+    protected static function transform_grade(grade_grade $gg, context $context, bool $ishistory) {
         $gi = $gg->load_grade_item();
         $timemodified = $gg->timemodified ? transform::datetime($gg->timemodified) : null;
         $timecreated = $gg->timecreated ? transform::datetime($gg->timecreated) : $timemodified; // When null we use timemodified.
+
+        $filearea = $ishistory ? GRADE_HISTORY_FEEDBACK_FILEAREA : GRADE_FEEDBACK_FILEAREA;
+        $itemid = $ishistory ? $gg->historyid : $gg->id;
+        $subpath = $ishistory ? get_string('feedbackhistoryfiles', 'core_grades') : get_string('feedbackfiles', 'core_grades');
+
+        $pathtofiles = [
+            get_string('grades', 'core_grades'),
+            $subpath
+        ];
+        $gg->feedback = writer::with_context($gg->get_context())->rewrite_pluginfile_urls(
+            $pathtofiles,
+            GRADE_FILE_COMPONENT,
+            $filearea,
+            $itemid,
+            $gg->feedback
+        );
+
         return [
+            'gradeobject' => $gg,
             'item' => $gi->get_name(),
             'grade' => $gg->finalgrade,
             'grade_formatted' => grade_format_gradevalue($gg->finalgrade, $gi),
@@ -1114,4 +1218,56 @@ class provider implements
         ];
     }
 
+    /**
+     * Handles deleting files for a given list of grade items.
+     *
+     * If an array of userids if given then it handles deleting files for those users.
+     *
+     * @param array $itemids
+     * @param bool $ishistory
+     * @param array|null $userids
+     * @throws \coding_exception
+     * @throws \dml_exception
+     */
+    protected static function delete_files(array $itemids, bool $ishistory, array $userids = null) {
+        global $DB;
+
+        list($iteminnsql, $params) = $DB->get_in_or_equal($itemids, SQL_PARAMS_NAMED);
+        if (!is_null($userids)) {
+            list($userinnsql, $userparams) = $DB->get_in_or_equal($userids, SQL_PARAMS_NAMED);
+            $params = array_merge($params, $userparams);
+        }
+
+        if ($ishistory) {
+            $gradefields = static::get_fields_sql('grade_grades_history', 'ggh', 'ggh_');
+            $gradetable = 'grade_grades_history';
+            $tableprefix = 'ggh';
+            $filearea = GRADE_HISTORY_FEEDBACK_FILEAREA;
+        } else {
+            $gradefields = static::get_fields_sql('grade_grade', 'gg', 'gg_');
+            $gradetable = 'grade_grades';
+            $tableprefix = 'gg';
+            $filearea = GRADE_FEEDBACK_FILEAREA;
+        }
+
+        $gifields = static::get_fields_sql('grade_item', 'gi', 'gi_');
+
+        $fs = new \file_storage();
+        $sql = "SELECT $gradefields, $gifields
+                  FROM {{$gradetable}} $tableprefix
+                  JOIN {grade_items} gi
+                    ON gi.id = {$tableprefix}.itemid
+                 WHERE gi.id $iteminnsql ";
+        if (!is_null($userids)) {
+            $sql .= "AND {$tableprefix}.userid $userinnsql";
+        }
+
+        $grades = $DB->get_recordset_sql($sql, $params);
+        foreach ($grades as $grade) {
+            $gg = static::extract_grade_grade_from_record($grade, $ishistory);
+            $fileitemid = ($ishistory) ? $gg->historyid : $gg->id;
+            $fs->delete_area_files($gg->get_context()->id, GRADE_FILE_COMPONENT, $filearea, $fileitemid);
+        }
+        $grades->close();
+    }
 }
index b4de80a..cb3f837 100644 (file)
@@ -1901,37 +1901,40 @@ class grade_report_grader extends grade_report {
      * @return array An associative array of HTML sorting links+arrows
      */
     public function get_sort_arrows(array $extrafields = array()) {
-        global $OUTPUT;
+        global $OUTPUT, $CFG;
         $arrows = array();
 
         $strsortasc   = $this->get_lang_string('sortasc', 'grades');
         $strsortdesc  = $this->get_lang_string('sortdesc', 'grades');
-        $strfirstname = $this->get_lang_string('firstname');
-        $strlastname  = $this->get_lang_string('lastname');
         $iconasc = $OUTPUT->pix_icon('t/sort_asc', $strsortasc, '', array('class' => 'iconsmall sorticon'));
         $icondesc = $OUTPUT->pix_icon('t/sort_desc', $strsortdesc, '', array('class' => 'iconsmall sorticon'));
 
-        $firstlink = html_writer::link(new moodle_url($this->baseurl, array('sortitemid'=>'firstname')), $strfirstname);
-        $lastlink = html_writer::link(new moodle_url($this->baseurl, array('sortitemid'=>'lastname')), $strlastname);
-
-        $arrows['studentname'] = $lastlink;
-
-        if ($this->sortitemid === 'lastname') {
-            if ($this->sortorder == 'ASC') {
-                $arrows['studentname'] .= $iconasc;
-            } else {
-                $arrows['studentname'] .= $icondesc;
-            }
+        // Sourced from tablelib.php
+        // Check the full name display for sortable fields.
+        if (has_capability('moodle/site:viewfullnames', context_system::instance())) {
+            $nameformat = $CFG->alternativefullnameformat;
+        } else {
+            $nameformat = $CFG->fullnamedisplay;
         }
 
-        $arrows['studentname'] .= ' ' . $firstlink;
+        if ($nameformat == 'language') {
+            $nameformat = get_string('fullnamedisplay');
+        }
 
-        if ($this->sortitemid === 'firstname') {
-            if ($this->sortorder == 'ASC') {
-                $arrows['studentname'] .= $iconasc;
-            } else {
-                $arrows['studentname'] .= $icondesc;
+        $arrows['studentname'] = '';
+        $requirednames = order_in_string(get_all_user_name_fields(), $nameformat);
+        if (!empty($requirednames)) {
+            foreach ($requirednames as $name) {
+                $arrows['studentname'] .= html_writer::link(
+                    new moodle_url($this->baseurl, array('sortitemid' => $name)), $this->get_lang_string($name)
+                );
+                if ($this->sortitemid == $name) {
+                    $arrows['studentname'] .= $this->sortorder == 'ASC' ? $iconasc : $icondesc;
+                }
+                $arrows['studentname'] .= ' / ';
             }
+
+            $arrows['studentname'] = substr($arrows['studentname'], 0, -3);
         }
 
         foreach ($extrafields as $field) {
index 0de9e5a..d9c2d48 100644 (file)
@@ -404,6 +404,9 @@ class core_grades_privacy_testcase extends provider_testcase {
 
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
+
+        $fs = new file_storage();
+
         $dg = $this->getDataGenerator();
 
         $c1 = $dg->create_course();
@@ -414,21 +417,118 @@ class core_grades_privacy_testcase extends provider_testcase {
         $c1ctx = context_course::instance($c1->id);
         $c2ctx = context_course::instance($c2->id);
 
-        // Create some stuff.
-        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
-        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
-        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
-        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $a1 = $dg->create_module('assign', ['course' => $c1->id]);
+        $a2 = $dg->create_module('assign', ['course' => $c1->id]);
+        $a3 = $dg->create_module('assign', ['course' => $c2->id]);
+        $a4 = $dg->create_module('assign', ['course' => $c2->id]);
 
-        $gi1a->update_final_grade($u1->id, 1, 'test');
-        $gi1a->update_final_grade($u2->id, 1, 'test');
-        $gi1b->update_final_grade($u1->id, 1, 'test');
-        $gi2a->update_final_grade($u1->id, 1, 'test');
-        $gi2a->update_final_grade($u2->id, 1, 'test');
-        $gi2b->update_final_grade($u1->id, 1, 'test');
-        $gi2b->update_final_grade($u2->id, 1, 'test');
+        $a1context = context_module::instance($a1->cmid);
+        $a2context = context_module::instance($a2->cmid);
+        $a3context = context_module::instance($a3->cmid);
+        $a4context = context_module::instance($a4->cmid);
+
+        // Create some stuff.
+        $gi1a = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a1->id
+            ]
+        ), false);
+        $gi1b = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a2->id
+            ]
+        ), false);
+        $gi2a = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a3->id
+            ]
+        ), false);
+        $gi2b = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a4->id
+            ]
+        ), false);
+
+        $this->add_feedback_file_to_copy();
+
+        $grades['feedback'] = 'Nice feedback!';
+        $grades['feedbackformat'] = FORMAT_MOODLE;
+        $grades['feedbackfiles'] = [
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1
+        ];
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi1a->courseid, $gi1a->itemtype, $gi1a->itemmodule, $gi1a->iteminstance,
+            $gi1a->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi1a->courseid, $gi1a->itemtype, $gi1a->itemmodule, $gi1a->iteminstance,
+            $gi1a->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi1b->courseid, $gi1b->itemtype, $gi1b->itemmodule, $gi1b->iteminstance,
+            $gi1b->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi2a->courseid, $gi2a->itemtype, $gi2a->itemmodule, $gi2a->iteminstance,
+            $gi2a->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi2a->courseid, $gi2a->itemtype, $gi2a->itemmodule, $gi2a->iteminstance,
+            $gi2a->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi2b->courseid, $gi2b->itemtype, $gi2b->itemmodule, $gi2b->iteminstance,
+            $gi2b->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi2b->courseid, $gi2b->itemtype, $gi2b->itemmodule, $gi2b->iteminstance,
+            $gi2b->itemnumber, $grades);
         $gi2b->delete();
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1b->id]));
@@ -446,20 +546,107 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         provider::delete_data_for_all_users_in_context($u1ctx);
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
         $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // The user context is only reported when there are orphan historical grades, so we only delete those files.
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // User 2 still has historical files.
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         provider::delete_data_for_all_users_in_context($c2ctx);
         $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi2a->id]));
         $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
     }
 
     public function test_delete_data_for_user() {
         global $DB;
+
+        $fs = new file_storage();
+
         $dg = $this->getDataGenerator();
 
         $c1 = $dg->create_course();
@@ -471,19 +658,88 @@ class core_grades_privacy_testcase extends provider_testcase {
         $c1ctx = context_course::instance($c1->id);
         $c2ctx = context_course::instance($c2->id);
 
+        $a1 = $dg->create_module('assign', ['course' => $c1->id]);
+        $a2 = $dg->create_module('assign', ['course' => $c1->id]);
+        $a3 = $dg->create_module('assign', ['course' => $c2->id]);
+        $a4 = $dg->create_module('assign', ['course' => $c2->id]);
+
+        $a1context = context_module::instance($a1->cmid);
+        $a2context = context_module::instance($a2->cmid);
+        $a3context = context_module::instance($a3->cmid);
+        $a4context = context_module::instance($a4->cmid);
+
         // Create some stuff.
-        $gi1a = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
-        $gi1b = new grade_item($dg->create_grade_item(['courseid' => $c1->id]), false);
-        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
-        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
+        $gi1a = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a1->id
+            ]
+        ), false);
+        $gi1b = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a2->id
+            ]
+        ), false);
+        $gi2a = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a3->id
+            ]
+        ), false);
+        $gi2b = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a4->id
+            ]
+        ), false);
+
+        $this->add_feedback_file_to_copy();
+
+        $grades['feedback'] = 'Nice feedback!';
+        $grades['feedbackfiles'] = [
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1
+        ];
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi1a->courseid, $gi1a->itemtype, $gi1a->itemmodule, $gi1a->iteminstance,
+            $gi1a->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi1a->courseid, $gi1a->itemtype, $gi1a->itemmodule, $gi1a->iteminstance,
+            $gi1a->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi1b->courseid, $gi1b->itemtype, $gi1b->itemmodule, $gi1b->iteminstance,
+            $gi1b->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi2a->courseid, $gi2a->itemtype, $gi2a->itemmodule, $gi2a->iteminstance,
+            $gi2a->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi2a->courseid, $gi2a->itemtype, $gi2a->itemmodule, $gi2a->iteminstance,
+            $gi2a->itemnumber, $grades);
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi2b->courseid, $gi2b->itemtype, $gi2b->itemmodule, $gi2b->iteminstance,
+            $gi2b->itemnumber, $grades);
+
+        $grades['userid'] = $u2->id;
+        grade_update('mod/assign', $gi2b->courseid, $gi2b->itemtype, $gi2b->itemmodule, $gi2b->iteminstance,
+            $gi2b->itemnumber, $grades);
 
-        $gi1a->update_final_grade($u1->id, 1, 'test');
-        $gi1a->update_final_grade($u2->id, 1, 'test');
-        $gi1b->update_final_grade($u1->id, 1, 'test');
-        $gi2a->update_final_grade($u1->id, 1, 'test');
-        $gi2a->update_final_grade($u2->id, 1, 'test');
-        $gi2b->update_final_grade($u1->id, 1, 'test');
-        $gi2b->update_final_grade($u2->id, 1, 'test');
         $gi2b->delete();
 
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
@@ -494,6 +750,34 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$c1ctx->id]));
         $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
@@ -503,6 +787,34 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u1ctx->id]));
         $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
@@ -512,6 +824,34 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
 
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(4, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
         provider::delete_data_for_user(new approved_contextlist($u1, 'core_grades', [$u2ctx->id, $c2ctx->id]));
         $this->assertFalse($DB->record_exists('grade_grades', ['userid' => $u1->id, 'itemid' => $gi1a->id]));
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi1a->id]));
@@ -520,6 +860,34 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('grade_grades', ['userid' => $u2->id, 'itemid' => $gi2a->id]));
         $this->assertFalse($DB->record_exists('grade_grades_history', ['userid' => $u1->id, 'itemid' => $gi2b->id]));
         $this->assertTrue($DB->record_exists('grade_grades_history', ['userid' => $u2->id, 'itemid' => $gi2b->id]));
+
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $files = $fs->get_area_files($a2context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        $files = $fs->get_area_files($a3context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        // Grade item 2 was deleted, so the associated files were as well.
+        $files = $fs->get_area_files($a4context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
     }
 
     /**
@@ -597,7 +965,6 @@ class core_grades_privacy_testcase extends provider_testcase {
     }
 
     public function test_export_data_for_user_about_grades_and_history() {
-        global $DB;
         $dg = $this->getDataGenerator();
 
         $c1 = $dg->create_course();
@@ -626,17 +993,83 @@ class core_grades_privacy_testcase extends provider_testcase {
         grade_category::fetch_course_category($c2->id);
         $ci2 = grade_item::fetch_course_item($c2->id);
 
+        $this->add_feedback_file_to_copy();
+
+        $grades['feedbackfiles'] = [
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1
+        ];
+
+        $a1 = $dg->create_module('assign', ['course' => $c1->id]);
+
         // Create data that will sit in the user context because we will delete the grate item.
-        $gi1 = new grade_item($dg->create_grade_item(['courseid' => $c1->id, 'aggregationcoef2' => 1]), false);
-        $gi1->update_final_grade($ug1->id, 100, 'test', 'Well done!', FORMAT_PLAIN, $ua2->id);
-        $gi1->update_final_grade($ug1->id, 1, 'test', 'Hi', FORMAT_PLAIN, $ua2->id);
-        $gi1->update_final_grade($ug3->id, 12, 'test', 'Hello', FORMAT_PLAIN, $ua2->id);
+        $gi1 = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a1->id,
+                'aggregationcoef2' => 1
+            ]
+        ), false);
+
+        $grades['feedback'] = 'Well done!';
+        $grades['feedbackformat'] = FORMAT_PLAIN;
+        $grades['userid'] = $ug1->id;
+        $grades['usermodified'] = $ua2->id;
+        $grades['rawgrade'] = 100;
+        grade_update('mod/assign', $gi1->courseid, $gi1->itemtype, $gi1->itemmodule, $gi1->iteminstance,
+            $gi1->itemnumber, $grades);
+
+        $grades['feedback'] = 'Hi';
+        $grades['userid'] = $ug1->id;
+        $grades['usermodified'] = $ua2->id;
+        $grades['rawgrade'] = 1;
+        grade_update('mod/assign', $gi1->courseid, $gi1->itemtype, $gi1->itemmodule, $gi1->iteminstance,
+            $gi1->itemnumber, $grades);
+
+        $grades['feedback'] = 'Hello';
+        $grades['userid'] = $ug3->id;
+        $grades['usermodified'] = $ua2->id;
+        $grades['rawgrade'] = 12;
+        grade_update('mod/assign', $gi1->courseid, $gi1->itemtype, $gi1->itemmodule, $gi1->iteminstance,
+            $gi1->itemnumber, $grades);
 
         // Create another set for another user.
-        $gi2a = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
-        $gi2a->update_final_grade($ug1->id, 15, 'test', '', FORMAT_PLAIN, $ua2->id);
-        $gi2b = new grade_item($dg->create_grade_item(['courseid' => $c2->id]), false);
-        $gi2b->update_final_grade($ug1->id, 30, 'test', 'Well played!', FORMAT_PLAIN, $ua2->id);
+        $a2 = $dg->create_module('assign', ['course' => $c2->id]);
+        $a3 = $dg->create_module('assign', ['course' => $c2->id]);
+        $gi2a = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a2->id
+            ]
+        ), false);
+        $gi2b = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c2->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a3->id
+            ]
+        ), false);
+
+        $grades['feedback'] = '';
+        $grades['userid'] = $ug1->id;
+        $grades['usermodified'] = $ua2->id;
+        $grades['rawgrade'] = 15;
+        grade_update('mod/assign', $gi2a->courseid, $gi2a->itemtype, $gi2a->itemmodule, $gi2a->iteminstance,
+            $gi2a->itemnumber, $grades);
+
+        $grades['feedback'] = 'Well played!';
+        $grades['userid'] = $ug1->id;
+        $grades['usermodified'] = $ua2->id;
+        $grades['rawgrade'] = 30;
+        grade_update('mod/assign', $gi2b->courseid, $gi2b->itemtype, $gi2b->itemmodule, $gi2b->iteminstance,
+            $gi2b->itemnumber, $grades);
 
         // Export action user 1 everywhere.
         provider::export_user_data(new approved_contextlist($ua1, 'core_grades', [$ug1ctx->id, $ug2ctx->id,
@@ -667,6 +1100,17 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertEquals('Hello', $data->grades[1]['feedback']);
         $this->assertEquals(transform::yesno(true), $data->grades[1]['created_or_modified_by_you']);
 
+        $pathtofiles = [
+            get_string('grades', 'core_grades'),
+            get_string('feedbackfiles', 'core_grades')
+        ];
+        $file = writer::with_context($gi1->get_context())->get_files($pathtofiles)['feedback1.txt'];
+
+        $this->assertInstanceOf('stored_file', $file);
+        $this->assertEquals('feedback1.txt', $file->get_filename());
+
+        $relatedtomepath = array_merge($rootpath, [get_string('privacy:path:relatedtome', 'core_grades')]);
+
         // Here we are testing the export of history of grades that we've changed.
         $data = writer::with_context($c1ctx)->get_related_data($relatedtomepath, 'grades_history');
         $this->assertCount(3, $data->modified_records);
@@ -692,6 +1136,15 @@ class core_grades_privacy_testcase extends provider_testcase {
         $this->assertEquals(transform::yesno(false), $grade['logged_in_user_was_you']);
         $this->assertEquals(transform::yesno(true), $grade['author_of_change_was_you']);
 
+        $pathtofiles = [
+            get_string('grades', 'core_grades'),
+            get_string('feedbackhistoryfiles', 'core_grades')
+        ];
+        $file = writer::with_context($gi1->get_context())->get_files($pathtofiles)['feedback1.txt'];
+
+        $this->assertInstanceOf('stored_file', $file);
+        $this->assertEquals('feedback1.txt', $file->get_filename());
+
         // Create a history record with logged user.
         $this->setUser($ua3);
         $gi1->update_final_grade($ug3->id, 50, 'test', '...', FORMAT_PLAIN, $ua2->id);
@@ -712,11 +1165,11 @@ class core_grades_privacy_testcase extends provider_testcase {
         provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$c1ctx->id]));
         $data = writer::with_context($c1ctx)->get_data($rootpath);
         $this->assert_context_has_no_data($c2ctx);
-        $this->assertCount(2, $data->grades);
+        $this->assertCount(3, $data->grades);
         $grade = $data->grades[0];
         $this->assertEquals($ci1->get_name(), $grade['item']);
         $this->assertEquals(1, $grade['grade']);
-        $grade = $data->grades[1];
+        $grade = $data->grades[2];
         $this->assertEquals($gi1->get_name(), $grade['item']);
         $this->assertEquals(1, $grade['grade']);
         $this->assertEquals('Hi', $grade['feedback']);
@@ -726,24 +1179,24 @@ class core_grades_privacy_testcase extends provider_testcase {
         provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
         $this->assert_context_has_no_data($ug1ctx);
         $data = writer::with_context($c1ctx)->get_data($rootpath);
-        $this->assertCount(2, $data->grades);
+        $this->assertCount(3, $data->grades);
         $grade = $data->grades[0];
         $this->assertEquals($ci1->get_name(), $grade['item']);
         $this->assertEquals(1, $grade['grade']);
-        $grade = $data->grades[1];
+        $grade = $data->grades[2];
         $this->assertEquals($gi1->get_name(), $grade['item']);
         $this->assertEquals(1, $grade['grade']);
         $this->assertEquals('Hi', $grade['feedback']);
 
         $data = writer::with_context($c2ctx)->get_data($rootpath);
-        $this->assertCount(3, $data->grades);
+        $this->assertCount(5, $data->grades);
         $grade = $data->grades[0];
         $this->assertEquals($ci2->get_name(), $grade['item']);
-        $grade = $data->grades[1];
+        $grade = $data->grades[3];
         $this->assertEquals($gi2a->get_name(), $grade['item']);
         $this->assertEquals(15, $grade['grade']);
         $this->assertEquals('', $grade['feedback']);
-        $grade = $data->grades[2];
+        $grade = $data->grades[4];
         $this->assertEquals($gi2b->get_name(), $grade['item']);
         $this->assertEquals(30, $grade['grade']);
         $this->assertEquals('Well played!', $grade['feedback']);
@@ -756,10 +1209,10 @@ class core_grades_privacy_testcase extends provider_testcase {
         writer::reset();
         provider::export_user_data(new approved_contextlist($ug1, 'core_grades', [$ug1ctx->id, $c1ctx->id, $c2ctx->id]));
         $data = writer::with_context($c1ctx)->get_data($rootpath);
-        $this->assertCount(1, $data->grades);
+        $this->assertCount(2, $data->grades);
         $this->assertEquals($ci1->get_name(), $data->grades[0]['item']);
         $data = writer::with_context($c2ctx)->get_data($rootpath);
-        $this->assertCount(3, $data->grades);
+        $this->assertCount(5, $data->grades);
         $data = writer::with_context($ug1ctx)->get_related_data($rootpath, 'history');
         $this->assertCount(3, $data->grades);
         $grade = $data->grades[0];
@@ -1131,4 +1584,21 @@ class core_grades_privacy_testcase extends provider_testcase {
             $this->assertEmpty($data);
         }
     }
+
+    /**
+     * Creates a feedback file to copy to the gradebook area.
+     */
+    private function add_feedback_file_to_copy() {
+        $dummy = array(
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'feedback1.txt'
+        );
+
+        $fs = get_file_storage();
+        $fs->create_file_from_string($dummy, '');
+    }
 }
index e347eda..435c7c8 100644 (file)
@@ -71,8 +71,6 @@ $string['archivehelp'] = '<p>This option means that the badge will be marked as
 <p>If you would like your users to retain access to the earned badges it is important to select this option instead of fully deleting badges.</p>';
 $string['attachment'] = 'Attach badge to message';
 $string['attachment_help'] = 'If enabled, an issued badge will be attached to the recipient\'s email for download. (Attachments must be enabled in Site administration / Server / Email / Outgoing mail configuration to use this option.)';
-$string['authorimage'] = 'Image author';
-$string['authorimage_help'] = 'Author of the image, must be an URL.';
 $string['award'] = 'Award badge';
 $string['awardedtoyou'] = 'Issued to me';
 $string['awardoncron'] = 'Access to the badges was successfully enabled. Too many users can instantly earn this badge. To ensure site performance, this action will take some time to process.';
@@ -278,7 +276,6 @@ $string['error:clone'] = 'Cannot clone the badge.';
 $string['error:duplicatename'] = 'Badge with such name already exists in the system.';
 $string['error:externalbadgedoesntexist'] = 'Badge not found';
 $string['error:guestuseraccess'] = 'You are currently using guest access. To see badges you need to log in with your user account.';
-$string['error:invalidbadgeurl'] = 'Invalid issuer URL format. The URL should have a prefix http:// or https://.';
 $string['error:invalidcriteriatype'] = 'Invalid criteria type.';
 $string['error:invalidexpiredate'] = 'Expiry date has to be in the future.';
 $string['error:invalidexpireperiod'] = 'Expiry period cannot be negative or equal 0.';
@@ -537,3 +534,6 @@ $string['error:badjson'] = 'The connection attempt returned invalid data.';
 $string['error:noassertion'] = 'No assertion was returned by Persona. You may have closed the dialog before completing the login process.';
 $string['error:personaneedsjs'] = 'Currently, Javascript is required to connect to your backpack. If you can, enable Javascript and reload the page.';
 $string['signinwithyouremail'] = 'Sign in with your email';
+
+// Deprecated since Moodle 3.6.
+$string['error:invalidbadgeurl'] = 'Invalid issuer URL format. The URL should have a prefix http:// or https://.';
index c1939ef..ff428c2 100644 (file)
@@ -138,4 +138,5 @@ messagedselecteduserfailed,core
 eventmessagecontactblocked,core_message
 eventmessagecontactunblocked,core_message
 userisblockingyou,core_message
-userisblockingyounoncontact,core_message
\ No newline at end of file
+userisblockingyounoncontact,core_message
+error:invalidbadgeurl,core_badges
\ No newline at end of file
index b9dc67d..7beca40 100644 (file)
@@ -223,7 +223,9 @@ $string['feedback'] = 'Feedback';
 $string['feedback_help'] = 'This box enables any comments about the grade to be added.';
 $string['feedbackadd'] = 'Add feedback';
 $string['feedbackedit'] = 'Edit feedback';
+$string['feedbackfiles'] = 'Feedback files';
 $string['feedbackforgradeitems'] = 'Feedback for {$a}';
+$string['feedbackhistoryfiles'] = 'Feedback history files';
 $string['feedbacks'] = 'Feedbacks';
 $string['feedbacksaved'] = 'Feedback saved';
 $string['feedbackview'] = 'View feedback';
@@ -615,6 +617,7 @@ $string['prefrows'] = 'Special rows';
 $string['prefshow'] = 'Show/hide toggles';
 $string['previewrows'] = 'Preview rows';
 $string['privacy:metadata:categorieshistory'] = 'A record of previous versions of grade categories';
+$string['privacy:metadata:filepurpose'] = 'Feedback files stored in the gradebook for a user.';
 $string['privacy:metadata:grade_import_newitem'] = 'Temporary table for storing new grade_item names from grade import';
 $string['privacy:metadata:grade_import_newitem:importcode'] = 'A unique batch code for identifying one batch of imports';
 $string['privacy:metadata:grade_import_newitem:importer'] = 'User importing the data';
index b42e617..1189482 100644 (file)
@@ -319,7 +319,9 @@ class api {
     public static function create_endpoints_for_standard_issuer($type, $issuer) {
         require_capability('moodle/site:config', context_system::instance());
         if ($type == 'google') {
-            return self::create_endpoints_for_google($issuer);
+            $issuer = self::create_endpoints_for_google($issuer);
+            self::discover_endpoints($issuer);
+            return $issuer;
         } else if ($type == 'microsoft') {
             return self::create_endpoints_for_microsoft($issuer);
         } else if ($type == 'facebook') {
index bd27f8e..553e8fe 100644 (file)
@@ -175,7 +175,13 @@ class client extends \oauth2_client {
             }
             // Update values from $token. Don't use from_record because that would skip validation.
             $persistedtoken->set('token', $token->token);
-            $persistedtoken->set('expires', $token->expires);
+            if (isset($token->expires)) {
+                $persistedtoken->set('expires', $token->expires);
+            } else {
+                // Assume an arbitrary time span of 1 week for access tokens without expiration.
+                // The "refresh_system_tokens_task" is run hourly (by default), so the token probably won't last that long.
+                $persistedtoken->set('expires', time() + WEEKSECS);
+            }
             $persistedtoken->set('scope', $token->scope);
             $persistedtoken->save();
         } else {
index 9f73923..a9cef8a 100644 (file)
@@ -1278,6 +1278,7 @@ $functions = array(
         'type' => 'read',
         'capabilities' => 'moodle/user:editownmessageprofile',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
+        'ajax' => true
     ),
     'core_message_set_favourite_conversations' => array(
         'classname' => 'core_message_external',
index aef9f51..6426657 100644 (file)
@@ -1067,7 +1067,7 @@ class grade_grade extends grade_object {
      *
      * @param int|null $historyid
      */
-    protected function update_feedback_files(int $historyid = null){
+    protected function update_feedback_files(int $historyid = null) {
         global $CFG;
 
         // We only support feedback files for modules atm.
@@ -1087,6 +1087,23 @@ class grade_grade extends grade_object {
         return true;
     }
 
+    /**
+     * Handles deleting feedback files in the gradebook.
+     */
+    protected function delete_feedback_files() {
+        // We only support feedback files for modules atm.
+        if ($this->grade_item && $this->grade_item->is_external_item()) {
+            $context = $this->get_context();
+
+            $fs = new file_storage();
+            $fs->delete_area_files($context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA, $this->id);
+
+            // Grade history only gets deleted when we delete the whole grade item.
+        }
+
+        return true;
+    }
+
     /**
      * Deletes the grade_grade instance from the database.
      *
index d3b12f6..60edaf6 100644 (file)
@@ -421,6 +421,13 @@ class grade_item extends grade_object {
             }
         }
 
+        // Delete all the historical files.
+        // We only support feedback files for modules atm.
+        if ($this->is_external_item()) {
+            $fs = new file_storage();
+            $fs->delete_area_files($this->get_context()->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        }
+
         return true;
     }
 
index 33e907e..6e7373d 100644 (file)
@@ -297,9 +297,12 @@ abstract class grade_object {
                 $data->loggeduser   = $USER->id;
                 $DB->insert_record($this->table.'_history', $data);
             }
+
             $this->notify_changed(true);
-            return true;
 
+            $this->delete_feedback_files();
+
+            return true;
         } else {
             return false;
         }
@@ -435,6 +438,12 @@ abstract class grade_object {
     protected function update_feedback_files(int $historyid = null) {
     }
 
+    /**
+     * Handles deleting feedback files in the gradebook.
+     */
+    protected function delete_feedback_files() {
+    }
+
     /**
      * Returns the current hidden state of this grade_item
      *
index 11e961e..d398033 100644 (file)
@@ -39,6 +39,7 @@ class core_grade_grade_testcase extends grade_base_testcase {
         $this->sub_test_grade_grade_is_locked();
         $this->sub_test_grade_grade_set_hidden();
         $this->sub_test_grade_grade_is_hidden();
+        $this->sub_test_grade_grade_deleted();
     }
 
     protected function sub_test_grade_grade_construct() {
@@ -429,4 +430,80 @@ class core_grade_grade_testcase extends grade_base_testcase {
 
         $CFG->grade_minmaxtouse = $initialminmaxtouse;
     }
+
+    /**
+     * Tests when a grade_grade has been deleted.
+     */
+    public function sub_test_grade_grade_deleted() {
+        $dg = $this->getDataGenerator();
+
+        // Create the data we need for the tests.
+        $fs = new file_storage();
+        $u1 = $dg->create_user();
+        $c1 = $dg->create_course();
+        $a1 = $dg->create_module('assign', ['course' => $c1->id]);
+        $a1context = context_module::instance($a1->cmid);
+
+        $gi = new grade_item($dg->create_grade_item(
+            [
+                'courseid' => $c1->id,
+                'itemtype' => 'mod',
+                'itemmodule' => 'assign',
+                'iteminstance' => $a1->id
+            ]
+        ), false);
+
+        // Add feedback files to copy as our update.
+        $this->add_feedback_file_to_copy();
+
+        $grades['feedback'] = 'Nice feedback!';
+        $grades['feedbackformat'] = FORMAT_MOODLE;
+        $grades['feedbackfiles'] = [
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1
+        ];
+
+        $grades['userid'] = $u1->id;
+        grade_update('mod/assign', $gi->courseid, $gi->itemtype, $gi->itemmodule, $gi->iteminstance,
+            $gi->itemnumber, $grades);
+
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+
+        $gg = grade_grade::fetch(array('userid' => $u1->id, 'itemid' => $gi->id));
+
+        $gg->delete();
+
+        // Feedback file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_FEEDBACK_FILEAREA);
+        $this->assertEquals(0, count($files));
+
+        // History file area.
+        $files = $fs->get_area_files($a1context->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEquals(2, count($files));
+    }
+
+    /**
+     * Creates a feedback file to copy to the gradebook area.
+     */
+    private function add_feedback_file_to_copy() {
+        $dummy = array(
+            'contextid' => 1,
+            'component' => 'test',
+            'filearea' => 'testarea',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'feedback1.txt'
+        );
+
+        $fs = get_file_storage();
+        $fs->create_file_from_string($dummy, '');
+    }
 }
index ade1d89..2bda968 100644 (file)
@@ -113,10 +113,33 @@ class core_grade_item_testcase extends grade_base_testcase {
         $grade_item = new grade_item($this->grade_items[7], false); // Use a grade item not touched by previous (or future) tests.
         $this->assertTrue(method_exists($grade_item, 'delete'));
 
+        // Add two files.
+        $dummy = array(
+            'contextid' => $grade_item->get_context()->id,
+            'component' => GRADE_FILE_COMPONENT,
+            'filearea' => GRADE_HISTORY_FEEDBACK_FILEAREA,
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'feedback1.txt'
+        );
+
+        $fs = get_file_storage();
+        $fs->create_file_from_string($dummy, '');
+
+        $dummy['itemid'] = 2;
+        $fs->create_file_from_string($dummy, '');
+
+        $files = $fs->get_area_files($grade_item->get_context()->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        // Includes directories.
+        $this->assertCount(4, $files);
+
         $this->assertTrue($grade_item->delete());
 
         $this->assertFalse($DB->get_record('grade_items', array('id' => $grade_item->id)));
 
+        $files = $fs->get_area_files($grade_item->get_context()->id, GRADE_FILE_COMPONENT, GRADE_HISTORY_FEEDBACK_FILEAREA);
+        $this->assertEmpty($files);
+
         // Keep our reference collection the same as the database.
         unset($this->grade_items[7]);
     }
index 8686881..62ce761 100644 (file)
@@ -106,10 +106,39 @@ class core_oauth2_testcase extends advanced_testcase {
         $this->assertNotEquals($userfields[0]->get('id'), $todelete->get('id'));
     }
 
+    /**
+     * Data provider for \core_oauth2_testcase::test_get_system_oauth_client().
+     *
+     * @return array
+     */
+    public function system_oauth_client_provider() {
+        return [
+            [
+                (object) [
+                    'access_token' => 'fdas...',
+                    'token_type' => 'Bearer',
+                    'expires_in' => '3600',
+                    'id_token' => 'llfsd..',
+                ], HOURSECS - 10
+            ],
+            [
+                (object) [
+                    'access_token' => 'fdas...',
+                    'token_type' => 'Bearer',
+                    'id_token' => 'llfsd..',
+                ], WEEKSECS
+            ],
+        ];
+    }
+
     /**
      * Tests we can get a logged in oauth client for a system account.
+     *
+     * @dataProvider system_oauth_client_provider
+     * @param stdClass $responsedata The response data to be mocked.
+     * @param int $expiresin The expected expiration time.
      */
-    public function test_get_system_oauth_client() {
+    public function test_get_system_oauth_client($responsedata, $expiresin) {
         $this->resetAfterTest();
         $this->setAdminUser();
 
@@ -128,17 +157,21 @@ class core_oauth2_testcase extends advanced_testcase {
         $sys->create();
 
         // Fake a response with an access token.
-        $response = json_encode(
-            (object) [
-                'access_token' => 'fdas...',
-                'token_type' => 'Bearer',
-                'expires_in' => '3600',
-                'id_token' => 'llfsd..',
-            ]
-        );
+        $response = json_encode($responsedata);
         curl::mock_response($response);
         $client = \core\oauth2\api::get_system_oauth_client($issuer);
         $this->assertTrue($client->is_logged_in());
+
+        // Check token expiry.
+        $accesstoken = \core\oauth2\access_token::get_record(['issuerid' => $issuer->get('id')]);
+
+        // Get the difference between the actual and expected expiry times.
+        // They might differ by a couple of seconds depending on the timing when the token gets actually processed.
+        $expiresdifference = time() + $expiresin - $accesstoken->get('expires');
+
+        // Assert that the actual token expiration is more or less the same as the expected.
+        $this->assertGreaterThanOrEqual(0, $expiresdifference);
+        $this->assertLessThanOrEqual(3, $expiresdifference);
     }
 
     /**
diff --git a/message/amd/build/message_drawer_view_contacts_section.min.js b/message/amd/build/message_drawer_view_contacts_section.min.js
deleted file mode 100644 (file)
index b114a61..0000000
Binary files a/message/amd/build/message_drawer_view_contacts_section.min.js and /dev/null differ
index b8b42f3..e2e89b7 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation.min.js and b/message/amd/build/message_drawer_view_conversation.min.js differ
index 9eaab1d..28f7499 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_patcher.min.js and b/message/amd/build/message_drawer_view_conversation_patcher.min.js differ
index 16a74fe..cf35154 100644 (file)
Binary files a/message/amd/build/message_drawer_view_conversation_renderer.min.js and b/message/amd/build/message_drawer_view_conversation_renderer.min.js differ
index 7afce0c..5fbcdb9 100644 (file)
Binary files a/message/amd/build/message_drawer_view_settings.min.js and b/message/amd/build/message_drawer_view_settings.min.js differ
index 4e55810..cdc2fa7 100644 (file)
Binary files a/message/amd/build/message_repository.min.js and b/message/amd/build/message_repository.min.js differ
diff --git a/message/amd/src/message_drawer_view_contacts_section.js b/message/amd/src/message_drawer_view_contacts_section.js
deleted file mode 100644 (file)
index abc7106..0000000
+++ /dev/null
@@ -1,317 +0,0 @@
-// 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 <http://www.gnu.org/licenses/>.
-
-/**
- * Controls a section on the contacts page of the message drawer.
- *
- * @module     core_message/message_drawer_view_contacts_section
- * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-define(
-[
-    'jquery',
-    'core/notification',
-    'core/pubsub',
-    'core/templates',
-    'core/custom_interaction_events',
-    'core_message/message_repository',
-    'core_message/message_drawer_events'
-],
-function(
-    $,
-    Notification,
-    PubSub,
-    Templates,
-    CustomEvents,
-    MessageRepository,
-    Events
-) {
-
-    var LOAD_CONTACTS_LIMIT = 100;
-
-    var numContacts = 0;
-    var contactsOffset = 0;
-    var loadedAllContacts = false;
-    var waitForScrollLoad = false;
-
-    var SELECTORS = {
-        BLOCK_ICON_CONTAINER: '[data-region="block-icon-container"]',
-        CONTACTS: '[data-region="contacts-container"]',
-        LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
-        CONTENT_CONTAINER: '[data-region="contacts-content-container"]',
-        EMPTY_MESSAGE: '[data-region="empty-message-container"]',
-        PLACEHOLDER: '[data-region="placeholder-container"]'
-    };
-
-    var TEMPLATES = {
-        CONTACTS_LIST: 'core_message/message_drawer_contacts_list'
-    };
-
-    /**
-     * Show the loading icon.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var startLoading = function(body) {
-        body.find(SELECTORS.LOADING_ICON_CONTAINER).removeClass('hidden');
-    };
-
-    /**
-     * Hide the loading icon.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var stopLoading = function(body) {
-        body.find(SELECTORS.LOADING_ICON_CONTAINER).addClass('hidden');
-    };
-
-    /**
-     * Get the content container of the contacts body container element.
-     *
-     * @param {Object} body Contacts body container element.
-     * @return {Object} jQuery element
-     */
-    var getContentContainer = function(body) {
-        return body.find(SELECTORS.CONTENT_CONTAINER);
-    };
-
-    /**
-     * Get the contacts container of the contacts body container element.
-     *
-     * @param {Object} body Contacts body container element.
-     * @return {Object} jQuery element
-     */
-    var getContactsContainer = function(body) {
-        return body.find(SELECTORS.CONTACTS);
-    };
-
-    /**
-     * Show a message when no contacts found.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var showEmptyMessage = function(body) {
-        getContentContainer(body).addClass('hidden');
-        body.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
-    };
-
-    /**
-     * Hide the placeholder image.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var hidePlaceholder = function(body) {
-        body.find(SELECTORS.PLACEHOLDER).addClass('hidden');
-    };
-
-    /**
-     * Show the content container.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var showContent = function(body) {
-        getContentContainer(body).removeClass('hidden');
-    };
-
-    /**
-     * Find a contact element.
-     *
-     * @param {Object} body Contacts body container element.
-     * @param {Number} userId User id of contact.
-     * @return {Object} contact element.
-     */
-    var findContact = function(body, userId) {
-        return body.find('[data-contact-user-id="' + userId + '"]');
-    };
-
-    /**
-     * Get logged in userid.
-     *
-     * @param {Object} body Contacts body container element.
-     * @return {Number} Logged in userid.
-     */
-    var getLoggedInUserId = function(body) {
-        return body.attr('data-user-id');
-    };
-
-    /**
-     * Render the contacts in the content container.
-     *
-     * @param {Object} body Contacts body container element.
-     * @param {Array} contacts List of contacts.
-     * @return {Object} jQuery promise
-     */
-    var render = function(body, contacts) {
-        var contentContainer = getContentContainer(body);
-        return Templates.render(TEMPLATES.CONTACTS_LIST, {contacts: contacts})
-            .then(function(html) {
-                hidePlaceholder(body);
-                contentContainer.append(html);
-                showContent(body);
-                return html;
-            });
-    };
-
-    /**
-     * Load the user contacts and call the renderer.
-     *
-     * @param {Object} body Contacts body container element.
-     * @return {Object} jQuery promise
-     */
-    var loadContacts = function(body) {
-        var userId = getLoggedInUserId(body);
-        return MessageRepository.getContacts(userId, (LOAD_CONTACTS_LIMIT + 1), contactsOffset)
-            .then(function(result) {
-                return result.contacts;
-            })
-            .then(function(contacts) {
-                if (contacts.length > LOAD_CONTACTS_LIMIT) {
-                    contacts.pop();
-                } else {
-                    loadedAllContacts = true;
-                }
-                return contacts;
-            })
-            .then(function(contacts) {
-                if (contactsOffset == 0 && contacts.length == 0) {
-                    hidePlaceholder(body);
-                    showEmptyMessage(body);
-                }
-
-                numContacts = numContacts + contacts.length;
-
-                contactsOffset = contactsOffset + LOAD_CONTACTS_LIMIT;
-                if (contacts.length > 0) {
-                    return render(body, contacts);
-                }
-
-                return contacts;
-            });
-    };
-
-    /**
-     * Remove contact from view.
-     *
-     * @param {Object} body Contacts body container element.
-     * @param {Number} userId Contact userid.
-     */
-    var removeContact = function(body, userId) {
-        findContact(body, userId).remove();
-    };
-
-    /**
-     * Show the contact has been blocked.
-     *
-     * @param {Object} body Contacts body container element.
-     * @param {Number} userId Contact userid.
-     */
-    var showContactBlocked = function(body, userId) {
-        var contact = findContact(body, userId);
-        if (contact.length) {
-            contact.find(SELECTORS.BLOCK_ICON_CONTAINER).removeClass('hidden');
-        }
-    };
-
-    /**
-     * Show the contact has been unblocked.
-     *
-     * @param {Object} body Contacts body container element.
-     * @param {Number} userId Contact userid.
-     */
-    var showContactUnblocked = function(body, userId) {
-        var contact = findContact(body, userId);
-        if (contact.length) {
-            contact.find(SELECTORS.BLOCK_ICON_CONTAINER).addClass('hidden');
-        }
-    };
-
-    /**
-     * Listen to and handle events for contacts.
-     *
-     * @param {Object} body Contacts body container element.
-     */
-    var registerEventListeners = function(body) {
-        PubSub.subscribe(Events.CONTACT_ADDED, function() {
-            contactsOffset = 0;
-            loadedAllContacts = false;
-            getContentContainer(body).empty();
-            loadContacts(body);
-        });
-
-        PubSub.subscribe(Events.CONTACT_REMOVED, function(userId) {
-            removeContact(body, userId);
-        });
-
-        PubSub.subscribe(Events.CONTACT_BLOCKED, function(userId) {
-            showContactBlocked(body, userId);
-        });
-
-        PubSub.subscribe(Events.CONTACT_UNBLOCKED, function(userId) {
-            showContactUnblocked(body, userId);
-        });
-
-        var contactsContainer = getContactsContainer(body);
-
-        CustomEvents.define(contactsContainer, [
-            CustomEvents.events.scrollBottom,
-            CustomEvents.events.scrollLock
-        ]);
-
-        contactsContainer.on(CustomEvents.events.scrollBottom, function(e, data) {
-            var hasContacts = numContacts > 1;
-            if (!loadedAllContacts && hasContacts && !waitForScrollLoad) {
-                waitForScrollLoad = true;
-                startLoading(body);
-                loadContacts(body)
-                    .then(function() {
-                        stopLoading(body);
-                        waitForScrollLoad = false;
-                        return;
-                    })
-                    .catch(function(error) {
-                        stopLoading(body);
-                        waitForScrollLoad = false;
-                        Notification.exception(error);
-                    });
-            }
-            data.originalEvent.preventDefault();
-        });
-    };
-
-    /**
-     * Setup the contact page.
-     *
-     * @param {Object} header Contacts header container element.
-     * @param {Object} body Contacts body container element.
-     */
-    var show = function(header, body) {
-        body = $(body);
-        contactsOffset = 0;
-
-        if (!body.attr('data-contacts-init')) {
-            registerEventListeners(body);
-            body.attr('data-contacts-init', true);
-        }
-
-        if (!loadedAllContacts) {
-            loadContacts(body);
-        }
-    };
-
-    return {
-        show: show,
-    };
-});
index 277cf31..047e344 100644 (file)
@@ -926,8 +926,8 @@ function(
         // Search the list of the logged in user's contact requests to find the
         // one from this user.
         var loggedInUserId = viewState.loggedInUserId;
-        var requests = viewState.members[loggedInUserId].contactrequests.filter(function(request) {
-            return request.userid == userId;
+        var requests = viewState.members[userId].contactrequests.filter(function(request) {
+            return request.requesteduserid == loggedInUserId;
         });
         var request = requests[0];
         var newState = StateManager.setLoadingConfirmAction(viewState, true);
index c866238..5e51454 100644 (file)
@@ -298,6 +298,8 @@ function(
                     totalmembercount: newState.totalMemberCount,
                     imageurl: newState.imageUrl,
                     isfavourite: newState.isFavourite,
+                    // Don't show favouriting if we don't have a conversation.
+                    showfavourite: newState.id !== null,
                     userid: newOtherUser.id,
                     showonlinestatus: newOtherUser.showonlinestatus,
                     isonline: newOtherUser.isonline,
@@ -334,7 +336,9 @@ function(
                     subname: newState.subname,
                     totalmembercount: totalMemberCount,
                     imageurl: newState.imageUrl,
-                    isfavourite: newState.isFavourite
+                    isfavourite: newState.isFavourite,
+                    // Don't show favouriting if we don't have a conversation.
+                    showfavourite: newState.id !== null
                 }
             };
         }
@@ -619,13 +623,23 @@ function(
         var oldIsFavourite = state.isFavourite;
         var newIsFavourite = newState.isFavourite;
 
-        if (oldIsFavourite == newIsFavourite) {
+        if (state.id === null && newState.id === null) {
+            // The conversation isn't yet created so don't change anything.
+            return null;
+        } else if (state.id === null && newState.id !== null) {
+            // The conversation was created so we can show the add favourite button.
+            return 'show-add';
+        } else if (state.id !== null && newState.id === null) {
+            // We're changing from a created conversation to a new conversation so hide
+            // the favouriting functionality for now.
+            return 'hide';
+        } else if (oldIsFavourite == newIsFavourite) {
             // No change.
             return null;
         } else if (!oldIsFavourite && newIsFavourite) {
-            return true;
+            return 'show-remove';
         } else if (oldIsFavourite && !newIsFavourite) {
-            return false;
+            return 'show-add';
         } else {
             return null;
         }
index c5abf88..5e5cf90 100644 (file)
@@ -1224,15 +1224,27 @@ function(
      * @param {Object} footer The footer container element.
      * @param {Bool} isFavourite is this conversation a favourite.
      */
-    var renderIsFavourite = function(header, body, footer, isFavourite) {
-        if (isFavourite) {
-            header.find(SELECTORS.FAVOURITE_ICON_CONTAINER).removeClass('hidden');
-            header.find(SELECTORS.ACTION_CONFIRM_FAVOURITE).addClass('hidden');
-            header.find(SELECTORS.ACTION_CONFIRM_UNFAVOURITE).removeClass('hidden');
-        } else {
-            header.find(SELECTORS.FAVOURITE_ICON_CONTAINER).addClass('hidden');
-            header.find(SELECTORS.ACTION_CONFIRM_FAVOURITE).removeClass('hidden');
-            header.find(SELECTORS.ACTION_CONFIRM_UNFAVOURITE).addClass('hidden');
+    var renderIsFavourite = function(header, body, footer, state) {
+        var favouriteIcon = header.find(SELECTORS.FAVOURITE_ICON_CONTAINER);
+        var addFavourite = header.find(SELECTORS.ACTION_CONFIRM_FAVOURITE);
+        var removeFavourite = header.find(SELECTORS.ACTION_CONFIRM_UNFAVOURITE);
+
+        switch (state) {
+            case 'hide':
+                favouriteIcon.addClass('hidden');
+                addFavourite.addClass('hidden');
+                removeFavourite.addClass('hidden');
+                break;
+            case 'show-add':
+                favouriteIcon.addClass('hidden');
+                addFavourite.removeClass('hidden');
+                removeFavourite.addClass('hidden');
+                break;
+            case 'show-remove':
+                favouriteIcon.removeClass('hidden');
+                addFavourite.addClass('hidden');
+                removeFavourite.removeClass('hidden');
+                break;
         }
     };
 
index cd7eda6..8146bbd 100644 (file)
@@ -26,6 +26,7 @@ define(
     'core/notification',
     'core/str',
     'core/pubsub',
+    'core/templates',
     'core_message/message_repository',
     'core/custom_interaction_events',
     'core_message/message_drawer_events'
@@ -35,32 +36,81 @@ function(
     Notification,
     Str,
     PubSub,
+    Templates,
     Repository,
     CustomEvents,
     MessageDrawerEvents
 ) {
 
     var SELECTORS = {
+        CHECKBOX: 'input[type="checkbox"]',
         SETTINGS: '[data-region="settings"]',
-        PREFERENCE_CONTROL: '[data-region="preference-control"]',
         PRIVACY_PREFERENCE: '[data-preference="blocknoncontacts"] input[type="radio"]',
-        EMAIL_ENABLED_PREFERENCE: '[data-preference="emailnotifications"] input[type="checkbox"]',
+        NOTIFICATIONS_PREFERENCE: '[data-preference="notifications"] input[type="checkbox"]',
         ENTER_TO_SEND_PREFERENCE: '[data-preference="entertosend"] input[type="checkbox"]',
+        NOTIFICATION_PREFERENCES_CONTAINER: '[data-region="notification-preference-container"]',
+        CONTENT_CONTAINER: '[data-region="content-container"]',
+        PLACEHOLDER_CONTAINER: '[data-region="placeholder-container"]'
     };
 
-    var PREFERENCES_EMAIL = {
-        'message_provider_moodle_instantmessage_loggedoff': {
-            type: 'emailnotifications',
-            enabled: 'email',
-            disabled: 'none'
-        },
-        'message_provider_moodle_instantmessage_loggedin': {
-            type: 'emailnotifications',
-            enabled: 'email',
-            disabled: 'none'
+    var TEMPLATES = {
+        NOTIFICATION_PREFERENCES: 'core_message/message_drawer_view_settings_body_content_notification_preferences'
+    };
+
+    var NOTIFICATION_PREFERENCES_KEY = 'message_provider_moodle_instantmessage';
+
+    /**
+     * Select the correct radio button in the DOM for the privacy preference.
+     *
+     * @param {Object} body The settings body element.
+     * @param {Number} value Which radio button should be set
+     */
+    var setPrivacyPreference = function(body, value) {
+        var inputs = body.find(SELECTORS.PRIVACY_PREFERENCE);
+        inputs.each(function(index, input) {
+            input = $(input);
+            if (input.val() == value) {
+                input.prop('checked', true);
+            } else {
+                input.prop('checked', false);
+            }
+        });
+    };
+
+    /**
+     * Set the "enter to send" checkbox to the correct value in the DOM.
+     *
+     * @param {Object} body The settings body element.
+     * @param {Bool} value Whether enter to send is enabled or disabled.
+     */
+    var setEnterToSend = function(body, value) {
+        var checkbox = body.find(SELECTORS.ENTER_TO_SEND_PREFERENCE);
+
+        if (value) {
+            checkbox.prop('checked', true);
+        } else {
+            checkbox.prop('checked', false);
         }
     };
 
+    /**
+     * Send a request to the server to save the given preferences. Also publish
+     * a preferences updated event for the rest of the message drawer to
+     * subscribe to.
+     *
+     * @param {Number} loggedInUserId The logged in user id.
+     * @param {Array} preferences The preferences to set.
+     * @return {Object} jQuery promise
+     */
+    var savePreferences = function(loggedInUserId, preferences) {
+        return Repository.savePreferences(loggedInUserId, preferences)
+            .then(function() {
+                PubSub.publish(MessageDrawerEvents.PREFERENCES_UPDATED, preferences);
+                return;
+            })
+            .catch(Notification.exception);
+    };
+
     /**
      * Create all of the event listeners for the message preferences page.
      *
@@ -75,68 +125,139 @@ function(
             CustomEvents.events.activate
         ]);
 
-        settingsContainer.on(CustomEvents.events.activate, SELECTORS.EMAIL_ENABLED_PREFERENCE, function(e) {
-                var checkbox = $(e.target);
-                var setting = checkbox.closest(SELECTORS.PREFERENCE_CONTROL);
-                var type = setting.attr('data-preference');
-                var isEnabled = checkbox.prop('checked');
-                var preferences = Object.keys(PREFERENCES_EMAIL).reduce(function(carry, preference) {
-                    var config = PREFERENCES_EMAIL[preference];
-
-                    if (config.type === type) {
-                        carry.push({
-                            type: preference,
-                            value: isEnabled ? config.enabled : config.disabled
-                        });
-                    }
+        settingsContainer.on(CustomEvents.events.activate, SELECTORS.NOTIFICATIONS_PREFERENCE, function(e) {
+            var container = $(e.target).closest(SELECTORS.NOTIFICATION_PREFERENCES_CONTAINER);
+            var checkboxes = container.find(SELECTORS.CHECKBOX);
+            if (!checkboxes.length) {
+                return;
+            }
+            // The preference value is all of the enabled processors, comma separated, so let's
+            // see which ones are enabled.
+            var values = checkboxes.toArray().reduce(function(carry, checkbox) {
+                checkbox = $(checkbox);
+                if (checkbox.prop('checked')) {
+                    carry.push(checkbox.attr('data-name'));
+                }
 
-                    return carry;
-                }, []);
+                return carry;
+            }, []);
+            var newValue = values.length ? values.join(',') : 'none';
+            var preferences = [
+                {
+                    type: 'message_provider_moodle_instantmessage_loggedoff',
+                    value: newValue
+                },
+                {
+                    type: 'message_provider_moodle_instantmessage_loggedin',
+                    value: newValue
+                }
+            ];
 
-                Repository.savePreferences(loggedInUserId, preferences)
-                    .then(function() {
-                        PubSub.publish(MessageDrawerEvents.PREFERENCES_UPDATED, preferences);
-                        return;
-                    })
-                    .catch(Notification.exception);
-            }
-        );
+            savePreferences(loggedInUserId, preferences);
+        });
 
         settingsContainer.on(CustomEvents.events.activate, SELECTORS.PRIVACY_PREFERENCE, function(e) {
-                var newValue = $(e.target).val();
-                var preferences = [
-                    {
-                        type: 'message_blocknoncontacts',
-                        value: newValue
-                    }
-                ];
-
-                Repository.savePreferences(loggedInUserId, preferences)
-                    .then(function() {
-                        PubSub.publish(MessageDrawerEvents.PREFERENCES_UPDATED, preferences);
-                        return;
-                    })
-                    .catch(Notification.exception);
-            }
-        );
+            var newValue = $(e.target).val();
+            var preferences = [
+                {
+                    type: 'message_blocknoncontacts',
+                    value: newValue
+                }
+            ];
+
+            savePreferences(loggedInUserId, preferences);
+        });
 
         settingsContainer.on(CustomEvents.events.activate, SELECTORS.ENTER_TO_SEND_PREFERENCE, function(e) {
-                var newValue = $(e.target).prop('checked');
-                var preferences = [
-                    {
-                        type: 'message_entertosend',
-                        value: newValue
-                    }
-                ];
-
-                Repository.savePreferences(loggedInUserId, preferences)
-                    .then(function() {
-                        PubSub.publish(MessageDrawerEvents.PREFERENCES_UPDATED, preferences);
-                        return;
-                    })
-                    .catch(Notification.exception);
-            }
-        );
+            var newValue = $(e.target).prop('checked');
+            var preferences = [
+                {
+                    type: 'message_entertosend',
+                    value: newValue
+                }
+            ];
+
+            savePreferences(loggedInUserId, preferences);
+        });
+    };
+
+    /**
+     * Initialise the module by loading the user's messaging preferences from the server and
+     * rendering them in the settings page.
+     *
+     * Moodle may have many (or no) message processors enabled to notify the user when they
+     * receive messages. We need to dynamically build the settings page based on which processors
+     * are configured for the user.
+     *
+     * @param {Object} body The settings body element.
+     * @param {Number} loggedInUserId The logged in user id.
+     */
+    var init = function(body, loggedInUserId) {
+        // Load the message preferences from the server.
+        Repository.getUserMessagePreferences(loggedInUserId)
+            .then(function(response) {
+                // Set the values of the stright forward preferences.
+                setPrivacyPreference(body, response.blocknoncontacts);
+                setEnterToSend(body, response.entertosend);
+
+                // Parse the list of other preferences into a more usable format.
+                var notificationProcessors = [];
+                if (response.preferences.components.length) {
+                    response.preferences.components.forEach(function(component) {
+                        if (component.notifications.length) {
+                            // Filter down to just the notification processors that work on instant
+                            // messaging. We don't care about another other ones.
+                            var notificationPreferences = component.notifications.filter(function(notification) {
+                                return notification.preferencekey == NOTIFICATION_PREFERENCES_KEY;
+                            });
+
+                            if (notificationPreferences.length) {
+                                // Messaging only has one config at the moment which is for notifications
+                                // on personal messages.
+                                var configuration = component.notifications[0];
+                                notificationProcessors = configuration.processors.map(function(processor) {
+                                    // Consider the the processor enabled if either preference is set. This is
+                                    // for backwards compatibility. Going forward they will be treated as one
+                                    // setting.
+                                    var checked = processor.loggedin.checked || processor.loggedoff.checked;
+                                    return {
+                                        displayname: processor.displayname,
+                                        name: processor.name,
+                                        checked: checked,
+                                        // The admin can force processors to be enabled at a site level so
+                                        // we need to check if this processor has been locked by the admin.
+                                        locked: processor.locked,
+                                        lockedmessage: processor.lockedmessage || null,
+                                    };
+                                });
+                            }
+                        }
+                    });
+                }
+
+                var container = body.find(SELECTORS.NOTIFICATION_PREFERENCES_CONTAINER);
+                if (notificationProcessors.length) {
+                    // We have processors (i.e. email, mobile, jabber) to show.
+                    container.removeClass('hidden');
+                    // Render the processor options.
+                    return Templates.render(TEMPLATES.NOTIFICATION_PREFERENCES, {processors: notificationProcessors})
+                        .then(function(html) {
+                            container.append(html);
+                            return html;
+                        });
+                } else {
+                    return true;
+                }
+            })
+            .then(function() {
+                // We're done loading so hide the loading placeholder and show the settings.
+                body.find(SELECTORS.CONTENT_CONTAINER).removeClass('hidden');
+                body.find(SELECTORS.PLACEHOLDER_CONTAINER).addClass('hidden');
+                // Register the event listers for if the user wants to change the preferences.
+                registerEventListeners(body, loggedInUserId);
+                return;
+            })
+            .catch(Notification.exception);
     };
 
     /**
@@ -150,7 +271,7 @@ function(
      */
     var show = function(header, body, loggedInUserId) {
         if (!body.attr('data-init')) {
-            registerEventListeners(body, loggedInUserId);
+            init(body, loggedInUserId);
             body.attr('data-init', true);
         }
 
index 8fa57e8..507e1ec 100644 (file)
@@ -954,6 +954,22 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         return Ajax.call([request])[0];
     };
 
+    /**
+     * Get the user's message preferences.
+     *
+     * @param {int} userId The user id to load preferences for
+     * @return {object} jQuery promise
+     */
+    var getUserMessagePreferences = function(userId) {
+        var request = {
+            methodname: 'core_message_get_user_message_preferences',
+            args: {
+                userid: userId
+            }
+        };
+        return Ajax.call([request])[0];
+    };
+
     return {
         query: query,
         countUnreadConversations: countUnreadConversations,
@@ -985,6 +1001,7 @@ define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notificat
         setFavouriteConversations: setFavouriteConversations,
         unsetFavouriteConversations: unsetFavouriteConversations,
         getMemberInfo: getMemberInfo,
-        markAllConversationMessagesAsRead: markAllConversationMessagesAsRead
+        markAllConversationMessagesAsRead: markAllConversationMessagesAsRead,
+        getUserMessagePreferences: getUserMessagePreferences
     };
 });
index f842b85..500638c 100644 (file)
@@ -3918,6 +3918,8 @@ class core_message_external extends external_api {
                                                     'displayname' => new external_value(PARAM_TEXT, 'Display name'),
                                                     'name' => new external_value(PARAM_PLUGIN, 'Processor name'),
                                                     'locked' => new external_value(PARAM_BOOL, 'Is locked by admin?'),
+                                                    'lockedmessage' => new external_value(PARAM_TEXT,
+                                                        'Text to display if locked', VALUE_OPTIONAL),
                                                     'userconfigured' => new external_value(PARAM_INT, 'Is configured?'),
                                                     'loggedin' => new external_single_structure(
                                                         array(
@@ -4067,6 +4069,7 @@ class core_message_external extends external_api {
             'warnings' => array(),
             'preferences' => $notificationlistoutput->export_for_template($renderer),
             'blocknoncontacts' => \core_message\api::get_user_privacy_messaging_preference($user->id),
+            'entertosend' => get_user_preferences('message_entertosend', false, $user)
         );
         return $result;
     }
@@ -4082,6 +4085,7 @@ class core_message_external extends external_api {
             array(
                 'preferences' => self::get_preferences_structure(),
                 'blocknoncontacts' => new external_value(PARAM_INT, 'Privacy messaging setting to define who can message you'),
+                'entertosend' => new external_value(PARAM_BOOL, 'User preference for using enter to send messages'),
                 'warnings' => new external_warnings(),
             )
         );
index 53487a7..16196d0 100644 (file)
@@ -851,31 +851,22 @@ function core_message_standard_after_main_region_html() {
     $requestcount = \core_message\api::count_received_contact_requests($USER);
     $contactscount = \core_message\api::count_contacts($USER->id);
 
-    // Get the privacy settings options for being messaged.
-    $privacysetting = \core_message\api::get_user_privacy_messaging_preference($USER->id);
     $choices = [];
     $choices[] = [
         'value' => \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS,
-        'text' => get_string('contactableprivacy_onlycontacts', 'message'),
-        'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_ONLYCONTACTS)
+        'text' => get_string('contactableprivacy_onlycontacts', 'message')
     ];
     $choices[] = [
         'value' => \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER,
-        'text' => get_string('contactableprivacy_coursemember', 'message'),
-        'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_COURSEMEMBER)
+        'text' => get_string('contactableprivacy_coursemember', 'message')
     ];
     if (!empty($CFG->messagingallusers)) {
         // Add the MESSAGE_PRIVACY_SITE option when site-wide messaging between users is enabled.
         $choices[] = [
             'value' => \core_message\api::MESSAGE_PRIVACY_SITE,
-            'text' => get_string('contactableprivacy_site', 'message'),
-            'checked' => ($privacysetting == \core_message\api::MESSAGE_PRIVACY_SITE)
+            'text' => get_string('contactableprivacy_site', 'message')
         ];
     }
-    // Email settings.
-    $emailloggedin = get_user_preferences('message_provider_moodle_instantmessage_loggedin', 'none', $USER);
-    $emailloggedoff = get_user_preferences('message_provider_moodle_instantmessage_loggedoff', 'none', $USER);
-    $emailenabled = $emailloggedin == 'email' || $emailloggedoff == 'email';
 
     // Enter to send.
     $entertosend = get_user_preferences('message_entertosend', false, $USER);
@@ -941,7 +932,6 @@ function core_message_standard_after_main_region_html() {
         ],
         'settings' => [
             'privacy' => $choices,
-            'emailenabled' => $emailenabled,
             'entertosend' => $entertosend
         ]
     ]);
index fb428de..5d614f9 100644 (file)
             <a class="dropdown-item" href="#" data-action="view-contact">
                 {{#str}} info, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-favourite">
+            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-favourite">
                 {{#str}} addtofavourites, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-unfavourite">
+            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-unfavourite">
                 {{#str}} removefromfavourites, core_message {{/str}}
             </a>
             <a class="dropdown-item {{#isblocked}}hidden{{/isblocked}}" href="#" data-action="request-block">
index 69ecc3f..07950f8 100644 (file)
             <a class="dropdown-item" href="#" data-action="view-group-info">
                 {{#str}} groupinfo, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-favourite">
+            <a class="dropdown-item {{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-favourite">
                 {{#str}} addtofavourites, core_message {{/str}}
             </a>
-            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}}" href="#" data-action="confirm-unfavourite">
+            <a class="dropdown-item {{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" href="#" data-action="confirm-unfavourite">
                 {{#str}} removefromfavourites, core_message {{/str}}
             </a>
         </div>
index 92443cf..cc11992 100644 (file)
 }}
 
 <div class="h-100 hidden bg-white" aria-hidden="true" data-region="view-settings">
-{{#settings}}
-    <div data-region="settings" class="p-3">
-        <h3 class="h6 font-weight-bold">{{#str}}privacy, message{{/str}}</h3>
-        <p>{{#str}}privacy_desc, message{{/str}}</p>
-        <div
-            data-region="preference-control"
-            data-preference="blocknoncontacts"
-            class="mb-3"
-        >
-            {{#privacy}}
-                <div class="custom-control custom-radio mb-2">
-                    <input
-                        type="radio"
-                        name="message_blocknoncontacts"
-                        class="custom-control-input"
-                        id="block-noncontacts-{{uniqid}}-{{value}}"
-                        value="{{value}}"
-                        {{#checked}}checked{{/checked}}
-                    >
-                    <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
-                        {{text}}
-                    </label>
-                </div>
-            {{/privacy}}
-        </div>
-        <h3 class="mb-2 mt-4 h6 font-weight-bold">{{#str}}categoryemail, admin{{/str}}</h3>
-        <div
-            data-region="preference-control"
-            data-preference="emailnotifications"
-        >
-            <span class="switch">
-                <input type="checkbox"
-                    id="email-alerts-{{uniqid}}"
-                    {{#emailenabled}}checked{{/emailenabled}}
-                >
-                <label for="email-alerts-{{uniqid}}">
-                    {{#str}} emailalert, hub {{/str}}
-                </label>
-            </span>
-        </div>
-        <h3 class="mb-2 mt-4 h6 font-weight-bold">{{#str}} general, core {{/str}}</h3>
-        <div
-            data-region="preference-control"
-            data-preference="entertosend"
-        >
-            <span class="switch">
-                <input type="checkbox"
-                    id="enter-to-send-{{uniqid}}"
-                    {{#entertosend}}checked{{/entertosend}}
-                >
-                <label for="enter-to-send-{{uniqid}}">
-                    {{#str}} useentertosend, core_message {{/str}}
-                </label>
-            </span>
-        </div>
+    <div class="hidden" data-region="content-container">
+        {{> core_message/message_drawer_view_settings_body_content }}
+    </div>
+    <div data-region="placeholder-container">
+        {{> core_message/message_drawer_view_settings_body_placeholder }}
     </div>
-{{/settings}}
 </div>
\ No newline at end of file
diff --git a/message/templates/message_drawer_view_settings_body_content.mustache b/message/templates/message_drawer_view_settings_body_content.mustache
new file mode 100644 (file)
index 0000000..9865378
--- /dev/null
@@ -0,0 +1,75 @@
+{{!
+    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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_message/message_drawer_view_settings_body_content
+
+    This template will render the content for the body of the settings page in the message drawer.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * All data attributes are required
+
+    Context variables required for this template:
+    * userid The logged in user id
+    * urls The URLs for the popover
+
+    Example context (json):
+    {}
+
+}}
+
+{{#settings}}
+<div data-region="settings" class="p-3">
+    <h3 class="h6 font-weight-bold">{{#str}} privacy, message {{/str}}</h3>
+    <p>{{#str}} privacy_desc, message {{/str}}</p>
+    <div data-preference="blocknoncontacts" class="mb-3">
+        {{#privacy}}
+            <div class="custom-control custom-radio mb-2">
+                <input
+                    type="radio"
+                    name="message_blocknoncontacts"
+                    class="custom-control-input"
+                    id="block-noncontacts-{{uniqid}}-{{value}}"
+                    value="{{value}}"
+                >
+                <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
+                    {{text}}
+                </label>
+            </div>
+        {{/privacy}}
+    </div>
+
+    <div class="hidden" data-region="notification-preference-container">
+        <h3 class="mb-2 mt-4 h6 font-weight-bold">{{#str}} notificationpreferences, core_message {{/str}}</h3>
+    </div>
+
+    <h3 class="mb-2 mt-4 h6 font-weight-bold">{{#str}} general, core {{/str}}</h3>
+    <div data-preference="entertosend">
+        <span class="switch">
+            <input type="checkbox"
+                id="enter-to-send-{{uniqid}}"
+                {{#entertosend}}checked{{/entertosend}}
+            >
+            <label for="enter-to-send-{{uniqid}}">
+                {{#str}} useentertosend, core_message {{/str}}
+            </label>
+        </span>
+    </div>
+</div>
+{{/settings}}
\ No newline at end of file
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core_message/message_drawer_view_contacts_section
+    @template core_message/message_drawer_view_settings_body_content_notification_preferences
 
-    This template will render a generic "section" on the contacts page of
-    the message drawer.
+    This template will render the notification perferences for the message settings..
 
     Classes required for JS:
     * none
 
 }}
 
-<div
-    data-region="{{$region}}{{/region}}"
-    data-user-id="{{loggedinuser.id}}"
->
-    <div
-        id="{{$region}}{{/region}}-target"
-        aria-labelledby="{{$region}}{{/region}}"
-    >
-        <div class="hidden text-center p-2" data-region="empty-message-container">
-            {{$emptymessage}}{{/emptymessage}}
-        </div>
-        <div class="hidden list-group" data-region="contacts-content-container">
-            {{$content}}{{/content}}
-        </div>
-        <div class="list-group" data-region="placeholder-container">
-            {{$placeholder}}{{/placeholder}}
-        </div>
-    </div>
-</div>
\ No newline at end of file
+<div data-preference="notifications" class="d-flex flex-column">
+    {{#processors}}
+        <span class="switch">
+            <input type="checkbox"
+                id="{{name}}-{{uniqid}}"
+                data-name="{{name}}"
+                {{#checked}}checked{{/checked}}
+                {{#locked}}disabled{{/locked}}
+            >
+            <label for="{{name}}-{{uniqid}}">{{displayname}}{{#locked}} ({{lockedmessage}}){{/locked}}</label>
+        </span>
+    {{/processors}}
+</div>
diff --git a/message/templates/message_drawer_view_settings_body_placeholder.mustache b/message/templates/message_drawer_view_settings_body_placeholder.mustache
new file mode 100644 (file)
index 0000000..299e0e6
--- /dev/null
@@ -0,0 +1,72 @@
+{{!
+    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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_message/message_drawer_view_settings_body_placeholder
+
+    This template will render the body of the settings page in the message drawer.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * All data attributes are required
+
+    Context variables required for this template:
+    * userid The logged in user id
+    * urls The URLs for the popover
+
+    Example context (json):
+    {}
+
+}}
+
+<div class="d-flex flex-column p-3">
+    <div class="w-25 bg-pulse-grey h6" style="height: 18px"></div>
+    <div class="w-75 bg-pulse-grey mb-4" style="height: 18px"></div>
+    <div class="mb-3">
+        <div class="w-100 d-flex mb-3">
+            <div class="bg-pulse-grey rounded-circle" style="width: 18px; height: 18px"></div>
+            <div class="bg-pulse-grey w-50 ml-2" style="height: 18px"></div>
+        </div>
+        <div class="w-100 d-flex mb-3">
+            <div class="bg-pulse-grey rounded-circle" style="width: 18px; height: 18px"></div>
+            <div class="bg-pulse-grey w-50 ml-2" style="height: 18px"></div>
+        </div>
+        <div class="w-100 d-flex mb-3">
+            <div class="bg-pulse-grey rounded-circle" style="width: 18px; height: 18px"></div>
+            <div class="bg-pulse-grey w-50 ml-2" style="height: 18px"></div>
+        </div>
+    </div>
+    <div class="w-50 bg-pulse-grey h6 mb-3 mt-2" style="height: 18px"></div>
+    <div class="mb-4">
+        <div class="w-100 d-flex mb-2 align-items-center">
+            <div class="bg-pulse-grey w-25" style="width: 18px; height: 27px"></div>
+            <div class="bg-pulse-grey w-25 ml-2" style="height: 18px"></div>
+        </div>
+        <div class="w-100 d-flex mb-2 align-items-center">
+            <div class="bg-pulse-grey w-25" style="width: 18px; height: 27px"></div>
+            <div class="bg-pulse-grey w-25 ml-2" style="height: 18px"></div>
+        </div>
+    </div>
+    <div class="w-25 bg-pulse-grey h6 mb-3 mt-2" style="height: 18px"></div>
+    <div class="mb-3">
+        <div class="w-100 d-flex mb-2 align-items-center">
+            <div class="bg-pulse-grey w-25" style="width: 18px; height: 27px"></div>
+            <div class="bg-pulse-grey w-50 ml-2" style="height: 18px"></div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
index d4ba0a5..179f9ed 100644 (file)
@@ -167,6 +167,11 @@ class restore_assign_activity_structure_step extends restore_activity_structure_
         }
         if (!empty($data->groupid)) {
             $data->groupid = $this->get_mappingid('group', $data->groupid);
+            if (!$data->groupid) {
+                // If the group does not exist, then the submission cannot be viewed and restoring can
+                // violate the unique index on the submission table.
+                return;
+            }
         } else {
             $data->groupid = 0;
         }
index e6453e6..aa014a0 100644 (file)
@@ -107,18 +107,18 @@ class provider implements
         $gradeid = $exportdata->get_pluginobject()->id;
         $comments = $plugin->get_feedback_comments($gradeid);
         if ($comments && !empty($comments->commenttext)) {
+            $currentpath = array_merge(
+                $exportdata->get_subcontext(),
+                [get_string('privacy:commentpath', 'assignfeedback_comments')]
+            );
+
             $comments->commenttext = writer::with_context($assign->get_context())->rewrite_pluginfile_urls(
-                [],
+                $currentpath,
                 ASSIGNFEEDBACK_COMMENTS_COMPONENT,
                 ASSIGNFEEDBACK_COMMENTS_FILEAREA,
                 $gradeid,
                 $comments->commenttext
             );
-
-            $currentpath = array_merge(
-                $exportdata->get_subcontext(),
-                [get_string('privacy:commentpath', 'assignfeedback_comments')]
-            );
             $data = (object)
             [
                 'commenttext' => format_text($comments->commenttext, $comments->commentformat,
index 1812bf7..aa1937f 100644 (file)
@@ -14,8 +14,8 @@
 // For the white version of the icons, just add the .icon-white class:
 // <i class="icon-inbox icon-white"></i>
 
-[class^="icon-"],
-[class*=" icon-"] {
+[class^="icon-"]:not([class^="icon-size-"]),
+[class*=" icon-"]:not([class*=" icon-size-"]) {
   display: inline-block;
   width: 14px;
   height: 14px;
index 1e500b7..fb52a7e 100644 (file)
         font-weight: normal;
     }
 
-    .overview-section-toggle {
+    .accordion-group {
         .collapsed-icon-container {
-            display: none;
+            display: inline-block;
         }
         .expanded-icon-container {
-            display: inline-block;
+            display: none;
         }
 
-        &.collapsed {
+        &.expanded {
             .collapsed-icon-container {
-                display: inline-block;
+                display: none;
             }
             .expanded-icon-container {
-                display: none;
+                display: inline-block;
             }
         }
     }
             }
         }
     }
+    .onepix {
+        height: 1px;
+        width: 1px;
+        position: absolute;
+    }
 
     .view-conversation {
         .content-message-container {
index 0c28edb..238ea14 100644 (file)
@@ -12,6 +12,7 @@ To update to the latest release of twitter bootstrap:
 * download the new less files and store them in less/bootstrap
 * Apply change in MDL-42195 (We don't want responsive images by default).
 * Apply change in MDL-48328 (We need to reset the width of the container directly, in ./less/bootstrap/navbar.less, using the calculated value found in ./less/bootstrap/mixin.less).
+* Apply change in MDL-64091 (We don't want icon-size-n classes to have the icon background image applied).
 * regenerate css files using grunt
 * update ./thirdpartylibs.xml
 
index 4fbae66..83a4ea0 100644 (file)
@@ -774,8 +774,8 @@ table th[class*="span"],
 .table-hover tbody tr.info:hover > td {
   background-color: #c4e3f3;
 }
-[class^="icon-"],
-[class*=" icon-"] {
+[class^="icon-"]:not([class^="icon-size-"]),
+[class*=" icon-"]:not([class*=" icon-size-"]) {
   display: inline-block;
   width: 14px;
   height: 14px;
index c2724a4..9aaf92b 100644 (file)
@@ -9190,18 +9190,18 @@ a.ygtvspacer:hover {
   font-size: 14px;
   font-weight: normal;
 }
-.message-drawer .overview-section-toggle .collapsed-icon-container {
-  display: none;
-}
-.message-drawer .overview-section-toggle .expanded-icon-container {
+.message-drawer .accordion-group .collapsed-icon-container {
   display: inline-block;
 }
-.message-drawer .overview-section-toggle.collapsed .collapsed-icon-container {
-  display: inline-block;
+.message-drawer .accordion-group .expanded-icon-container {
+  display: none;
 }
-.message-drawer .overview-section-toggle.collapsed .expanded-icon-container {
+.message-drawer .accordion-group.expanded .collapsed-icon-container {
   display: none;
 }
+.message-drawer .accordion-group.expanded .expanded-icon-container {
+  display: inline-block;
+}
 .message-drawer .view-overview-body .section {
   display: block;
 }
@@ -9209,6 +9209,11 @@ a.ygtvspacer:hover {
   display: flex;
   flex-direction: column;
 }
+.message-drawer .onepix {
+  height: 1px;
+  width: 1px;
+  position: absolute;
+}
 .message-drawer .view-conversation .content-message-container img {
   max-width: 100%;
 }
@@ -12552,8 +12557,8 @@ table th[class*="span"],
 .table-hover tbody tr.info:hover > td {
   background-color: #c4e3f3;
 }
-[class^="icon-"],
-[class*=" icon-"] {
+[class^="icon-"]:not([class^="icon-size-"]),
+[class*=" icon-"]:not([class*=" icon-size-"]) {
   display: inline-block;
   width: 14px;
   height: 14px;
diff --git a/theme/bootstrapbase/templates/core_message/message_drawer_lazy_load_list.mustache b/theme/bootstrapbase/templates/core_message/message_drawer_lazy_load_list.mustache
new file mode 100644 (file)
index 0000000..46b3b3e
--- /dev/null
@@ -0,0 +1,63 @@
+{{!
+    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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_message/message_drawer_lazy_load_list
+
+    This template will render a lazy loaded list for the message drawer.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * All data attributes are required
+
+    Context variables required for this template:
+    * userid The logged in user id
+    * urls The URLs for the popover
+
+    This is an overridden template
+    adding "div.onpix" to ensure content has a 1 px height for Bootstrap 2 JS to work.
+
+    Example context (json):
+    {}
+
+}}
+<div
+    class="{{$rootclasses}}{{/rootclasses}}"
+    style="overflow-y: auto"
+    aria-live="polite"
+    data-region="lazy-load-list"
+    data-user-id="{{loggedinuser.id}}"
+    {{$rootattributes}}{{/rootattributes}}
+>
+    <div class="onepix">
+    </div>
+    <div class="hidden text-center p-2" data-region="empty-message-container">
+        <p class="text-muted mt-2">
+            {{$emptymessage}}{{/emptymessage}}
+        </p>
+    </div>
+    <div class="hidden list-group" data-region="content-container">
+        {{$content}}{{/content}}
+    </div>
+    <div class="list-group" data-region="placeholder-container">
+        {{$placeholder}}{{/placeholder}}
+    </div>
+    <div class="w-100 text-center p-3 hidden" data-region="loading-icon-container" >
+        {{> core/loading }}
+    </div>
+</div>
index 015a7a4..ae6f25b 100644 (file)
                     {{#str}} info, core_message {{/str}}
                 </a>
             </li>
-            <li class="{{#isfavourite}}hidden{{/isfavourite}}" data-action="confirm-favourite">
+            <li class="{{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" data-action="confirm-favourite">
                 <a class="dropdown-item" href="#" >
                     {{#str}} addtofavourites, core_message {{/str}}
                 </a>
             </li>
-            <li class="{{^isfavourite}}hidden{{/isfavourite}}" data-action="confirm-unfavourite">
+            <li class="{{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" data-action="confirm-unfavourite">
                 <a class="dropdown-item " href="#">
                     {{#str}} removefromfavourites, core_message {{/str}}
                 </a>
index 8c20346..42071fc 100644 (file)
                     {{#str}} groupinfo, core_message {{/str}}
                 </a>
             </li>
-            <li class="{{#isfavourite}}hidden{{/isfavourite}}" data-action="confirm-favourite">
+            <li class="{{#isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" data-action="confirm-favourite">
                 <a class="dropdown-item" href="#">
                     {{#str}} addtofavourites, core_message {{/str}}
                 </a>
             </li>
-            <li class="{{^isfavourite}}hidden{{/isfavourite}}" data-action="confirm-unfavourite">
+            <li class="{{^isfavourite}}hidden{{/isfavourite}} {{^showfavourite}}hidden{{/showfavourite}}" data-action="confirm-unfavourite">
                 <a class="dropdown-item" href="#">
                     {{#str}} removefromfavourites, core_message {{/str}}
                 </a>
index 060d287..b4798a1 100644 (file)
     This is an overridden template
     changing icon "t/collapsedcaret" to "t/collapsed"
     adding ".in" to ".show"
+    adding .accordion-group to the main container div
+    adding expanded to the .accordion-group if required for toggle icons to work.
 
     Example context (json):
     {}
 
 }}
 <div
-    class="accordion-group section border-0"
+    class="accordion-group section border-0 {{#expanded}}expanded{{/expanded}}"
     data-region="{{$region}}{{/region}}"
 >
-    <div id="{{$region}}{{/region}}-toggle" class="card-header p-0" data-region="toggle">
+    <div id="{{$region}}{{/region}}-toggle" class="accordion-heading" data-region="toggle">
         <button
             class="btn btn-link w-100 text-left p-2 m-0 d-flex align-items-center overview-section-toggle {{^expanded}}collapsed{{/expanded}}"
             data-toggle="collapse"
+            data-parent="#message-drawer-view-overview-container"
             data-target="#{{$region}}{{/region}}-target"
             aria-expanded="{{#expanded}}true{{/expanded}}{{^expanded}}false{{/expanded}}"
             aria-controls="{{$region}}{{/region}}-target"
@@ -66,7 +69,7 @@
         </button>
     </div>
     {{< core_message/message_drawer_lazy_load_list }}
-        {{$rootclasses}}collapse border-bottom {{#expanded}}show in{{/expanded}}{{/rootclasses}}
+        {{$rootclasses}} accordion-body collapse border-bottom {{#expanded}}show in{{/expanded}}{{/rootclasses}}
         {{$rootattributes}}
             id="{{$region}}{{/region}}-target"
             aria-labelledby="{{$region}}{{/region}}-toggle"