From 218b6b1ff812548842ac5499d86a20b3003c6f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luca=20B=C3=B6sch?= Date: Wed, 12 Jun 2024 11:16:48 +0200 Subject: [PATCH] Configure subscriptions to discussions like in forum activity. --- .github/workflows/moodle-plugin-ci.yml | 94 +- .../moodle2/backup_pdfannotator_stepslib.php | 2 +- classes/output/comment.php | 29 +- classes/subscriptions.php | 885 ++++++++++++++++++ db/install.xml | 1 + db/upgrade.php | 14 + lang/en/pdfannotator.php | 12 + lib.php | 30 +- locallib.php | 41 +- mod_form.php | 13 + model/comment.class.php | 10 +- tests/behat/add_pdfannotator.feature | 35 + tests/behat/annotate_pdfannotator.feature | 110 +++ tests/behat/behat_mod_pdfannotator.php | 120 +++ tests/behat/behat_pdfannotator_editpdf.php | 59 ++ tests/fixtures/submission.pdf | Bin 0 -> 24751 bytes version.php | 2 +- 17 files changed, 1388 insertions(+), 69 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_mod_pdfannotator.php create mode 100644 tests/behat/behat_pdfannotator_editpdf.php create mode 100644 tests/fixtures/submission.pdf diff --git a/.github/workflows/moodle-plugin-ci.yml b/.github/workflows/moodle-plugin-ci.yml index bf554fa..a2bd105 100644 --- a/.github/workflows/moodle-plugin-ci.yml +++ b/.github/workflows/moodle-plugin-ci.yml @@ -8,7 +8,7 @@ jobs: services: postgres: - image: postgres + image: postgres:14 env: POSTGRES_USER: 'postgres' POSTGRES_HOST_AUTH_METHOD: 'trust' @@ -16,7 +16,7 @@ jobs: - 5432:5432 options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 3 mariadb: - image: mariadb + image: mariadb:10.6 env: MYSQL_USER: 'root' MYSQL_ALLOW_EMPTY_PASSWORD: "true" @@ -28,51 +28,86 @@ jobs: fail-fast: false matrix: include: - - php: 8.0 - moodle-branch: MOODLE_401_STABLE + - php: 8.3 + moodle-branch: MOODLE_404_STABLE database: pgsql - - php: 8.0 - moodle-branch: MOODLE_401_STABLE + - php: 8.3 + moodle-branch: MOODLE_404_STABLE database: mariadb - - php: 8.0 - moodle-branch: MOODLE_400_STABLE + - php: 8.2 + moodle-branch: MOODLE_404_STABLE database: pgsql - - php: 8.0 - moodle-branch: MOODLE_400_STABLE + - php: 8.2 + moodle-branch: MOODLE_404_STABLE + database: mariadb + - php: 8.2 + moodle-branch: MOODLE_403_STABLE + database: pgsql + - php: 8.2 + moodle-branch: MOODLE_403_STABLE + database: mariadb + - php: 8.2 + moodle-branch: MOODLE_402_STABLE + database: pgsql + - php: 8.2 + moodle-branch: MOODLE_402_STABLE database: mariadb - - php: 7.4 + - php: 8.1 + moodle-branch: MOODLE_404_STABLE + database: pgsql + - php: 8.1 + moodle-branch: MOODLE_404_STABLE + database: mariadb + - php: 8.1 + moodle-branch: MOODLE_403_STABLE + database: pgsql + - php: 8.1 + moodle-branch: MOODLE_403_STABLE + database: mariadb + - php: 8.1 + moodle-branch: MOODLE_402_STABLE + database: pgsql + - php: 8.1 + moodle-branch: MOODLE_402_STABLE + database: mariadb + - php: 8.1 moodle-branch: MOODLE_401_STABLE database: pgsql - - php: 7.4 + - php: 8.1 moodle-branch: MOODLE_401_STABLE database: mariadb - - php: 7.4 - moodle-branch: MOODLE_400_STABLE + - php: 8.0 + moodle-branch: MOODLE_403_STABLE database: pgsql - - php: 7.4 - moodle-branch: MOODLE_400_STABLE + - php: 8.0 + moodle-branch: MOODLE_403_STABLE database: mariadb - - - php: 7.4 - moodle-branch: MOODLE_311_STABLE + - php: 8.0 + moodle-branch: MOODLE_402_STABLE database: pgsql - - php: 7.4 - moodle-branch: MOODLE_311_STABLE + - php: 8.0 + moodle-branch: MOODLE_402_STABLE + database: mariadb + - php: 8.0 + moodle-branch: MOODLE_401_STABLE + database: pgsql + - php: 8.0 + moodle-branch: MOODLE_401_STABLE database: mariadb - - php: 7.3 - moodle-branch: MOODLE_311_STABLE + - php: 7.4 + moodle-branch: MOODLE_401_STABLE database: pgsql - - php: 7.3 - moodle-branch: MOODLE_311_STABLE + - php: 7.4 + moodle-branch: MOODLE_401_STABLE database: mariadb steps: - name: Check out repository code - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: path: plugin @@ -86,7 +121,7 @@ jobs: - name: Initialise moodle-plugin-ci run: | - composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^3 + composer create-project -n --no-dev --prefer-dist moodlehq/moodle-plugin-ci ci ^4 echo $(cd ci/bin; pwd) >> $GITHUB_PATH echo $(cd ci/vendor/bin; pwd) >> $GITHUB_PATH sudo locale-gen en_AU.UTF-8 @@ -106,11 +141,6 @@ jobs: if: ${{ always() }} run: moodle-plugin-ci phplint - - name: PHP Copy/Paste Detector - continue-on-error: true # This step will show errors but will not fail - if: ${{ always() }} - run: moodle-plugin-ci phpcpd - - name: PHP Mess Detector continue-on-error: true # This step will show errors but will not fail if: ${{ always() }} diff --git a/backup/moodle2/backup_pdfannotator_stepslib.php b/backup/moodle2/backup_pdfannotator_stepslib.php index bd0cdec..441022b 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 e37ca65..5161f85 100644 --- a/classes/output/comment.php +++ b/classes/output/comment.php @@ -83,7 +83,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); @@ -270,15 +270,26 @@ private function adddeletebutton($comment, $deleteown, $deleteany) { } } - 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')]; + } } } } diff --git a/classes/subscriptions.php b/classes/subscriptions.php new file mode 100644 index 0000000..20e7fde --- /dev/null +++ b/classes/subscriptions.php @@ -0,0 +1,885 @@ +. + +/** + * 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 102667d..e8dac02 100755 --- a/db/install.xml +++ b/db/install.xml @@ -18,6 +18,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index d831d7a..75e68a2 100755 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -644,5 +644,19 @@ function xmldb_pdfannotator_upgrade($oldversion) { upgrade_mod_savepoint(true, 2022110200, 'pdfannotator'); } + if ($oldversion < 2023112901) { + + // 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, 2023112901, 'pdfannotator'); + } return true; } diff --git a/lang/en/pdfannotator.php b/lang/en/pdfannotator.php index f8d65b5..bc7f915 100644 --- a/lang/en/pdfannotator.php +++ b/lang/en/pdfannotator.php @@ -453,6 +453,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 3a7d5fe..7f12ba2 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); + require_once($CFG->dirroot . '/mod/pdfannotator/locallib.php'); // Ugly hack to make 3.11 and 4.0 work seamlessly. @@ -68,14 +73,7 @@ function pdfannotator_supports($feature) { return null; } } -/** - * Function currently unused. - * - * @return string - */ -function mod_pdfannotator_before_standard_html_head() { -} /** * Returns all other caps used in module * @return array @@ -795,7 +793,7 @@ function mod_pdfannotator_output_fragment_open_edit_comment_editor($args) { $out .= html_writer::empty_tag('input', ['type' => 'hidden', 'class' => 'pdfannotator_' . $args['action'] . 'comment' . '_editoritemid', 'name' => 'input_value_editor', 'value' => $data['draftItemId']]); $out .= html_writer::empty_tag('input', ['type' => 'hidden', 'class' => 'pdfannotator_' . $args['action'] . 'comment' . '_editorformat', 'name' => 'input_value_editor', 'value' => $data['editorFormat']]); $out .= 'displaycontent:' . $displaycontent; - + return $out; } @@ -809,10 +807,24 @@ function mod_pdfannotator_output_fragment_open_add_comment_editor($args) { $data = pdfannotator_data_preprocessing($context, 'id_pdfannotator_content', 0); $text = file_prepare_draft_area($data['draftItemId'], $context->id, 'mod_pdfannotator', 'post', 0, pdfannotator_get_editor_options($context)); - + $out = ''; $out = html_writer::empty_tag('input', ['type' => 'hidden', 'class' => 'pdfannotator_' . $args['action'] . 'comment' . '_editoritemid', 'name' => 'input_value_editor', 'value' => $data['draftItemId']]); $out .= html_writer::empty_tag('input', ['type' => 'hidden', 'class' => 'pdfannotator_' . $args['action'] . 'comment' . '_editorformat', 'name' => 'input_value_editor', 'value' => $data['editorFormat']]); return $out; } +/** + * 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 = []; + $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 aba372e..d3306c6 100644 --- a/locallib.php +++ b/locallib.php @@ -90,7 +90,7 @@ function pdfannotator_display_embed($pdfannotator, $cm, $course, $file, $page = $capabilities->useprintcomments = has_capability('mod/pdfannotator:printcomments', $context); // 3. Comment editor setting. $editorsettings = new stdClass(); - $editorsettings->active_editor = get_class(editors_get_preferred_editor(FORMAT_HTML)); + $editorsettings->active_editor = explode(',', get_config('core', 'texteditors'))[0]; $params = [$cm, $documentobject, $context->id, $USER->id, $capabilities, $toolbarsettings, $page, $annoid, $commid, $editorsettings]; $PAGE->requires->js_init_call('adjustPdfannotatorNavbar', null, true); @@ -126,10 +126,10 @@ function pdfannotator_get_editor_options($context) { 'maxbytes' => get_config('mod_pdfannotator', 'maxbytes'), 'maxfiles' => PDFANNOTATOR_EDITOR_UNLIMITED_FILES, 'return_types' => 15, - 'enable_filemanagement' => true, - 'removeorphaneddrafts' => false, + 'enable_filemanagement' => true, + 'removeorphaneddrafts' => false, 'autosave' => false, - 'noclean' => false, + 'noclean' => false, 'trusttext' => 0, 'subdirs' => true, 'forcehttps' => false, @@ -191,8 +191,8 @@ function pdfannotator_split_content_image($content, $res, $itemid, $context=null $data = []; while (preg_match_all('/', $imgpos_start); $firststr = substr($content, 0, $imgpos_start); @@ -204,7 +204,7 @@ function pdfannotator_split_content_image($content, $res, $itemid, $context=null if (!$format) { throw new \moodle_exception('error:unsupportedextension', 'pdfannotator'); } - if (in_array('jpg', $format) || in_array('jpeg', $format) || in_array('jpe', $format) + if (in_array('jpg', $format) || in_array('jpeg', $format) || in_array('jpe', $format) || in_array('JPG', $format) || in_array('JPEG', $format) || in_array('JPE', $format)) { $format[0] = 'jpeg'; } @@ -241,7 +241,7 @@ function pdfannotator_split_content_image($content, $res, $itemid, $context=null } else { throw new Exception(get_string('error:findimage', 'pdfannotator', $encodedurl)); } - + preg_match('/height=[0-9]+/', $imgstr, $height); if ($height) { $data['imageheight'] = str_replace("\"", "", explode('=', $height[0])[1]); @@ -266,7 +266,7 @@ function pdfannotator_split_content_image($content, $res, $itemid, $context=null } finally { $res[] = $firststr; $res[] = $data; - $content = $laststr; + $content = $laststr; } } @@ -299,8 +299,8 @@ function pdfannotator_data_preprocessing($context, $textarea, $draftitemid = 0) $editor->use_editor($textarea, $options); } else { // initialize Filepicker if image button is active. - $args = new \stdClass(); - // need these three to filter repositories list. + $args = new \stdClass(); + // need these three to filter repositories list. $args->accepted_types = ['web_image']; $args->return_types = 15; $args->context = $context; @@ -324,7 +324,7 @@ function pdfannotator_data_preprocessing($context, $textarea, $draftitemid = 0) $editorformat = editors_get_preferred_format(FORMAT_HTML); //$PAGE->requires->js_init_call('inputDraftItemID', [$draftitemid, (int)$editorformat, $classname]); - + return ['draftItemId' => $draftitemid, 'editorFormat' => $editorformat]; } @@ -1963,7 +1963,7 @@ function pdfannotator_userspoststable_add_row($table, $post) { */ function pdfannotator_reportstable_add_row($thiscourse, $table, $report, $cmid, $itemsperpage, $reportfilter, $currentpage, $context) { global $CFG, $PAGE, $DB; - + $questionid = $DB->get_record('pdfannotator_comments', ['annotationid' => $report->annotationid, 'isquestion' => 1], 'id'); $report->report = pdfannotator_get_relativelink($report->report, $questionid, $context); $report->reportedcomment = pdfannotator_get_relativelink($report->reportedcomment, $report->commentid, $context); @@ -2117,4 +2117,17 @@ function pdfannotator_count_answers($annotationid, $context) { $count++; } return $count; -} \ No newline at end of file +} + +/** + * 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; +} diff --git a/mod_form.php b/mod_form.php index cf4f7fd..070dfef 100644 --- a/mod_form.php +++ b/mod_form.php @@ -119,6 +119,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 3b5e2c7..500c0a5 100644 --- a/model/comment.class.php +++ b/model/comment.class.php @@ -59,7 +59,7 @@ public static function create($documentid, $annotationid, $content, $visibility, // Create a new record in the table named 'comments' and return its id, which is created by autoincrement. $commentuuid = $DB->insert_record('pdfannotator_comments', $datarecord, true); $datarecord->id = $commentuuid; - + // Get the draftitemid and prepare the draft area. $draftitemid = required_param('pdfannotator_addcomment_editoritemid', PARAM_INT); $options = pdfannotator_get_editor_options($context); @@ -71,7 +71,7 @@ public static function create($documentid, $annotationid, $content, $visibility, $datarecord->uuid = $commentuuid; self::set_username($datarecord); - + $datarecord->displaycontent = pdfannotator_get_relativelink($datarecord->content, $datarecord->id, $context); $datarecord->displaycontent = format_text($datarecord->displaycontent, FORMAT_MOODLE, ['para' => false, 'filter' => true]); $datarecord->timecreated = pdfannotator_optional_timeago($datarecord->timecreated); @@ -108,7 +108,11 @@ public static function create($documentid, $annotationid, $content, $visibility, } } } else if ($visibility != 'private') { - self::insert_subscription($annotationid, $context); + 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/tests/behat/add_pdfannotator.feature b/tests/behat/add_pdfannotator.feature new file mode 100644 index 0000000..24f7ff4 --- /dev/null +++ b/tests/behat/add_pdfannotator.feature @@ -0,0 +1,35 @@ +@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 the following "user preferences" exist: + | user | preference | value | + | teacher1 | htmleditor | atto | + | student1 | htmleditor | atto | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a pdfannotator activity to course "Course 1" 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..d9573e7 --- /dev/null +++ b/tests/behat/annotate_pdfannotator.feature @@ -0,0 +1,110 @@ +@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 the following "user preferences" exist: + | user | preference | value | + | teacher1 | htmleditor | atto | + | student1 | htmleditor | atto | + And I log in as "teacher1" + And I am on "Course 1" course homepage with editing mode on + And I add a pdfannotator activity to course "Course 1" 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 am on the "Test PDF annotation" "mod_pdfannotator > View" page logged in as "student1" + 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 "//div[@id='id_pdfannotator_contenteditable']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I should see "This is a smurfing smurf" + And I click the pdfannotator public comment dropdown menu button + Then I should not see "Unsubscribe" + And I should see "Subscribe" + And I log out + + Scenario: Add a question to a pdfannotator with auto subscription + Given I am on the "Test PDF annotation" "mod_pdfannotator > Edit" page logged in as "teacher1" + And I set the following fields to these values: + | Subscription mode | Auto subscription | + And I press "Save" + And I log out + And I am on the "Test PDF annotation" "mod_pdfannotator > View" page logged in as "student1" + 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 "//div[@id='id_pdfannotator_contenteditable']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I should see "This is a smurfing smurf" + And I click the pdfannotator public comment dropdown menu button + Then I should not see "Subscribe" + And I should see "Unsubscribe" + And I log out + + Scenario: Add a question to a pdfannotator with subscription disabled + Given I am on the "Test PDF annotation" "mod_pdfannotator > Edit" page logged in as "teacher1" + 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 "//div[@id='id_pdfannotator_contenteditable']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I should see "This is a smurfing smurf" + And I click the pdfannotator public comment dropdown menu button + Then I should not see "Subscribe" + And I should not see "Unsubscribe" + And I log out + + Scenario: Add a question to a pdfannotator with forced subscription + Given I am on the "Test PDF annotation" "mod_pdfannotator > Edit" page logged in as "teacher1" + 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 "//div[@id='id_pdfannotator_contenteditable']" to "This is a smurfing smurf" + And I click on "Create Annotation" "button" + And I wait until the page is ready + And I should see "This is a smurfing smurf" + And I click the pdfannotator public comment dropdown menu button + Then I should not see "Subscribe" + And I should not see "Unsubscribe" + And I log out diff --git a/tests/behat/behat_mod_pdfannotator.php b/tests/behat/behat_mod_pdfannotator.php new file mode 100644 index 0000000..cebb6b8 --- /dev/null +++ b/tests/behat/behat_mod_pdfannotator.php @@ -0,0 +1,120 @@ +. + +/** + * Steps definitions related to mod_pdfannotator. + * + * @package mod_pdfannotator + * @category test + * @copyright 2019 HSR (http://www.hsr.ch) + * @author 2019 Huong Nguyen + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); + +use mod_pdfannotator\utils; +use Behat\Mink\Exception\ExpectationException as ExpectationException; +use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException; +use Behat\Gherkin\Node\TableNode as TableNode; + +/** + * Steps definitions related to mod_pdfannotator. + * + * @package pdfannotator + * @category test + * @copyright 2024 Luca Bösch + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_mod_pdfannotator extends behat_base { + + /** + * Convert page names to URLs for steps like 'When I am on the "[page name]" page'. + * + * Recognised page names are: + * | None so far! | | + * + * @param string $page name of the page, with the component name removed e.g. 'Admin notification'. + * @return moodle_url the corresponding URL. + * @throws Exception with a meaningful error message if the specified page cannot be found. + */ + protected function resolve_page_url(string $page): moodle_url { + switch ($page) { + default: + throw new Exception('Unrecognised pdfannotator page type "' . $page . '."'); + } + } + + /** + * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'. + * + * Recognised page names are: + * | pagetype | name meaning | description | + * | View | Student Quiz name | The student quiz info page (view.php) | + * | Edit | Student Quiz name | The edit quiz page (edit.php) | + * | Statistics | Student Quiz name | The Statistics report page | + * | Ranking | Student Quiz name | The Ranking page | + * + * @param string $type identifies which type of page this is, e.g. 'View'. + * @param string $identifier identifies the particular page, e.g. 'Test student quiz'. + * @return moodle_url the corresponding URL. + * @throws Exception with a meaningful error message if the specified page cannot be found. + */ + protected function resolve_page_instance_url(string $type, string $identifier): moodle_url { + switch ($type) { + case 'View': + return new moodle_url('/mod/pdfannotator/view.php', + ['id' => $this->get_cm_by_pdfannotator_name($identifier)->id]); + + case 'Edit': + return new moodle_url('/course/modedit.php', + ['update' => $this->get_cm_by_pdfannotator_name($identifier)->id]); + + case 'Statistics': + return new moodle_url('/mod/pdfannotator/reportstat.php', + ['id' => $this->get_cm_by_pdfannotator_name($identifier)->id]); + + case 'Ranking': + return new moodle_url('/mod/pdfannotator/reportrank.php', + ['id' => $this->get_cm_by_pdfannotator_name($identifier)->id]); + + default: + throw new Exception('Unrecognised pdfannotator page type "' . $type . '."'); + } + } + + /** + * Get a pdfannotator by name. + * + * @param string $name pdfannotator name. + * @return stdClass the corresponding DB row. + */ + protected function get_pdfannotator_by_name(string $name): stdClass { + global $DB; + return $DB->get_record('pdfannotator', array('name' => $name), '*', MUST_EXIST); + } + + /** + * Get cmid from the pdfannotator name. + * + * @param string $name pdfannotator name. + * @return stdClass cm from get_coursemodule_from_instance. + */ + protected function get_cm_by_pdfannotator_name(string $name): stdClass { + $pdfannotator = $this->get_pdfannotator_by_name($name); + return get_coursemodule_from_instance('pdfannotator', $pdfannotator->id, $pdfannotator->course); + } +} 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 = 2023112900; +$plugin->version = 2023112901; $plugin->release = 'PDF Annotator v1.5 release 5'; $plugin->requires = 2021051700; $plugin->maturity = MATURITY_STABLE;