From 98d642c7a97f036c3600655831e3845591937b8b 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] Begin develop subscription like forum. --- .../moodle2/backup_pdfannotator_stepslib.php | 2 +- classes/existing_subscriber_selector.php | 69 ++ classes/output/comment.php | 130 ++- classes/output/index.php | 3 + classes/potential_subscriber_selector.php | 163 ++++ classes/subscriber_selector_base.php | 87 ++ 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 | 62 +- 22 files changed, 1758 insertions(+), 105 deletions(-) create mode 100644 classes/existing_subscriber_selector.php create mode 100644 classes/potential_subscriber_selector.php create mode 100644 classes/subscriber_selector_base.php 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..7102adb 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')); + 'useprotectedcomments', 'useprivatecomments', '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/existing_subscriber_selector.php b/classes/existing_subscriber_selector.php new file mode 100644 index 0000000..b4e2f5b --- /dev/null +++ b/classes/existing_subscriber_selector.php @@ -0,0 +1,69 @@ +. + +/** + * A type of pdfannotator. + * + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/user/selector/lib.php'); + +/** + * User selector control for removing subscribed users + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_pdfannotator_existing_subscriber_selector extends mod_pdfannotator_subscriber_selector_base { + + /** + * Finds all subscribed users + * + * @param string $search + * @return array + */ + public function find_users($search) { + global $DB; + list($wherecondition, $params) = $this->search_sql($search, 'u'); + $params['pdfannotatorid'] = $this->pdfannotatorid; + + // Only active enrolled or everybody on the frontpage. + list($esql, $eparams) = get_enrolled_sql($this->context, '', $this->currentgroup, true); + $fields = $this->required_fields_sql('u'); + list($sort, $sortparams) = users_order_by_sql('u', $search, $this->accesscontext); + $params = array_merge($params, $eparams, $sortparams); + + $subscribers = $DB->get_records_sql("SELECT $fields + FROM {user} u + JOIN ($esql) je ON je.id = u.id + JOIN {pdfannotator_subscriptions} s ON s.userid = u.id + WHERE $wherecondition AND s.pdfannotator = :pdfannotatorid + ORDER BY $sort", $params); + + $cm = get_coursemodule_from_instance('pdfannotator', $this->pdfannotatorid); + $modinfo = get_fast_modinfo($cm->course); + $info = new \core_availability\info_module($modinfo->get_cm($cm->id)); + $subscribers = $info->filter_user_list($subscribers); + + return array(get_string("existingsubscribers", 'pdfannotator') => $subscribers); + } + +} 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/potential_subscriber_selector.php b/classes/potential_subscriber_selector.php new file mode 100644 index 0000000..704864e --- /dev/null +++ b/classes/potential_subscriber_selector.php @@ -0,0 +1,163 @@ +. + +/** + * A type of pdfannotator. + * + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/user/selector/lib.php'); + +/** + * A user selector control for potential subscribers to the selected pdfannotator + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class mod_pdfannotator_potential_subscriber_selector extends mod_pdfannotator_subscriber_selector_base { + /** + * If set to true EVERYONE in this course is force subscribed to this pdfannotator + * @var bool + */ + protected $forcesubscribed = false; + + /** + * Can be used to store existing subscribers so that they can be removed from + * the potential subscribers list + * + * @var array + */ + protected $existingsubscribers = array(); + + /** + * Constructor method + * @param string $name + * @param array $options + */ + public function __construct($name, $options) { + parent::__construct($name, $options); + if (isset($options['forcesubscribed'])) { + $this->forcesubscribed = true; + } + } + + /** + * Returns an array of options for this control + * @return array + */ + protected function get_options() { + $options = parent::get_options(); + if ($this->forcesubscribed === true) { + $options['forcesubscribed'] = 1; + } + return $options; + } + + /** + * Finds all potential users + * + * Potential subscribers are all enroled users who are not already subscribed. + * + * @param string $search + * @return array + */ + public function find_users($search) { + global $DB; + + $whereconditions = array(); + list($wherecondition, $params) = $this->search_sql($search, 'u'); + if ($wherecondition) { + $whereconditions[] = $wherecondition; + } + + if (!$this->forcesubscribed) { + $existingids = array(); + foreach ($this->existingsubscribers as $group) { + foreach ($group as $user) { + $existingids[$user->id] = 1; + } + } + if ($existingids) { + list($usertest, $userparams) = $DB->get_in_or_equal( + array_keys($existingids), SQL_PARAMS_NAMED, 'existing', false); + $whereconditions[] = 'u.id ' . $usertest; + $params = array_merge($params, $userparams); + } + } + + if ($whereconditions) { + $wherecondition = 'WHERE ' . implode(' AND ', $whereconditions); + } + + list($esql, $eparams) = get_enrolled_sql($this->context, '', $this->currentgroup, true); + $params = array_merge($params, $eparams); + + $fields = 'SELECT ' . $this->required_fields_sql('u'); + + $sql = " FROM {user} u + JOIN ($esql) je ON je.id = u.id + $wherecondition"; + + list($sort, $sortparams) = users_order_by_sql('u', $search, $this->accesscontext); + $order = ' ORDER BY ' . $sort; + + $availableusers = $DB->get_records_sql($fields . $sql . $order, array_merge($params, $sortparams)); + + $cm = get_coursemodule_from_instance('pdfannotator', $this->pdfannotatorid); + $modinfo = get_fast_modinfo($cm->course); + $info = new \core_availability\info_module($modinfo->get_cm($cm->id)); + $availableusers = $info->filter_user_list($availableusers); + + if (empty($availableusers)) { + return array(); + } + + // Check to see if there are too many to show sensibly. + if (!$this->is_validating()) { + $potentialmemberscount = count($availableusers); + if ($potentialmemberscount > $this->maxusersperpage) { + return $this->too_many_results($search, $potentialmemberscount); + } + } + + if ($this->forcesubscribed) { + return array(get_string("existingsubscribers", 'pdfannotator') => $availableusers); + } else { + return array(get_string("potentialsubscribers", 'pdfannotator') => $availableusers); + } + } + + /** + * Sets the existing subscribers + * @param array $users + */ + public function set_existing_subscribers(array $users) { + $this->existingsubscribers = $users; + } + + /** + * Sets this pdfannotator as force subscribed or not + * @param bool $setting Whether the pdfannotator should be be force subscribed + */ + public function set_force_subscribed($setting=true) { + $this->forcesubscribed = true; + } +} diff --git a/classes/subscriber_selector_base.php b/classes/subscriber_selector_base.php new file mode 100644 index 0000000..bb78342 --- /dev/null +++ b/classes/subscriber_selector_base.php @@ -0,0 +1,87 @@ +. + +/** + * A type of pdfannotator. + * + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot.'/user/selector/lib.php'); + +/** + * Abstract class used by pdfannotator subscriber selection controls + * @package mod_pdfannotator + * @copyright 2021 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class mod_pdfannotator_subscriber_selector_base extends user_selector_base { + + /** + * The id of the pdfannotator this selector is being used for + * @var int + */ + protected $pdfannotatorid = null; + /** + * The context of the pdfannotator this selector is being used for + * @var object + */ + protected $context = null; + /** + * The id of the current group + * @var int + */ + protected $currentgroup = null; + + /** + * Constructor method + * @param string $name + * @param array $options + */ + public function __construct($name, $options) { + $options['accesscontext'] = $options['context']; + parent::__construct($name, $options); + if (isset($options['context'])) { + $this->context = $options['context']; + } + if (isset($options['currentgroup'])) { + $this->currentgroup = $options['currentgroup']; + } + if (isset($options['pdfannotatorid'])) { + $this->pdfannotatorid = $options['pdfannotatorid']; + } + } + + /** + * Returns an array of options to seralise and store for searches + * + * @return array + */ + protected function get_options() { + global $CFG; + $options = parent::get_options(); + $options['file'] = substr(__FILE__, strlen($CFG->dirroot.'/')); + $options['context'] = $this->context; + $options['currentgroup'] = $this->currentgroup; + $options['pdfannotatorid'] = $this->pdfannotatorid; + return $options; + } + +} 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..191110a 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 < 2021110100) { + + // 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, 2021110100, '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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. - -/** - * Version information for mod/pdfannotator - * - * @package mod_pdfannotator - * @copyright 2018 RWTH Aachen - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ - -defined('MOODLE_INTERNAL') || die(); - -$plugin->component = 'mod_pdfannotator'; -$plugin->version = 2021090100; -$plugin->release = 'PDF Annotator v1.4 release 9'; -$plugin->requires = 2021051700; -$plugin->maturity = MATURITY_STABLE; +. + +/** + * Version information for mod/pdfannotator + * + * @package mod_pdfannotator + * @copyright 2018 RWTH Aachen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'mod_pdfannotator'; +$plugin->version = 2021110100; +$plugin->release = 'PDF Annotator v1.4 release 9'; +$plugin->requires = 2021051700; +$plugin->maturity = MATURITY_STABLE;