From df6803acdcd689bf856c63b2b5c09a8f5dd51a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Fri, 29 Oct 2021 16:07:56 +0200 Subject: [PATCH] Pdfannotator comment subscription like forum fixes #20. The subscription setting will distinguish between optional subscription, auto subscription, forced subscription and subscription disabled for comments, auto subscription being the default as it has been up to now. If disabled or forced, no subscribe/unsubscribe menu entry is shown. Contrary to forum if you change this in hindsight, for example it will not subscribe or unsubscribe all person who have subscribed/unsubscribed to a comment. Also, Behat tests are introduced hereby. --- .../moodle2/backup_pdfannotator_stepslib.php | 2 +- classes/output/comment.php | 130 ++- classes/output/index.php | 3 + classes/subscriptions.php | 884 ++++++++++++++++++ db/install.xml | 1 + db/upgrade.php | 15 + lang/en/pdfannotator.php | 13 + lib.php | 20 + locallib.php | 13 + mod_form.php | 27 +- model/comment.class.php | 9 +- settings.php | 9 +- templates/comment.mustache | 120 ++- templates/index.mustache | 32 +- tests/behat/add_pdfannotator.feature | 31 + tests/behat/annotate_pdfannotator.feature | 114 +++ tests/behat/behat_pdfannotator_editpdf.php | 59 ++ tests/fixtures/submission.pdf | Bin 0 -> 24751 bytes version.php | 2 +- 19 files changed, 1409 insertions(+), 75 deletions(-) create mode 100644 classes/subscriptions.php create mode 100644 tests/behat/add_pdfannotator.feature create mode 100644 tests/behat/annotate_pdfannotator.feature create mode 100644 tests/behat/behat_pdfannotator_editpdf.php create mode 100644 tests/fixtures/submission.pdf diff --git a/backup/moodle2/backup_pdfannotator_stepslib.php b/backup/moodle2/backup_pdfannotator_stepslib.php index 5160537..a76c93a 100644 --- a/backup/moodle2/backup_pdfannotator_stepslib.php +++ b/backup/moodle2/backup_pdfannotator_stepslib.php @@ -55,7 +55,7 @@ protected function define_structure() { // 2. Define each element separately. $pdfannotator = new backup_nested_element('pdfannotator', array('id'), array( 'name', 'intro', 'introformat', 'usevotes', 'useprint', 'useprintcomments', 'use_studenttextbox', 'use_studentdrawing', - 'useprivatecomments', 'useprotectedcomments', 'timecreated', 'timemodified')); + 'useprivatecomments', 'useprotectedcomments', 'forcesubscribe', 'timecreated', 'timemodified')); $annotations = new backup_nested_element('annotations'); $annotation = new backup_nested_element('annotation', array('id'), array('page', 'userid', 'annotationtypeid', diff --git a/classes/output/comment.php b/classes/output/comment.php index b212b83..1551c10 100644 --- a/classes/output/comment.php +++ b/classes/output/comment.php @@ -29,16 +29,22 @@ */ class comment implements \renderable, \templatable { + /** + * @var array An array of comments + */ private $comments = []; + + /** + * @var bool Visibility of a question + */ private $questionvisibility; /** * Constructor of renderable for comments. * - * @param object $data Comment or array of comments - * @param object $cm Course module + * @param stdClass $data Comment or array of comments + * @param stdClass $cm course module object * @param object $context Context - * @return type */ public function __construct($data, $cm, $context) { global $USER; @@ -46,7 +52,6 @@ public function __construct($data, $cm, $context) { if (!is_array($data)) { $data = [$data]; } - $report = has_capability('mod/pdfannotator:report', $context); $closequestion = has_capability('mod/pdfannotator:closequestion', $context); $closeanyquestion = has_capability('mod/pdfannotator:closeanyquestion', $context); @@ -83,7 +88,7 @@ public function __construct($data, $cm, $context) { $this->addeditbutton($comment, $editanypost); $this->addhidebutton($comment, $seehiddencomments, $hidecomments); $this->adddeletebutton($comment, $deleteown, $deleteany); - $this->addsubscribebutton($comment, $subscribe); + $this->addsubscribebutton($comment, $subscribe, $cm); $this->addforwardbutton($comment, $forwardquestions, $cm); $this->addmarksolvedbutton($comment, $solve); @@ -94,8 +99,8 @@ public function __construct($data, $cm, $context) { } if (!empty($comment->modifiedby) && ($comment->modifiedby != $comment->userid) && ($comment->userid != 0)) { - $comment->modifiedby = get_string('modifiedby', 'pdfannotator') . ' '. - pdfannotator_get_username($comment->modifiedby); + $comment->modifiedby = get_string('modifiedby', 'pdfannotator') . ' ' . + pdfannotator_get_username($comment->modifiedby); } else { $comment->modifiedby = null; } @@ -112,8 +117,9 @@ public function __construct($data, $cm, $context) { /** * This function is required by any renderer to retrieve the data structure * passed into the template. + * * @param \renderer_base $output - * @return type + * @return stdClass */ public function export_for_template(\renderer_base $output) { $data = []; @@ -121,6 +127,12 @@ public function export_for_template(\renderer_base $output) { return $data; } + /** + * Add css class to a comment + * + * @param object $comment + * @param bool $owner + */ private function addcssclasses($comment, $owner) { $comment->wrapperClass = 'chat-message comment-list-item'; if ($comment->isquestion) { @@ -136,6 +148,12 @@ private function addcssclasses($comment, $owner) { } } + /** + * Set votes to a comment + * + * @param object $comment + * @throws \coding_exception + */ public function setvotes($comment) { if ($comment->usevotes && !$comment->isdeleted) { if ($comment->owner) { @@ -163,7 +181,8 @@ public function setvotes($comment) { /** * Add check icon if comment is marked as correct. - * @param type $comment + * + * @param object $comment */ public function addsolvedicon($comment) { if ($comment->solved) { @@ -179,9 +198,10 @@ public function addsolvedicon($comment) { /** * Report comment if user is not the owner. - * @param type $comment - * @param type $owner - * @param type $report + * + * @param object $comment + * @param bool $report + * @param stdClass $cm course module object */ private function addreportbutton($comment, $report, $cm) { if (!$comment->isdeleted && $report && !$comment->owner && !isset($comment->type)) { @@ -193,10 +213,11 @@ private function addreportbutton($comment, $report, $cm) { /** * Open/close question if user is owner of the question or manager. - * @param type $comment - * @param type $owner - * @param type $closequestion - * @param type $closeanyquestion + * + * @param object $comment + * @param bool $closequestion + * @param bool $closeanyquestion + * @throws \coding_exception */ private function addcloseopenbutton($comment, $closequestion, $closeanyquestion) { @@ -215,9 +236,10 @@ private function addcloseopenbutton($comment, $closequestion, $closeanyquestion) /** * Button for editing comment if user is owner of the comment or manager. - * @param type $comment - * @param type $owner - * @param type $editanypost + * + * @param object $comment + * @param bool $editanypost + * @throws \coding_exception */ private function addeditbutton($comment, $editanypost) { if (!$comment->isdeleted && !isset($comment->type) && ($comment->owner || $editanypost)) { @@ -228,6 +250,14 @@ private function addeditbutton($comment, $editanypost) { } } + /** + * Add a hide button + * + * @param object $comment + * @param bool $seehiddencomments + * @param bool $hidecomments + * @throws \coding_exception + */ private function addhidebutton($comment, $seehiddencomments, $hidecomments) { // Don't need to hide personal notes. if ($this->questionvisibility == 'private') { @@ -257,32 +287,53 @@ private function addhidebutton($comment, $seehiddencomments, $hidecomments) { /** * Delete comment if user is owner of the comment or manager. - * @param type $comment - * @param type $owner - * @param type $deleteown - * @param type $deleteany + * + * @param object $comment + * @param bool $deleteown + * @param bool $deleteany + * @throws \coding_exception */ private function adddeletebutton($comment, $deleteown, $deleteany) { if (!$comment->isdeleted && ($deleteany || ($deleteown && $comment->owner))) { $comment->buttons[] = ["classes" => "comment-delete-a", "text" => get_string('delete', 'pdfannotator'), "moodleicon" => ["key" => "delete", "component" => "pdfannotator", - "title" => get_string('delete', 'pdfannotator')]]; + "title" => get_string('delete', 'pdfannotator')]]; } } - private function addsubscribebutton($comment, $subscribe) { + /** + * Add a subscribe button + * + * @param object $comment + * @param bool $subscribe + * @param stdClass $cm course module object + * @throws \coding_exception + */ + private function addsubscribebutton($comment, $subscribe, $cm) { if (!isset($comment->type) && $comment->isquestion && $subscribe && $comment->visibility != 'private') { - // Only set for textbox and drawing. - if (!empty($comment->issubscribed)) { - $comment->buttons[] = ["classes" => "comment-subscribe-a", "faicon" => ["class" => "fa-bell-slash"], - "text" => get_string('unsubscribeQuestion', 'pdfannotator')]; - } else { - $comment->buttons[] = ["classes" => "comment-subscribe-a", "faicon" => ["class" => "fa-bell"], - "text" => get_string('subscribeQuestion', 'pdfannotator')]; + // Only set for textbox and drawing, and only if subscription mode is not disabled or forced. + if ((pdfannotator_get_subscriptionmode($cm->instance) == PDFANNOTATOR_CHOOSESUBSCRIBE) || + (pdfannotator_get_subscriptionmode($cm->instance) == PDFANNOTATOR_INITIALSUBSCRIBE)) { + if (!empty($comment->issubscribed)) { + $comment->buttons[] = ["classes" => "comment-subscribe-a", "faicon" => ["class" => "fa-bell-slash"], + "text" => get_string('unsubscribeQuestion', 'pdfannotator')]; + } else { + $comment->buttons[] = ["classes" => "comment-subscribe-a", "faicon" => ["class" => "fa-bell"], + "text" => get_string('subscribeQuestion', 'pdfannotator')]; + } } } } + /** + * Add a forward button + * + * @param object $comment + * @param bool $forwardquestions + * @param stdClass $cm course module object + * @throws \coding_exception + * @throws \moodle_exception + */ private function addforwardbutton($comment, $forwardquestions, $cm) { if (!isset($comment->type) && $comment->isquestion && !$comment->isdeleted && $forwardquestions && $comment->visibility != 'private') { @@ -291,22 +342,29 @@ private function addforwardbutton($comment, $forwardquestions, $cm) { $url = new moodle_url($CFG->wwwroot . '/mod/pdfannotator/view.php', $urlparams); $comment->buttons[] = ["classes" => "comment-forward-a", "attributes" => ["name" => "onclick", - "value" => "window.location.href = '$url';"], "faicon" => ["class" => "fa-share"], - "text" => get_string('forward', 'pdfannotator')]; + "value" => "window.location.href = '$url';"], + "faicon" => ["class" => "fa-share"], "text" => get_string('forward', 'pdfannotator')]; } } + /** + * Add a Mark as correct or a Remove mark as correct button + * + * @param object $comment + * @param bool $solve + * @throws \coding_exception + */ private function addmarksolvedbutton($comment, $solve) { if ($solve && !$comment->isquestion && !$comment->isdeleted && !isset($comment->type) && $this->questionvisibility != 'private') { if ($comment->solved) { $comment->buttons[] = ["classes" => "comment-solve-a", "text" => get_string('removeCorrect', 'pdfannotator'), "moodleicon" => ["key" => "i/completion-manual-n", "component" => "core", - "title" => get_string('removeCorrect', 'pdfannotator')]]; + "title" => get_string('removeCorrect', 'pdfannotator')]]; } else { $comment->buttons[] = ["classes" => "comment-solve-a", "text" => get_string('markCorrect', 'pdfannotator'), "moodleicon" => ["key" => "i/completion-manual-enabled", "component" => "core", - "title" => get_string('markCorrect', 'pdfannotator')]]; + "title" => get_string('markCorrect', 'pdfannotator')]]; } } } diff --git a/classes/output/index.php b/classes/output/index.php index 846bc3e..c37c480 100644 --- a/classes/output/index.php +++ b/classes/output/index.php @@ -44,6 +44,7 @@ class index implements \renderable, \templatable { // Class should be placed els private $printurl; private $useprivatecomments; private $useprotectedcomments; + private $forcesubscribe; public function __construct($pdfannotator, $capabilities, $file) { @@ -55,6 +56,7 @@ public function __construct($pdfannotator, $capabilities, $file) { $this->useprintcomments = ($pdfannotator->useprintcomments || $capabilities->useprintcomments); $this->useprivatecomments = $pdfannotator->useprivatecomments; $this->useprotectedcomments = $pdfannotator->useprotectedcomments; + $this->forcesubscribe = $pdfannotator->forcesubscribe; $contextid = $file->get_contextid(); $component = $file->get_component(); @@ -85,6 +87,7 @@ public function export_for_template(\renderer_base $output) { if ($data->useprivatecomments) { $data->privatehelpicon = $OUTPUT->help_icon('private_comments', 'mod_pdfannotator'); } + $data->forcesubscribe = $this->forcesubscribe; $data->printlink = $this->printurl; $data->pixprintdoc = $OUTPUT->image_url('download', 'mod_pdfannotator'); $data->pixprintcomments = $OUTPUT->image_url('print_comments', 'mod_pdfannotator'); diff --git a/classes/subscriptions.php b/classes/subscriptions.php new file mode 100644 index 0000000..65ef799 --- /dev/null +++ b/classes/subscriptions.php @@ -0,0 +1,884 @@ +. + +/** + * Pdfannotator subscription manager. + * + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace mod_pdfannotator; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Pdfannotator subscription manager. + * + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class subscriptions { + + /** + * The status value for an unsubscribed discussion. + * + * @var int + */ + const PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED = -1; + + /** + * The subscription cache for pdfannotators. + * + * The first level key is the user ID + * The second level is the pdfannotator ID + * The Value then is bool for subscribed of not. + * + * @var array[] An array of arrays. + */ + protected static $pdfannotatorcache = array(); + + /** + * The list of pdfannotators which have been wholly retrieved for the pdfannotator subscription cache. + * + * This allows for prior caching of an entire pdfannotator to reduce the + * number of DB queries in a subscription check loop. + * + * @var bool[] + */ + protected static $fetchedpdfannotators = array(); + + /** + * The subscription cache for pdfannotator discussions. + * + * The first level key is the user ID + * The second level is the pdfannotator ID + * The third level key is the discussion ID + * The value is then the users preference (int) + * + * @var array[] + */ + protected static $pdfannotatordiscussioncache = array(); + + /** + * The list of pdfannotators which have been wholly retrieved for the pdfannotator discussion subscription cache. + * + * This allows for prior caching of an entire pdfannotator to reduce the + * number of DB queries in a subscription check loop. + * + * @var bool[] + */ + protected static $discussionfetchedpdfannotators = array(); + + /** + * Whether a user is subscribed to this pdfannotator, or a discussion within + * the pdfannotator. + * + * If a discussion is specified, then report whether the user is + * subscribed to posts to this particular discussion, taking into + * account the pdfannotator preference. + * + * If it is not specified then only the pdfannotator preference is considered. + * + * @param int $userid The user ID + * @param \stdClass $pdfannotator The record of the pdfannotator to test + * @param int $discussionid The ID of the discussion to check + * @param object $cm The coursemodule record. If not supplied, this will be calculated using get_fast_modinfo instead. + * @return bool + * @throws \coding_exception + * @throws \moodle_exception + */ + public static function is_subscribed($userid, $pdfannotator, $discussionid = null, $cm = null) { + // If pdfannotator is force subscribed and has allowforcesubscribe, then user is subscribed. + if (self::is_forcesubscribed($pdfannotator)) { + if (!$cm) { + $cm = get_fast_modinfo($pdfannotator->course)->instances['pdfannotator'][$pdfannotator->id]; + } + if (has_capability('mod/pdfannotator:allowforcesubscribe', \context_module::instance($cm->id), $userid)) { + return true; + } + } + + if ($discussionid === null) { + return self::is_subscribed_to_pdfannotator($userid, $pdfannotator); + } + + $subscriptions = self::fetch_discussion_subscription($pdfannotator->id, $userid); + + // Check whether there is a record for this discussion subscription. + if (isset($subscriptions[$discussionid])) { + return ($subscriptions[$discussionid] != self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED); + } + + return self::is_subscribed_to_pdfannotator($userid, $pdfannotator); + } + + /** + * Whether a user is subscribed to this pdfannotator. + * + * @param int $userid The user ID + * @param \stdClass $pdfannotator The record of the pdfannotator to test + * @return boolean + */ + protected static function is_subscribed_to_pdfannotator($userid, $pdfannotator) { + return self::fetch_subscription_cache($pdfannotator->id, $userid); + } + + /** + * Helper to determine whether a pdfannotator has it's subscription mode set + * to forced subscription. + * + * @param \stdClass $pdfannotator The record of the pdfannotator to test + * @return bool + */ + public static function is_forcesubscribed($pdfannotator) { + return ($pdfannotator->forcesubscribe == pdfannotator_FORCESUBSCRIBE); + } + + /** + * Helper to determine whether a pdfannotator has it's subscription mode set to disabled. + * + * @param \stdClass $pdfannotator The record of the pdfannotator to test + * @return bool + */ + public static function subscription_disabled($pdfannotator) { + return ($pdfannotator->forcesubscribe == pdfannotator_DISALLOWSUBSCRIBE); + } + + /** + * Helper to determine whether the specified pdfannotator can be subscribed to. + * + * @param \stdClass $pdfannotator The record of the pdfannotator to test + * @return bool + */ + public static function is_subscribable($pdfannotator) { + return (isloggedin() && !isguestuser() && + !self::is_forcesubscribed($pdfannotator) && + !self::subscription_disabled($pdfannotator)); + } + + /** + * Set the pdfannotator subscription mode. + * + * By default when called without options, this is set to PDFANNOTATOR_FORCESUBSCRIBE. + * + * @param \stdClass $pdfannotatorid The id of the pdfannotator to set the state + * @param int $status The new subscription state + * @return bool + * @throws \dml_exception + */ + public static function set_subscription_mode($pdfannotatorid, $status = 1) { + global $DB; + return $DB->set_field("pdfannotator", "forcesubscribe", $status, array("id" => $pdfannotatorid)); + } + + /** + * Returns the current subscription mode for the pdfannotator. + * + * @param \stdClass $pdfannotator The record of the pdfannotator to set + * @return int The pdfannotator subscription mode + */ + public static function get_subscription_mode($pdfannotator) { + return $pdfannotator->forcesubscribe; + } + + /** + * Returns an array of pdfannotators that the current user is subscribed to and is allowed to unsubscribe from + * + * @return array An array of unsubscribable pdfannotators + */ + public static function get_unsubscribable_pdfannotators() { + global $USER, $DB; + + // Get courses that $USER is enrolled in and can see. + $courses = enrol_get_my_courses(); + if (empty($courses)) { + return array(); + } + + $courseids = array(); + foreach ($courses as $course) { + $courseids[] = $course->id; + } + list($coursesql, $courseparams) = $DB->get_in_or_equal($courseids, SQL_PARAMS_NAMED, 'c'); + + // Get all pdfannotators from the user's courses that they are subscribed to and which are not set to forced. + // It is possible for users to be subscribed to a pdfannotator in subscription disallowed mode so they must be listed + // here so that that can be unsubscribed from. + $sql = "SELECT f.id, cm.id as cm, cm.visible, f.course + FROM {pdfannotator} f + JOIN {course_modules} cm ON cm.instance = f.id + JOIN {modules} m ON m.name = :modulename AND m.id = cm.module + LEFT JOIN {pdfannotator_subscriptions} fs ON (fs.pdfannotator = f.id AND fs.userid = :userid) + WHERE f.forcesubscribe <> :forcesubscribe + AND fs.id IS NOT NULL + AND cm.course + $coursesql"; + $params = array_merge($courseparams, array( + 'modulename' => 'pdfannotator', + 'userid' => $USER->id, + 'forcesubscribe' => pdfannotator_FORCESUBSCRIBE, + )); + $pdfannotators = $DB->get_recordset_sql($sql, $params); + + $unsubscribablepdfannotators = array(); + foreach ($pdfannotators as $pdfannotator) { + if (empty($pdfannotator->visible)) { + // The pdfannotator is hidden - check if the user can view the pdfannotator. + $context = \context_module::instance($pdfannotator->cm); + if (!has_capability('moodle/course:viewhiddenactivities', $context)) { + // The user can't see the hidden pdfannotator to cannot unsubscribe. + continue; + } + } + + $unsubscribablepdfannotators[] = $pdfannotator; + } + $pdfannotators->close(); + + return $unsubscribablepdfannotators; + } + + /** + * Get the list of potential subscribers to a pdfannotator. + * + * @param context_module $context the pdfannotator context. + * @param integer $groupid the id of a group, or 0 for all groups. + * @param string $fields the list of fields to return for each user. As for get_users_by_capability. + * @param string $sort sort order. As for get_users_by_capability. + * @return array list of users. + */ + public static function get_potential_subscribers($context, $groupid, $fields, $sort = '') { + global $DB; + + // Only active enrolled users or everybody on the frontpage. + list($esql, $params) = get_enrolled_sql($context, 'mod/pdfannotator:allowforcesubscribe', $groupid, true); + if (!$sort) { + list($sort, $sortparams) = users_order_by_sql('u'); + $params = array_merge($params, $sortparams); + } + + $sql = "SELECT $fields + FROM {user} u + JOIN ($esql) je ON je.id = u.id + WHERE u.auth <> 'nologin' AND u.suspended = 0 AND u.confirmed = 1 + ORDER BY $sort"; + + return $DB->get_records_sql($sql, $params); + } + + /** + * Fetch the pdfannotator subscription data for the specified userid and pdfannotator. + * + * @param int $pdfannotatorid The pdfannotator to retrieve a cache for + * @param int $userid The user ID + * @return boolean + */ + public static function fetch_subscription_cache($pdfannotatorid, $userid) { + if (isset(self::$pdfannotatorcache[$userid]) && isset(self::$pdfannotatorcache[$userid][$pdfannotatorid])) { + return self::$pdfannotatorcache[$userid][$pdfannotatorid]; + } + self::fill_subscription_cache($pdfannotatorid, $userid); + + if (!isset(self::$pdfannotatorcache[$userid]) || !isset(self::$pdfannotatorcache[$userid][$pdfannotatorid])) { + return false; + } + + return self::$pdfannotatorcache[$userid][$pdfannotatorid]; + } + + /** + * Fill the pdfannotator subscription data for the specified userid and pdfannotator. + * + * If the userid is not specified, then all subscription data for that pdfannotator is fetched in a single query and used + * for subsequent lookups without requiring further database queries. + * + * @param int $pdfannotatorid The pdfannotator to retrieve a cache for + * @param int $userid The user ID + * @return void + */ + public static function fill_subscription_cache($pdfannotatorid, $userid = null) { + global $DB; + + if (!isset(self::$fetchedpdfannotators[$pdfannotatorid])) { + // This pdfannotator has not been fetched as a whole. + if (isset($userid)) { + if (!isset(self::$pdfannotatorcache[$userid])) { + self::$pdfannotatorcache[$userid] = array(); + } + + if (!isset(self::$pdfannotatorcache[$userid][$pdfannotatorid])) { + if ($DB->record_exists('pdfannotator_subscriptions', array( + 'userid' => $userid, + 'pdfannotator' => $pdfannotatorid, + ))) { + self::$pdfannotatorcache[$userid][$pdfannotatorid] = true; + } else { + self::$pdfannotatorcache[$userid][$pdfannotatorid] = false; + } + } + } else { + $subscriptions = $DB->get_recordset('pdfannotator_subscriptions', array( + 'pdfannotator' => $pdfannotatorid, + ), '', 'id, userid'); + foreach ($subscriptions as $id => $data) { + if (!isset(self::$pdfannotatorcache[$data->userid])) { + self::$pdfannotatorcache[$data->userid] = array(); + } + self::$pdfannotatorcache[$data->userid][$pdfannotatorid] = true; + } + self::$fetchedpdfannotators[$pdfannotatorid] = true; + $subscriptions->close(); + } + } + } + + /** + * Fill the pdfannotator subscription data for all pdfannotators that the specified userid can subscribe to in the specified + * course. + * + * @param int $courseid The course to retrieve a cache for + * @param int $userid The user ID + * @return void + */ + public static function fill_subscription_cache_for_course($courseid, $userid) { + global $DB; + + if (!isset(self::$pdfannotatorcache[$userid])) { + self::$pdfannotatorcache[$userid] = array(); + } + + $sql = "SELECT + f.id AS pdfannotatorid, + s.id AS subscriptionid + FROM {pdfannotator} f + LEFT JOIN {pdfannotator_subscriptions} s ON (s.pdfannotator = f.id AND s.userid = :userid) + WHERE f.course = :course + AND f.forcesubscribe <> :subscriptionforced"; + + $subscriptions = $DB->get_recordset_sql($sql, array( + 'course' => $courseid, + 'userid' => $userid, + 'subscriptionforced' => pdfannotator_FORCESUBSCRIBE, + )); + + foreach ($subscriptions as $id => $data) { + self::$pdfannotatorcache[$userid][$id] = !empty($data->subscriptionid); + } + $subscriptions->close(); + } + + /** + * Returns a list of user objects who are subscribed to this pdfannotator. + * + * @param stdClass $pdfannotator The pdfannotator record. + * @param int $groupid The group id if restricting subscriptions to a group of users, or 0 for all. + * @param context_module $context the pdfannotator context, to save re-fetching it where possible. + * @param string $fields requested user fields (with "u." table prefix). + * @param boolean $includediscussionsubscriptions Whether to take discussion subscriptions and unsubscriptions into + * consideration. + * @return array list of users. + */ + public static function fetch_subscribed_users($pdfannotator, $groupid = 0, $context = null, $fields = null, + $includediscussionsubscriptions = false) { + global $CFG, $DB; + + if (empty($fields)) { + $userfieldsapi = \core_user\fields::for_name(); + $allnames = $userfieldsapi->get_sql('u', false, '', '', false)->selects; + $fields = "u.id, + u.username, + $allnames, + u.maildisplay, + u.mailformat, + u.maildigest, + u.imagealt, + u.email, + u.emailstop, + u.city, + u.country, + u.lastaccess, + u.lastlogin, + u.picture, + u.timezone, + u.theme, + u.lang, + u.trackpdfannotators, + u.mnethostid"; + } + + // Retrieve the pdfannotator context if it wasn't specified. + $context = pdfannotator_get_context($pdfannotator->id, $context); + + if (self::is_forcesubscribed($pdfannotator)) { + $results = self::get_potential_subscribers($context, $groupid, $fields, "u.email ASC"); + + } else { + // Only active enrolled users or everybody on the frontpage. + list($esql, $params) = get_enrolled_sql($context, '', $groupid, true); + $params['pdfannotatorid'] = $pdfannotator->id; + + if ($includediscussionsubscriptions) { + $params['spdfannotatorid'] = $pdfannotator->id; + $params['dspdfannotatorid'] = $pdfannotator->id; + $params['unsubscribed'] = self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED; + + $sql = "SELECT $fields + FROM ( + SELECT userid FROM {pdfannotator_subscriptions} s + WHERE + s.pdfannotator = :spdfannotatorid + UNION + SELECT userid FROM {pdfannotator_discussion_subs} ds + WHERE + ds.pdfannotator = :dspdfannotatorid AND ds.preference <> :unsubscribed + ) subscriptions + JOIN {user} u ON u.id = subscriptions.userid + JOIN ($esql) je ON je.id = u.id + WHERE u.auth <> 'nologin' AND u.suspended = 0 AND u.confirmed = 1 + ORDER BY u.email ASC"; + + } else { + $sql = "SELECT $fields + FROM {user} u + JOIN ($esql) je ON je.id = u.id + JOIN {pdfannotator_subscriptions} s ON s.userid = u.id + WHERE + s.pdfannotator = :pdfannotatorid AND u.auth <> 'nologin' AND u.suspended = 0 AND u.confirmed = 1 + ORDER BY u.email ASC"; + } + $results = $DB->get_records_sql($sql, $params); + } + + // Guest user should never be subscribed to a pdfannotator. + unset($results[$CFG->siteguest]); + + // Apply the activity module availability resetrictions. + $cm = get_coursemodule_from_instance('pdfannotator', $pdfannotator->id, $pdfannotator->course); + $modinfo = get_fast_modinfo($pdfannotator->course); + $info = new \core_availability\info_module($modinfo->get_cm($cm->id)); + $results = $info->filter_user_list($results); + + return $results; + } + + /** + * Retrieve the discussion subscription data for the specified userid and pdfannotator. + * + * This is returned as an array of discussions for that pdfannotator which contain the preference in a stdClass. + * + * @param int $pdfannotatorid The pdfannotator to retrieve a cache for + * @param int $userid The user ID + * @return array of stdClass objects with one per discussion in the pdfannotator. + */ + public static function fetch_discussion_subscription($pdfannotatorid, $userid = null) { + self::fill_discussion_subscription_cache($pdfannotatorid, $userid); + + if (!isset(self::$pdfannotatordiscussioncache[$userid]) || + !isset(self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid])) { + return array(); + } + + return self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid]; + } + + /** + * Fill the discussion subscription data for the specified userid and pdfannotator. + * + * If the userid is not specified, then all discussion subscription data for that pdfannotator is fetched in a single query + * and used for subsequent lookups without requiring further database queries. + * + * @param int $pdfannotatorid The pdfannotator to retrieve a cache for + * @param int $userid The user ID + * @return void + */ + public static function fill_discussion_subscription_cache($pdfannotatorid, $userid = null) { + global $DB; + + if (!isset(self::$discussionfetchedpdfannotators[$pdfannotatorid])) { + // This pdfannotator hasn't been fetched as a whole yet. + if (isset($userid)) { + if (!isset(self::$pdfannotatordiscussioncache[$userid])) { + self::$pdfannotatordiscussioncache[$userid] = array(); + } + + if (!isset(self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid])) { + $subscriptions = $DB->get_recordset('pdfannotator_discussion_subs', array( + 'userid' => $userid, + 'pdfannotator' => $pdfannotatorid, + ), null, 'id, discussion, preference'); + + self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid] = array(); + foreach ($subscriptions as $id => $data) { + self::add_to_discussion_cache($pdfannotatorid, $userid, $data->discussion, $data->preference); + } + + $subscriptions->close(); + } + } else { + $subscriptions = $DB->get_recordset('pdfannotator_discussion_subs', array( + 'pdfannotator' => $pdfannotatorid, + ), null, 'id, userid, discussion, preference'); + foreach ($subscriptions as $id => $data) { + self::add_to_discussion_cache($pdfannotatorid, $data->userid, $data->discussion, $data->preference); + } + self::$discussionfetchedpdfannotators[$pdfannotatorid] = true; + $subscriptions->close(); + } + } + } + + /** + * Add the specified discussion and user preference to the discussion + * subscription cache. + * + * @param int $pdfannotatorid The ID of the pdfannotator that this preference belongs to + * @param int $userid The ID of the user that this preference belongs to + * @param int $discussion The ID of the discussion that this preference relates to + * @param int $preference The preference to store + */ + protected static function add_to_discussion_cache($pdfannotatorid, $userid, $discussion, $preference) { + if (!isset(self::$pdfannotatordiscussioncache[$userid])) { + self::$pdfannotatordiscussioncache[$userid] = array(); + } + + if (!isset(self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid])) { + self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid] = array(); + } + + self::$pdfannotatordiscussioncache[$userid][$pdfannotatorid][$discussion] = $preference; + } + + /** + * Reset the discussion cache. + * + * This cache is used to reduce the number of database queries when + * checking pdfannotator discussion subscription states. + */ + public static function reset_discussion_cache() { + self::$pdfannotatordiscussioncache = array(); + self::$discussionfetchedpdfannotators = array(); + } + + /** + * Reset the pdfannotator cache. + * + * This cache is used to reduce the number of database queries when + * checking pdfannotator subscription states. + */ + public static function reset_pdfannotator_cache() { + self::$pdfannotatorcache = array(); + self::$fetchedpdfannotators = array(); + } + + /** + * Adds user to the subscriber list. + * + * @param int $userid The ID of the user to subscribe + * @param \stdClass $pdfannotator The pdfannotator record for this pdfannotator. + * @param \context_module|null $context Module context, may be omitted if not known or if called for the current + * module set in page. + * @param boolean $userrequest Whether the user requested this change themselves. This has an effect on whether + * discussion subscriptions are removed too. + * @return bool|int Returns true if the user is already subscribed, or the pdfannotator_subscriptions ID if the user was + * successfully subscribed. + */ + public static function subscribe_user($userid, $pdfannotator, $context = null, $userrequest = false) { + global $DB; + + if (self::is_subscribed($userid, $pdfannotator)) { + return true; + } + + $sub = new \stdClass(); + $sub->userid = $userid; + $sub->pdfannotator = $pdfannotator->id; + + $result = $DB->insert_record("pdfannotator_subscriptions", $sub); + + if ($userrequest) { + $discussionsubscriptions = $DB->get_recordset('pdfannotator_discussion_subs', array('userid' => $userid, + 'pdfannotator' => $pdfannotator->id)); + $DB->delete_records_select('pdfannotator_discussion_subs', + 'userid = :userid AND pdfannotator = :pdfannotatorid AND preference <> :preference', array( + 'userid' => $userid, + 'pdfannotatorid' => $pdfannotator->id, + 'preference' => self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED, + )); + + // Reset the subscription caches for this pdfannotator. + // We know that the there were previously entries and there aren't any more. + if (isset(self::$pdfannotatordiscussioncache[$userid]) && + isset(self::$pdfannotatordiscussioncache[$userid][$pdfannotator->id])) { + foreach (self::$pdfannotatordiscussioncache[$userid][$pdfannotator->id] as $discussionid => $preference) { + if ($preference != self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED) { + unset(self::$pdfannotatordiscussioncache[$userid][$pdfannotator->id][$discussionid]); + } + } + } + } + + // Reset the cache for this pdfannotator. + self::$pdfannotatorcache[$userid][$pdfannotator->id] = true; + + $context = pdfannotator_get_context($pdfannotator->id, $context); + $params = array( + 'context' => $context, + 'objectid' => $result, + 'relateduserid' => $userid, + 'other' => array('pdfannotatorid' => $pdfannotator->id), + + ); + $event = event\subscription_created::create($params); + if ($userrequest && $discussionsubscriptions) { + foreach ($discussionsubscriptions as $subscription) { + $event->add_record_snapshot('pdfannotator_discussion_subs', $subscription); + } + $discussionsubscriptions->close(); + } + $event->trigger(); + + return $result; + } + + /** + * Removes user from the subscriber list + * + * @param int $userid The ID of the user to unsubscribe + * @param \stdClass $pdfannotator The pdfannotator record for this pdfannotator. + * @param \context_module|null $context Module context, may be omitted if not known or if called for the current + * module set in page. + * @param boolean $userrequest Whether the user requested this change themselves. This has an effect on whether + * discussion subscriptions are removed too. + * @return boolean Always returns true. + */ + public static function unsubscribe_user($userid, $pdfannotator, $context = null, $userrequest = false) { + global $DB; + + $sqlparams = array( + 'userid' => $userid, + 'pdfannotator' => $pdfannotator->id, + ); + $DB->delete_records('pdfannotator_digests', $sqlparams); + + if ($pdfannotatorsubscription = $DB->get_record('pdfannotator_subscriptions', $sqlparams)) { + $DB->delete_records('pdfannotator_subscriptions', array('id' => $pdfannotatorsubscription->id)); + + if ($userrequest) { + $discussionsubscriptions = $DB->get_recordset('pdfannotator_discussion_subs', $sqlparams); + $DB->delete_records('pdfannotator_discussion_subs', + array('userid' => $userid, 'pdfannotator' => $pdfannotator->id, + 'preference' => self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED)); + + // We know that the there were previously entries and there aren't any more. + if (isset(self::$pdfannotatordiscussioncache[$userid]) && + isset(self::$pdfannotatordiscussioncache[$userid][$pdfannotator->id])) { + self::$pdfannotatordiscussioncache[$userid][$pdfannotator->id] = array(); + } + } + + // Reset the cache for this pdfannotator. + self::$pdfannotatorcache[$userid][$pdfannotator->id] = false; + + $context = pdfannotator_get_context($pdfannotator->id, $context); + $params = array( + 'context' => $context, + 'objectid' => $pdfannotatorsubscription->id, + 'relateduserid' => $userid, + 'other' => array('pdfannotatorid' => $pdfannotator->id), + + ); + $event = event\subscription_deleted::create($params); + $event->add_record_snapshot('pdfannotator_subscriptions', $pdfannotatorsubscription); + if ($userrequest && $discussionsubscriptions) { + foreach ($discussionsubscriptions as $subscription) { + $event->add_record_snapshot('pdfannotator_discussion_subs', $subscription); + } + $discussionsubscriptions->close(); + } + $event->trigger(); + } + + return true; + } + + /** + * Subscribes the user to the specified discussion. + * + * @param int $userid The userid of the user being subscribed + * @param \stdClass $discussion The discussion to subscribe to + * @param \context_module|null $context Module context, may be omitted if not known or if called for the current + * module set in page. + * @return boolean Whether a change was made + */ + public static function subscribe_user_to_discussion($userid, $discussion, $context = null) { + global $DB; + + // First check whether the user is subscribed to the discussion already. + $subscription = $DB->get_record('pdfannotator_discussion_subs', array('userid' => $userid, + 'discussion' => $discussion->id)); + if ($subscription) { + if ($subscription->preference != self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED) { + // The user is already subscribed to the discussion. Ignore. + return false; + } + } + // No discussion-level subscription. Check for a pdfannotator level subscription. + if ($DB->record_exists('pdfannotator_subscriptions', array('userid' => $userid, + 'pdfannotator' => $discussion->pdfannotator))) { + if ($subscription && $subscription->preference == self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED) { + // The user is subscribed to the pdfannotator, but unsubscribed from the discussion, delete the discussion + // preference. + $DB->delete_records('pdfannotator_discussion_subs', array('id' => $subscription->id)); + unset(self::$pdfannotatordiscussioncache[$userid][$discussion->pdfannotator][$discussion->id]); + } else { + // The user is already subscribed to the pdfannotator. Ignore. + return false; + } + } else { + if ($subscription) { + $subscription->preference = time(); + $DB->update_record('pdfannotator_discussion_subs', $subscription); + } else { + $subscription = new \stdClass(); + $subscription->userid = $userid; + $subscription->pdfannotator = $discussion->pdfannotator; + $subscription->discussion = $discussion->id; + $subscription->preference = time(); + + $subscription->id = $DB->insert_record('pdfannotator_discussion_subs', $subscription); + self::$pdfannotatordiscussioncache[$userid][$discussion->pdfannotator][$discussion->id] = $subscription->preference; + } + } + + $context = pdfannotator_get_context($discussion->pdfannotator, $context); + $params = array( + 'context' => $context, + 'objectid' => $subscription->id, + 'relateduserid' => $userid, + 'other' => array( + 'pdfannotatorid' => $discussion->pdfannotator, + 'discussion' => $discussion->id, + ), + + ); + $event = event\discussion_subscription_created::create($params); + $event->trigger(); + + return true; + } + /** + * Unsubscribes the user from the specified discussion. + * + * @param int $userid The userid of the user being unsubscribed + * @param \stdClass $discussion The discussion to unsubscribe from + * @param \context_module|null $context Module context, may be omitted if not known or if called for the current + * module set in page. + * @return boolean Whether a change was made + */ + public static function unsubscribe_user_from_discussion($userid, $discussion, $context = null) { + global $DB; + + // First check whether the user's subscription preference for this discussion. + $subscription = $DB->get_record('pdfannotator_discussion_subs', array('userid' => $userid, + 'discussion' => $discussion->id)); + if ($subscription) { + if ($subscription->preference == self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED) { + // The user is already unsubscribed from the discussion. Ignore. + return false; + } + } + // No discussion-level preference. Check for a pdfannotator level subscription. + if (!$DB->record_exists('pdfannotator_subscriptions', array('userid' => $userid, + 'pdfannotator' => $discussion->pdfannotator))) { + if ($subscription && $subscription->preference != self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED) { + // The user is not subscribed to the pdfannotator, but subscribed from the discussion, delete the discussion + // subscription. + $DB->delete_records('pdfannotator_discussion_subs', array('id' => $subscription->id)); + unset(self::$pdfannotatordiscussioncache[$userid][$discussion->pdfannotator][$discussion->id]); + } else { + // The user is not subscribed from the pdfannotator. Ignore. + return false; + } + } else { + if ($subscription) { + $subscription->preference = self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED; + $DB->update_record('pdfannotator_discussion_subs', $subscription); + } else { + $subscription = new \stdClass(); + $subscription->userid = $userid; + $subscription->pdfannotator = $discussion->pdfannotator; + $subscription->discussion = $discussion->id; + $subscription->preference = self::PDFANNOTATOR_DISCUSSION_UNSUBSCRIBED; + + $subscription->id = $DB->insert_record('pdfannotator_discussion_subs', $subscription); + } + self::$pdfannotatordiscussioncache[$userid][$discussion->pdfannotator][$discussion->id] = $subscription->preference; + } + + $context = pdfannotator_get_context($discussion->pdfannotator, $context); + $params = array( + 'context' => $context, + 'objectid' => $subscription->id, + 'relateduserid' => $userid, + 'other' => array( + 'pdfannotatorid' => $discussion->pdfannotator, + 'discussion' => $discussion->id, + ), + + ); + $event = event\discussion_subscription_deleted::create($params); + $event->trigger(); + + return true; + } + + /** + * Gets the default subscription value for the logged in user. + * + * @param \stdClass $pdfannotator The pdfannotator record + * @param \context $context The course context + * @param \cm_info $cm cm_info + * @param int|null $discussionid The discussion we are checking against + * @return bool Default subscription + * @throws coding_exception + */ + public static function get_user_default_subscription($pdfannotator, $context, $cm, ?int $discussionid) { + global $USER; + $manageactivities = has_capability('moodle/course:manageactivities', $context); + if (self::subscription_disabled($pdfannotator) && !$manageactivities) { + // User does not have permission to subscribe to this discussion at all. + $discussionsubscribe = false; + } else if (self::is_forcesubscribed($pdfannotator)) { + // User does not have permission to unsubscribe from this discussion at all. + $discussionsubscribe = true; + } else { + if (isset($discussionid) && self::is_subscribed($USER->id, $pdfannotator, $discussionid, $cm)) { + // User is subscribed to the discussion - continue the subscription. + $discussionsubscribe = true; + } else if (!isset($discussionid) && self::is_subscribed($USER->id, $pdfannotator, null, $cm)) { + // Starting a new discussion, and the user is subscribed to the pdfannotator - subscribe to the discussion. + $discussionsubscribe = true; + } else { + // User is not subscribed to either pdfannotator or discussion. Follow user preference. + $discussionsubscribe = $USER->autosubscribe ?? false; + } + } + + return $discussionsubscribe; + } +} diff --git a/db/install.xml b/db/install.xml index 3324d94..995dfc9 100644 --- a/db/install.xml +++ b/db/install.xml @@ -18,6 +18,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index c623f1f..567cdb8 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -603,5 +603,20 @@ function xmldb_pdfannotator_upgrade($oldversion) { upgrade_mod_savepoint(true, 2021032201, 'pdfannotator'); } + if ($oldversion < 2021112901) { + + // Define field forcesubscribe to be added to pdfannotator. + $table = new xmldb_table('pdfannotator'); + $field = new xmldb_field('forcesubscribe', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'useprotectedcomments'); + + // Conditionally launch add field forcesubscribe. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Pdfannotator savepoint reached. + upgrade_mod_savepoint(true, 2021112901, 'pdfannotator'); + } + return true; } diff --git a/lang/en/pdfannotator.php b/lang/en/pdfannotator.php index f06bb51..2ff2522 100644 --- a/lang/en/pdfannotator.php +++ b/lang/en/pdfannotator.php @@ -63,6 +63,7 @@ $string['comment'] = 'Comment'; $string['commentDeleted'] = 'Comment has been deleted'; $string['comments'] = 'Comments'; +$string['configsubscriptiontype'] = 'Default setting for subscription mode.'; $string['correct'] = 'correct'; $string['count'] = 'count'; $string['createAnnotation'] = 'Create Annotation'; @@ -424,6 +425,18 @@ $string['subscribed'] = 'Subscribed'; $string['subscribedanswers'] = 'to my subscribed questions'; $string['subscribeQuestion'] = 'Subscribe'; +$string['subscription'] = 'Subscription'; +$string['subscriptionauto'] = 'Auto subscription'; +$string['subscriptiondisabled'] = 'Subscription disabled'; +$string['subscriptionforced'] = 'Forced subscription'; +$string['subscriptionmode'] = 'Subscription mode'; +$string['subscriptionmode_help'] = 'When a participant is subscribed to a question it means they will receive notifications for questions. There are 4 subscription mode options: + +* Auto subscription - Everyone is subscribed initially to notifications for questions but can choose to unsubscribe at any time +* Optional subscription - Participants can choose whether notifications for questions are subscribed +* Forced subscription - Everyone is subscribed to notifications for questions and cannot unsubscribe +* Subscription disabled - Subscriptions to notifications for questions are not allowed'; +$string['subscriptionoptional'] = 'Optional subscription'; $string['subtitleforreportcommentform'] = 'Your message for the course manager'; $string['successfullyEdited'] = 'Changes saved'; $string['successfullyHidden'] = 'Participants now see this comment as hidden.'; diff --git a/lib.php b/lib.php index 85b0433..81956f8 100644 --- a/lib.php +++ b/lib.php @@ -21,6 +21,11 @@ */ defined('MOODLE_INTERNAL') || die; +define('PDFANNOTATOR_CHOOSESUBSCRIBE', 0); +define('PDFANNOTATOR_FORCESUBSCRIBE', 1); +define('PDFANNOTATOR_INITIALSUBSCRIBE', 2); +define('PDFANNOTATOR_DISALLOWSUBSCRIBE',3); + /** * List of features supported in pdfannotator module * @param string $feature FEATURE_xx constant for requested feature @@ -732,3 +737,18 @@ function pdfannotator_print_recent_mod_activity($activity, $courseid, $detail, $ echo $output; } + +/** + * List the options for pdfannotator subscription modes. + * This is used by the settings page and by the mod_form page. + * + * @return array + */ +function pdfannotator_get_subscriptionmode_options() { + $options = array(); + $options[PDFANNOTATOR_INITIALSUBSCRIBE] = get_string('subscriptionauto', 'pdfannotator'); + $options[PDFANNOTATOR_CHOOSESUBSCRIBE] = get_string('subscriptionoptional', 'pdfannotator'); + $options[PDFANNOTATOR_FORCESUBSCRIBE] = get_string('subscriptionforced', 'pdfannotator'); + $options[PDFANNOTATOR_DISALLOWSUBSCRIBE] = get_string('subscriptiondisabled', 'pdfannotator'); + return $options; +} diff --git a/locallib.php b/locallib.php index e01ead4..0935ca5 100644 --- a/locallib.php +++ b/locallib.php @@ -119,6 +119,19 @@ function pdfannotator_get_username($userid) { return fullname($user); } +/** + * Returns the subscription mode for a given pdfannotator + * + * @param $id The pdfannotator id + * @return false|int + * @throws dml_exception + */ +function pdfannotator_get_subscriptionmode($id) { + global $DB; + $subscriptionmode = $DB->get_field('pdfannotator', 'forcesubscribe', array('id' => $id), $strictness = MUST_EXIST); + return $subscriptionmode; +} + function pdfannotator_get_annotationtype_id($typename) { global $DB; if ($typename == 'point') { diff --git a/mod_form.php b/mod_form.php index cf4f7fd..ea45421 100644 --- a/mod_form.php +++ b/mod_form.php @@ -14,6 +14,8 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . /** + * The pdfannotator module form + * * @package mod_pdfannotator * @copyright 2018 RWTH Aachen (see README.md) * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later @@ -94,20 +96,20 @@ public function definition() { $mform->setDefault('useprint', $config->useprint); $mform->addHelpButton('useprint', 'setting_useprint_document', 'pdfannotator'); - $mform->addElement('advcheckbox', 'useprintcomments', get_string('setting_useprint_comments', 'pdfannotator'), - get_string('useprint_comments', 'pdfannotator'), null, array(0, 1)); + $mform->addElement('advcheckbox', 'useprintcomments', get_string('setting_useprint_comments', + 'pdfannotator'), get_string('useprint_comments', 'pdfannotator'), null, array(0, 1)); $mform->setType('useprintcomments', PARAM_BOOL); $mform->setDefault('useprintcomments', $config->useprintcomments); $mform->addHelpButton('useprintcomments', 'setting_useprint_comments', 'pdfannotator'); - $mform->addElement('advcheckbox', 'useprivatecomments', get_string('setting_use_private_comments', 'pdfannotator'), - get_string('use_private_comments', 'pdfannotator'), null, array(0, 1)); + $mform->addElement('advcheckbox', 'useprivatecomments', get_string('setting_use_private_comments', + 'pdfannotator'), get_string('use_private_comments', 'pdfannotator'), null, array(0, 1)); $mform->setType('useprivatecomments', PARAM_BOOL); $mform->setDefault('useprivatecomments', $config->use_private_comments); $mform->addHelpButton('useprivatecomments', 'setting_use_private_comments', 'pdfannotator'); - $mform->addElement('advcheckbox', 'useprotectedcomments', get_string('setting_use_protected_comments', 'pdfannotator'), - get_string('use_protected_comments', 'pdfannotator'), null, array(0, 1)); + $mform->addElement('advcheckbox', 'useprotectedcomments', get_string('setting_use_protected_comments', + 'pdfannotator'), get_string('use_protected_comments', 'pdfannotator'), null, array(0, 1)); $mform->setType('useprotectedcomments', PARAM_BOOL); $mform->setDefault('useprotectedcomments', $config->use_protected_comments); $mform->addHelpButton('useprotectedcomments', 'setting_use_protected_comments', 'pdfannotator'); @@ -119,6 +121,19 @@ public function definition() { $mform->addElement('select', 'legacyfiles', get_string('legacyfiles', 'pdfannotator'), $options); } + // Subscription and tracking. + $mform->addElement('header', 'subscriptionandtrackinghdr', get_string('subscription', 'pdfannotator')); + + $options = pdfannotator_get_subscriptionmode_options(); + $mform->addElement('select', 'forcesubscribe', get_string('subscriptionmode', 'pdfannotator'), $options); + $mform->addHelpButton('forcesubscribe', 'subscriptionmode', 'pdfannotator'); + if (isset($CFG->pdfannotator_subscription)) { + $defaultpdfannotatorsubscription = $CFG->pdfannotator_subscription; + } else { + $defaultpdfannotatorsubscription = PDFANNOTATOR_INITIALSUBSCRIBE; + } + $mform->setDefault('forcesubscribe', $defaultpdfannotatorsubscription); + $this->standard_coursemodule_elements(); $this->add_action_buttons(); diff --git a/model/comment.class.php b/model/comment.class.php index b282c6b..e6adc7f 100644 --- a/model/comment.class.php +++ b/model/comment.class.php @@ -95,7 +95,14 @@ public static function create($documentid, $annotationid, $content, $visibility, } } } else if ($visibility != 'private') { - self::insert_subscription($annotationid, $context); + + $pdfannotatorid = 1; + + if (!((pdfannotator_get_subscriptionmode($cm->instance) == PDFANNOTATOR_CHOOSESUBSCRIBE) || + (pdfannotator_get_subscriptionmode($cm->instance) == PDFANNOTATOR_DISALLOWSUBSCRIBE))) { + // Don't insert if subscription mode is Optional subscription or Subscription disabled + self::insert_subscription($annotationid, $context); + } // Notify all users, that there is a new question. $recipients = get_enrolled_users($context, 'mod/pdfannotator:recievenewquestionnotifications'); diff --git a/settings.php b/settings.php index d445ca4..4a25a95 100644 --- a/settings.php +++ b/settings.php @@ -21,6 +21,8 @@ */ defined('MOODLE_INTERNAL') || die; // Prevents crashes on misconfigured production server. +require_once($CFG->dirroot . '/mod/pdfannotator/lib.php'); + if ($ADMIN->fulltree) { require_once('constants.php'); $settings->add(new admin_setting_configcheckbox('mod_pdfannotator/usevotes', @@ -49,7 +51,12 @@ get_string('global_setting_use_protected_comments', 'pdfannotator'), get_string('global_setting_use_protected_comments_desc', 'pdfannotator'), 0)); - // Define what API to use for converting latex formulas into png. + // Default Subscription mode setting. + $options = pdfannotator_get_subscriptionmode_options(); + $settings->add(new admin_setting_configselect('pdfannotator_subscription', get_string('subscriptionmode', 'pdfannotator'), + get_string('configsubscriptiontype', 'pdfannotator'), PDFANNOTATOR_INITIALSUBSCRIBE, $options)); + + //Define what API to use for converting latex formulas into png. $options = array(); $options[LATEX_TO_PNG_MOODLE] = get_string("global_setting_latexusemoodle", "pdfannotator"); $options[LATEX_TO_PNG_GOOGLE_API] = get_string("global_setting_latexusegoogle", "pdfannotator"); diff --git a/templates/comment.mustache b/templates/comment.mustache index 68c167e..05eabda 100644 --- a/templates/comment.mustache +++ b/templates/comment.mustache @@ -1,3 +1,100 @@ +{{! + 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 . +}} +{{! + @template mod_pdfannotator/comment + + Template which displays a comment. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Example context (json): + { + "comments": [ + { + "userid": "103", + "visibility": "public", + "isquestion": true, + "annotationid": 1, + "annotation": 1, + "timecreated": "58 minutes ago", + "isdeleted": false, + "uuid": "1", + "ishidden": 0, + "content": "Comment", + "username": "me", + "solved": false, + "votes": "0", + "isvoted": false, + "usevotes": true, + "issubscribed": true, + "buttons": [ + { + "classes": "comment-solve-a", + "faicon": { + "class": "fa-lock" + }, + "text": "Close question" + }, + { + "classes": "comment-edit-a", + "attributes": { + "name": "id", + "value": "editButton1" + }, + "moodleicon": { + "key": "i/edit", + "component": "core", + "title": "Edit" + }, + "text": "Edit" + }, + { + "classes": "comment-delete-a", + "text": "Delete", + "moodleicon": { + "key": "delete", + "component": "pdfannotator", + "title": "Delete" + } + }, + { + "classes": "comment-subscribe-a", + "faicon": { + "class": "fa-bell-slash" + }, + "text": "Unsubscribe" + } + ], + "owner": true, + "private": false, + "protected": false, + "wrapperClass": "chat-message comment-list-item questioncomment owner usevotes", + "voteBtn": "own comment", + "voteTitle": "0 persons are also interested in this question", + "modifiedby": null, + "dropdown": true + } + ] + } +}} {{# comments }}
@@ -10,15 +107,14 @@
{{{ votes }}}
{{/ usevotes }} {{/ isdeleted }}
- +
{{# dropdown }} - - +
{{{ content }}}
- + {{#displayhidden}}{{# str }} hiddenforparticipants, pdfannotator {{/ str }}{{/displayhidden}} - + - -
+ +
{{^ isdeleted }} {{# edited }} diff --git a/templates/index.mustache b/templates/index.mustache index 57c31b8..c869c1d 100644 --- a/templates/index.mustache +++ b/templates/index.mustache @@ -5,10 +5,10 @@
-
+
- + @@ -35,8 +35,8 @@ + @@ -61,11 +61,11 @@ + - / + + / 1 @@ -88,11 +88,11 @@ -
+
@@ -100,7 +100,7 @@ - +
@@ -118,11 +118,11 @@
- + -
+
@@ -130,7 +130,7 @@
-
- diff --git a/tests/behat/add_pdfannotator.feature b/tests/behat/add_pdfannotator.feature new file mode 100644 index 0000000..85d2952 --- /dev/null +++ b/tests/behat/add_pdfannotator.feature @@ -0,0 +1,31 @@ +@mod @mod_pdfannotator @_file_upload +Feature: Add a pdfannotator activity + In order to let the users use the pdfannotator in a course + As a teacher + I need to add a pdfannotator to a moodle course + + @javascript + Scenario: Add a pdfannotator to a course + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a "PDF Annotation" to section "1" and I fill the form with: + | Name | Test pdf annotation | + | Description | Test pdf annotation description | + | Select a pdf-file | mod/pdfannotator/tests/fixtures/submission.pdf | + And I am on "Course 1" course homepage with editing mode on + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test pdf annotation" + Then I should see "Test pdf annotation" diff --git a/tests/behat/annotate_pdfannotator.feature b/tests/behat/annotate_pdfannotator.feature new file mode 100644 index 0000000..ebbeaa1 --- /dev/null +++ b/tests/behat/annotate_pdfannotator.feature @@ -0,0 +1,114 @@ +@mod @mod_pdfannotator @_file_upload @javascript +Feature: Annotate in a pdfannotator activity + In order to annotate in the pdfannotator in a course + As a student + I need to note questions and subscribe or unsubscribe to notificatoins + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | category | + | Course 1 | C1 | 0 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a "PDF Annotation" to section "1" and I fill the form with: + | Name | Test PDF annotation | + | Description | Test pdf annotation description | + | Subscription mode | Optional subscription | + | Select a pdf-file | mod/pdfannotator/tests/fixtures/submission.pdf | + And I am on "Course 1" course homepage with editing mode on + And I log out + + Scenario: Add a question to a pdfannotator with optional subscription + Given I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I click on "comment" "button" + And I wait "1" seconds + And I point at the pdfannotator canvas + And I wait "1" seconds + And I set the field with xpath "//textarea[@id='myarea']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I click the pdfannotator public comment dropdown menu button + And I should not see "Unsubscribe" + Then I should see "Subscribe" + And I log out + + Scenario: Add a question to a pdfannotator with auto subscription + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I navigate to "Edit settings" in current page administration + And I set the following fields to these values: + | Subscription mode | Auto subscription | + And I press "Save" + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I click on "comment" "button" + And I wait "1" seconds + And I point at the pdfannotator canvas + And I wait "1" seconds + And I set the field with xpath "//textarea[@id='myarea']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I click the pdfannotator public comment dropdown menu button + And I should not see "Subscribe" + Then I should see "Unsubscribe" + And I log out + + Scenario: Add a question to a pdfannotator with subscription disabled + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I navigate to "Edit settings" in current page administration + And I set the following fields to these values: + | Subscription mode | Subscription disabled | + And I press "Save" + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I click on "comment" "button" + And I wait "1" seconds + And I point at the pdfannotator canvas + And I wait "1" seconds + And I set the field with xpath "//textarea[@id='myarea']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I click the pdfannotator public comment dropdown menu button + And I should not see "Subscribe" + Then I should not see "Unsubscribe" + And I log out + + Scenario: Add a question to a pdfannotator with forced subscription + Given I log in as "teacher1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I navigate to "Edit settings" in current page administration + And I set the following fields to these values: + | Subscription mode | Forced subscription | + And I press "Save" + And I log out + And I log in as "student1" + And I am on "Course 1" course homepage + And I follow "Test PDF annotation" + And I click on "comment" "button" + And I wait "1" seconds + And I point at the pdfannotator canvas + And I wait "1" seconds + And I set the field with xpath "//textarea[@id='myarea']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I click the pdfannotator public comment dropdown menu button + And I should not see "Subscribe" + Then I should not see "Unsubscribe" diff --git a/tests/behat/behat_pdfannotator_editpdf.php b/tests/behat/behat_pdfannotator_editpdf.php new file mode 100644 index 0000000..9e7a250 --- /dev/null +++ b/tests/behat/behat_pdfannotator_editpdf.php @@ -0,0 +1,59 @@ +. + +/** + * Behat pdfannotator-related steps definitions. + * + * @package pdfannotator + * @category test + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php. + +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); + +/** + * Steps definitions related with the pdfannotator. + * + * @package pdfannotator + * @category test + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_pdfannotator_editpdf extends behat_base { + + /** + * Point at the pdfannotator pdf. + * + * @When /^I point at the pdfannotator canvas$/ + */ + public function i_point_at_the_pdfannotator_canvas() { + $node = $this->find('xpath', '//div[@id=\'pageContainer1\']'); + $this->execute('behat_general::i_click_on', [$node, 'NodeElement']); + } + + /** + * Point at the pdfannotator pdf. + * + * @When /^I click the pdfannotator public comment dropdown menu button$/ + */ + public function i_click_the_pdfannotator_public_comment_dropdown_menu_button() { + $node = $this->find('xpath', '//a[@id=\'dropdownMenuButton\']'); + $this->execute('behat_general::i_click_on', [$node, 'NodeElement']); + } +} diff --git a/tests/fixtures/submission.pdf b/tests/fixtures/submission.pdf new file mode 100644 index 0000000000000000000000000000000000000000..576d37832e921287cfbd2e5f63bdea43534789c9 GIT binary patch literal 24751 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 = 'mod_pdfannotator'; -$plugin->version = 2021112900; +$plugin->version = 2021112901; $plugin->release = 'PDF Annotator v1.4 release 10'; $plugin->requires = 2021051700; $plugin->maturity = MATURITY_STABLE;