MDL-55982 mod_forum: Add time-based discussion locking
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 13 Sep 2016 00:16:11 +0000 (08:16 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 21 Sep 2016 01:03:42 +0000 (09:03 +0800)
This patch adds support for time-based locking of discussions.
Discussions are automatically locked after a user-definable period of
inactivity. After this time, only those with the the relevant capability
are able to add replies.

This has been designed to add support for other types of discussion locking
at a later date with relative ease.

12 files changed:
mod/forum/backup/moodle2/backup_forum_stepslib.php
mod/forum/db/access.php
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/discuss.php
mod/forum/externallib.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/mod_form.php
mod/forum/tests/externallib_test.php
mod/forum/tests/lib_test.php
mod/forum/version.php

index ed374a6..c01e33a 100644 (file)
@@ -44,7 +44,7 @@ class backup_forum_activity_structure_step extends backup_activity_structure_ste
             'maxbytes', 'maxattachments', 'forcesubscribe', 'trackingtype',
             'rsstype', 'rssarticles', 'timemodified', 'warnafter',
             'blockafter', 'blockperiod', 'completiondiscussions', 'completionreplies',
-            'completionposts', 'displaywordcount'));
+            'completionposts', 'displaywordcount', 'lockdiscussionafter'));
 
         $discussions = new backup_nested_element('discussions');
 
index e3eae61..1dce7d4 100644 (file)
@@ -366,5 +366,14 @@ $capabilities = array(
             'manager' => CAP_ALLOW
         )
     ),
+    'mod/forum:canoverridediscussionlock' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => array(
+            'teacher' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        )
+    ),
 );
 
index 4e44b1b..e1c3309 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20160113" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20160912" COMMENT="XMLDB file for Moodle mod/forum"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -30,6 +30,7 @@
         <FIELD NAME="completionreplies" TYPE="int" LENGTH="9" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Nonzero if a certain number of replies are required to mark this forum complete for a user."/>
         <FIELD NAME="completionposts" TYPE="int" LENGTH="9" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Nonzero if a certain number of posts or replies (total) are required to mark this forum complete for a user."/>
         <FIELD NAME="displaywordcount" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="lockdiscussionafter" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index 942b760..eb131b7 100644 (file)
@@ -177,5 +177,20 @@ function xmldb_forum_upgrade($oldversion) {
     // Moodle v3.1.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2016091200) {
+
+        // Define field lockdiscussionafter to be added to forum.
+        $table = new xmldb_table('forum');
+        $field = new xmldb_field('lockdiscussionafter', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0', 'displaywordcount');
+
+        // Conditionally launch add field lockdiscussionafter.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Forum savepoint reached.
+        upgrade_mod_savepoint(true, 2016091200, 'forum');
+    }
+
     return true;
 }
index 29bfbb3..2bb4a41 100644 (file)
@@ -372,6 +372,10 @@ if (has_capability('mod/forum:pindiscussions', $modcontext)) {
 
 echo "</div></div>";
 
+if (forum_discussion_is_locked($forum, $discussion)) {
+    echo html_writer::div(get_string('discussionlocked', 'forum'), 'discussionlocked');
+}
+
 if (!empty($forum->blockafter) && !empty($forum->blockperiod)) {
     $a = new stdClass();
     $a->blockafter  = $forum->blockafter;
index c0db8fe..005e7fb 100644 (file)
@@ -112,7 +112,7 @@ class mod_forum_external extends external_api {
      * @return external_single_structure
      * @since Moodle 2.5
      */
-     public static function get_forums_by_courses_returns() {
+    public static function get_forums_by_courses_returns() {
         return new external_multiple_structure(
             new external_single_structure(
                 array(
@@ -143,6 +143,7 @@ class mod_forum_external extends external_api {
                     'cmid' => new external_value(PARAM_INT, 'Course module id'),
                     'numdiscussions' => new external_value(PARAM_INT, 'Number of discussions in the forum', VALUE_OPTIONAL),
                     'cancreatediscussions' => new external_value(PARAM_BOOL, 'If the user can create discussions', VALUE_OPTIONAL),
+                    'lockdiscussionafter' => new external_value(PARAM_INT, 'After what period a discussion is locked', VALUE_OPTIONAL),
                 ), 'forum'
             )
         );
@@ -499,6 +500,9 @@ class mod_forum_external extends external_api {
                                                                                 $discussion->id);
                 }
 
+                $discussion->locked = forum_discussion_is_locked($forum, $discussion);
+                $discussion->canreply = forum_user_can_post($forum, $discussion, $USER, $cm, $course, $modcontext);
+
                 $discussions[] = $discussion;
             }
         }
@@ -549,7 +553,9 @@ class mod_forum_external extends external_api {
                                 'usermodifiedpictureurl' => new external_value(PARAM_URL, 'Post modifier picture.'),
                                 'numreplies' => new external_value(PARAM_TEXT, 'The number of replies in the discussion'),
                                 'numunread' => new external_value(PARAM_INT, 'The number of unread discussions.'),
-                                'pinned' => new external_value(PARAM_BOOL, 'Is the discussion pinned')
+                                'pinned' => new external_value(PARAM_BOOL, 'Is the discussion pinned'),
+                                'locked' => new external_value(PARAM_BOOL, 'Is the discussion locked'),
+                                'canreply' => new external_value(PARAM_BOOL, 'Can the user reply to the discussion'),
                             ), 'post'
                         )
                     ),
index e5c7459..dea33f2 100644 (file)
@@ -133,6 +133,9 @@ $string['disallowsubscription'] = 'Subscription';
 $string['disallowsubscription_help'] = 'This forum has been configured so that you cannot subscribe to discussions.';
 $string['disallowsubscribeteacher'] = 'Subscriptions not allowed (except for teachers)';
 $string['discussion'] = 'Discussion';
+$string['discussionlocked'] = 'This discussion has been locked so you can no longer reply to it.';
+$string['discussionlockingheader'] = 'Discussion locking';
+$string['discussionlockingdisabled'] = 'Do not lock discussions';
 $string['discussionmoved'] = 'This discussion has been moved to \'{$a}\'.';
 $string['discussionmovedpost'] = 'This discussion has been moved to <a href="{$a->discusshref}">here</a> in the forum <a href="{$a->forumhref}">{$a->forumname}</a>';
 $string['discussionname'] = 'Discussion name';
@@ -216,6 +219,7 @@ $string['forum:addinstance'] = 'Add a new forum';
 $string['forum:addnews'] = 'Add news';
 $string['forum:addquestion'] = 'Add question';
 $string['forum:allowforcesubscribe'] = 'Allow force subscribe';
+$string['forum:canoverridediscussionlock'] = 'Reply to locked discussions';
 $string['forumauthorhidden'] = 'Author (hidden)';
 $string['forumblockingalmosttoomanyposts'] = 'You are approaching the posting threshold. You have posted {$a->numposts} times in the last {$a->blockperiod} and the limit is {$a->blockafter} posts.';
 $string['forumbodyhidden'] = 'This post cannot be viewed by you, probably because you have not posted in the discussion, the maximum editing time hasn\'t passed yet, the discussion has not started or the discussion has expired.';
@@ -275,6 +279,10 @@ $string['invalidparentpostid'] = 'Parent post ID was incorrect';
 $string['invalidpostid'] = 'Invalid post ID - {$a}';
 $string['lastpost'] = 'Last post';
 $string['learningforums'] = 'Learning forums';
+$string['lockdiscussionafter'] = 'Lock discussions after period of inactivity';
+$string['lockdiscussionafter_help'] = 'Discussions may be automatically locked after a specified time has elapsed since the last reply.
+
+Users with the capability to reply to locked discussions can unlock a discussion by replying to it.';
 $string['longpost'] = 'Long post';
 $string['mailnow'] = 'Send forum post notifications with no editing-time delay';
 $string['manydiscussions'] = 'Discussions per page';
index 5b08c41..265a002 100644 (file)
@@ -5043,6 +5043,13 @@ function forum_user_can_post($forum, $discussion, $user=NULL, $cm=NULL, $course=
         $context = context_module::instance($cm->id);
     }
 
+    // Check whether the discussion is locked.
+    if (forum_discussion_is_locked($forum, $discussion)) {
+        if (!has_capability('mod/forum:canoverridediscussionlock', $context)) {
+            return false;
+        }
+    }
+
     // normal users with temporary guest access can not post, suspended users can not post either
     if (!is_viewing($context, $user->id) and !is_enrolled($context, $user->id, '', true)) {
         return false;
@@ -8013,3 +8020,27 @@ function mod_forum_inplace_editable($itemtype, $itemid, $newvalue) {
         return $renderer->render_digest_options($forum, $newvalue);
     }
 }
+
+/**
+ * Determine whether the specified discussion is time-locked.
+ *
+ * @param   stdClass    $forum          The forum that the discussion belongs to
+ * @param   stdClass    $discussion     The discussion to test
+ * @return  bool
+ */
+function forum_discussion_is_locked($forum, $discussion) {
+    if (empty($forum->lockdiscussionafter)) {
+        return false;
+    }
+
+    if ($forum->type === 'single') {
+        // It does not make sense to lock a single discussion forum.
+        return false;
+    }
+
+    if (($discussion->timemodified + $forum->lockdiscussionafter) < time()) {
+        return true;
+    }
+
+    return false;
+}
index 9f9cc0a..34465e3 100644 (file)
@@ -147,6 +147,22 @@ class mod_forum_mod_form extends moodleform_mod {
             }
         }
 
+        $mform->addElement('header', 'discussionlocking', get_string('discussionlockingheader', 'forum'));
+        $options = [
+            0               => get_string('discussionlockingdisabled', 'forum'),
+            1   * DAYSECS   => get_string('numday', 'core', 1),
+            1   * WEEKSECS  => get_string('numweek', 'core', 1),
+            2   * WEEKSECS  => get_string('numweeks', 'core', 2),
+            30  * DAYSECS   => get_string('nummonth', 'core', 1),
+            60  * DAYSECS   => get_string('nummonths', 'core', 2),
+            90  * DAYSECS   => get_string('nummonths', 'core', 3),
+            180 * DAYSECS   => get_string('nummonths', 'core', 6),
+            1   * YEARSECS  => get_string('numyear', 'core', 1),
+        ];
+        $mform->addElement('select', 'lockdiscussionafter', get_string('lockdiscussionafter', 'forum'), $options);
+        $mform->addHelpButton('lockdiscussionafter', 'lockdiscussionafter', 'forum');
+        $mform->disabledIf('lockdiscussionafter', 'type', 'eq', 'single');
+
 //-------------------------------------------------------------------------------
         $mform->addElement('header', 'blockafterheader', get_string('blockafter', 'forum'));
         $options = array();
index f9f08ca..9f7612a 100644 (file)
@@ -491,7 +491,9 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
                 'usermodifiedpictureurl' => '',
                 'numreplies' => 3,
                 'numunread' => 0,
-                'pinned' => FORUM_DISCUSSION_UNPINNED
+                'pinned' => FORUM_DISCUSSION_UNPINNED,
+                'locked' => false,
+                'canreply' => false,
             );
 
         // Call the external function passing forum id.
index 3453534..ef135e5 100644 (file)
@@ -3203,4 +3203,61 @@ class mod_forum_lib_testcase extends advanced_testcase {
             ],
         ];
     }
+
+    /**
+     * Test the forum_discussion_is_locked function.
+     *
+     * @dataProvider forum_discussion_is_locked_provider
+     * @param   stdClass    $forum
+     * @param   stdClass    $discussion
+     * @param   bool        $expect
+     */
+    public function test_forum_discussion_is_locked($forum, $discussion, $expect) {
+        $this->assertEquals($expect, forum_discussion_is_locked($forum, $discussion));
+    }
+
+    /**
+     * Dataprovider for forum_discussion_is_locked tests.
+     *
+     * @return  array
+     */
+    public function forum_discussion_is_locked_provider() {
+        return [
+            'Unlocked: lockdiscussionafter is unset' => [
+                (object) [],
+                (object) [],
+                false
+            ],
+            'Unlocked: lockdiscussionafter is false' => [
+                (object) ['lockdiscussionafter' => false],
+                (object) [],
+                false
+            ],
+            'Unlocked: lockdiscussionafter is null' => [
+                (object) ['lockdiscussionafter' => null],
+                (object) [],
+                false
+            ],
+            'Unlocked: lockdiscussionafter is set; forum is of type single; post is recent' => [
+                (object) ['lockdiscussionafter' => DAYSECS, 'type' => 'single'],
+                (object) ['timemodified' => time()],
+                false
+            ],
+            'Unlocked: lockdiscussionafter is set; forum is of type single; post is old' => [
+                (object) ['lockdiscussionafter' => MINSECS, 'type' => 'single'],
+                (object) ['timemodified' => time() - DAYSECS],
+                false
+            ],
+            'Unlocked: lockdiscussionafter is set; forum is of type eachuser; post is recent' => [
+                (object) ['lockdiscussionafter' => DAYSECS, 'type' => 'eachuser'],
+                (object) ['timemodified' => time()],
+                false
+            ],
+            'Locked: lockdiscussionafter is set; forum is of type eachuser; post is old' => [
+                (object) ['lockdiscussionafter' => MINSECS, 'type' => 'eachuser'],
+                (object) ['timemodified' => time() - DAYSECS],
+                true
+            ],
+        ];
+    }
 }
index 8c71908..09f78b3 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2016052300;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2016091201;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2016051900;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)