diff --git a/mod/bigbluebuttonbn/bbb_broker.php b/mod/bigbluebuttonbn/bbb_broker.php index 550d696b420..6d6cab08242 100644 --- a/mod/bigbluebuttonbn/bbb_broker.php +++ b/mod/bigbluebuttonbn/bbb_broker.php @@ -28,8 +28,10 @@ // phpcs:disable moodle.Files.MoodleInternal.MoodleInternalGlobalState,moodle.Files.RequireLogin.Missing require(__DIR__ . '/../../config.php'); +use Firebase\JWT\Key; use mod_bigbluebuttonbn\broker; use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; use mod_bigbluebuttonbn\meeting; global $PAGE, $USER, $CFG, $SESSION, $DB; @@ -56,12 +58,12 @@ $PAGE->set_context($instance->get_context()); try { switch (strtolower($action)) { case 'recording_ready': - broker::recording_ready($instance, $params); + broker::process_recording_ready($instance, $params); return; case 'meeting_events': // When meeting_events callback is implemented by BigBlueButton, Moodle receives a POST request // which is processed in the function using super globals. - meeting::meeting_events($instance); + broker::process_meeting_events($instance); return; } header("HTTP/1.0 400 Bad request. The action '{$action}' does not exist"); diff --git a/mod/bigbluebuttonbn/classes/broker.php b/mod/bigbluebuttonbn/classes/broker.php index 4f17e90bc63..3c48634df31 100644 --- a/mod/bigbluebuttonbn/classes/broker.php +++ b/mod/bigbluebuttonbn/classes/broker.php @@ -84,7 +84,7 @@ class broker { * @param instance $instance * @param array $params */ - public static function recording_ready(instance $instance, array $params): void { + public static function process_recording_ready(instance $instance, array $params): void { // Decodes the received JWT string. try { $decodedparameters = JWT::decode( @@ -127,4 +127,67 @@ class broker { header('HTTP/1.0 503 Service Unavailable. ' . $error); } } + + /** + * Process meeting events for instance with provided HTTP headers. + * + * @param instance $instance + * @return void + */ + public static function process_meeting_events(instance $instance) { + try { + // Get the HTTP headers. + $authorization = self::get_authorization_token(); + + // Pull the Bearer from the headers. + if (empty($authorization)) { + $msg = 'Authorization failed'; + header('HTTP/1.0 400 Bad Request. ' . $msg); + return; + } + // Verify the authenticity of the request. + $token = \Firebase\JWT\JWT::decode( + $authorization[1], + new Key(config::get('shared_secret'), 'HS512') + ); + + // Get JSON string from the body. + $jsonstr = file_get_contents('php://input'); + + // Convert JSON string to a JSON object. + $jsonobj = json_decode($jsonstr); + $headermsg = meeting::meeting_events($instance, $jsonobj); + header($headermsg); + } catch (Exception $e) { + $msg = 'Caught exception: ' . $e->getMessage(); + header('HTTP/1.0 400 Bad Request. ' . $msg); + } + } + + + /** + * Get authorisation token + * + * We could use getallheaders but this is only compatible with apache types of servers + * some explanations and examples here: https://www.php.net/manual/en/function.getallheaders.php#127190 + * + * @return array|null an array composed of the Authorization token provided in the header. + */ + private static function get_authorization_token(): ?array { + $autorization = null; + if (isset($_SERVER['Authorization'])) { + $autorization = trim($_SERVER["Authorization"]); + } else if (isset($_SERVER['HTTP_AUTHORIZATION'])) { + $autorization = trim($_SERVER["HTTP_AUTHORIZATION"]); + } else if (function_exists('apache_request_headers')) { + $requestheaders = apache_request_headers(); + $requestheaders = array_combine(array_map('ucwords', + array_keys($requestheaders)), array_values($requestheaders)); + + if (isset($requestheaders['Authorization'])) { + $autorization = trim($requestheaders['Authorization']); + } + } + return empty($autorization) ? null : explode(" ", $autorization); + } } diff --git a/mod/bigbluebuttonbn/classes/completion/custom_completion.php b/mod/bigbluebuttonbn/classes/completion/custom_completion.php index 00ab5cdbe42..6f2518eb1be 100644 --- a/mod/bigbluebuttonbn/classes/completion/custom_completion.php +++ b/mod/bigbluebuttonbn/classes/completion/custom_completion.php @@ -32,6 +32,18 @@ use stdClass; */ class custom_completion extends activity_custom_completion { + /** + * Filters for logs + */ + const FILTERS = [ + 'completionattendance' => [logger::EVENT_SUMMARY], + 'completionengagementchats' => [logger::EVENT_SUMMARY], + 'completionengagementtalks' => [logger::EVENT_SUMMARY], + 'completionengagementraisehand' => [logger::EVENT_SUMMARY], + 'completionengagementpollvotes' => [logger::EVENT_SUMMARY], + 'completionengagementemojis' => [logger::EVENT_SUMMARY], + ]; + /** * Get current state * @@ -47,23 +59,27 @@ class custom_completion extends activity_custom_completion { } // Default return value. - $value = COMPLETION_INCOMPLETE; - $logs = logger::get_user_completion_logs($instance, $this->userid, [logger::EVENT_SUMMARY]); + $returnedvalue = COMPLETION_INCOMPLETE; + $filters = self::FILTERS[$rule] ?? [logger::EVENT_SUMMARY]; + $logs = logger::get_user_completion_logs($instance, $this->userid, $filters); if (method_exists($this, "get_{$rule}_value")) { - $valuecount = $this->count_actions($logs, self::class . "::get_{$rule}_value"); - if ($valuecount) { - if (!is_null($instance->get_instance_var($rule))) { - if ($instance->get_instance_var($rule) <= $valuecount) { - $value = COMPLETION_COMPLETE; + $completionvalue = $this->aggregate_values($logs, self::class . "::get_{$rule}_value"); + if ($completionvalue) { + // So in this case we check the value set in the module setting. If we go over the threshold, then + // this is complete. + $rulevalue = $instance->get_instance_var($rule); + if (!is_null($rulevalue)) { + if ($rulevalue <= $completionvalue) { + $returnedvalue = COMPLETION_COMPLETE; } } else { // If there is at least a hit, we consider it as complete. - $value = $valuecount ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; + $returnedvalue = $completionvalue ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE; } } } - return $value; + return $returnedvalue; } /** @@ -71,20 +87,20 @@ class custom_completion extends activity_custom_completion { * * @param array $logs * @param callable $logvaluegetter - * @return int the number of hits on this particular rule + * @return int the sum of all values for this particular event (it can be a duration or a number of hits) */ - protected function count_actions(array $logs, callable $logvaluegetter): int { + protected function aggregate_values(array $logs, callable $logvaluegetter): int { if (empty($logs)) { // As completion by engagement with $rulename hand was required, the activity hasn't been completed. return 0; } - $valuecount = 0; + $value = 0; foreach ($logs as $log) { - $valuecount += $logvaluegetter($log); + $value += $logvaluegetter($log); } - return $valuecount; + return $value; } /** @@ -149,34 +165,47 @@ class custom_completion extends activity_custom_completion { } /** - * Get current state in a friendly version + * Get current states of completion in a human-friendly version + * + * @return string[] + */ + public function get_printable_states(): array { + $result = []; + foreach ($this->get_available_custom_rules() as $rule) { + $result[] = $this->get_printable_state($rule); + } + return $result; + } + + /** + * Get current states of completion for a rule in a human-friendly version * * @param string $rule * @return string */ - public function get_printable_state(string $rule): string { + private function get_printable_state(string $rule): string { // Get instance details. $instance = instance::get_from_cmid($this->cm->id); if (empty($instance)) { throw new moodle_exception("Can't find bigbluebuttonbn instance {$this->cm->instance}"); } - $summary = ""; - $logs = logger::get_user_completion_logs($instance, $this->userid, [logger::EVENT_SUMMARY]); + $filters = self::FILTERS[$rule] ?? [logger::EVENT_SUMMARY]; + $logs = logger::get_user_completion_logs($instance, $this->userid, $filters); if (method_exists($this, "get_{$rule}_value")) { $summary = get_string( $rule . '_event_desc', 'mod_bigbluebuttonbn', - $this->count_actions($logs, self::class . "::get_{$rule}_value") + $this->aggregate_values($logs, self::class . "::get_{$rule}_value") ); } return $summary; } /** - * Get current state in a friendly version + * Get current state in a friendly version * * @param string $rule * @return string @@ -188,7 +217,7 @@ class custom_completion extends activity_custom_completion { if (empty($instance)) { throw new moodle_exception("Can't find bigbluebuttonbn instance {$this->cm->instance}"); } - $filters = $rule != "completionview" ? [logger::EVENT_SUMMARY] : [logger::EVENT_JOIN, logger::EVENT_PLAYED]; + $filters = self::FILTERS[$rule] ?? [logger::EVENT_SUMMARY]; return logger::get_user_completion_logs_max_timestamp($instance, $this->userid, $filters); } @@ -200,10 +229,7 @@ class custom_completion extends activity_custom_completion { */ protected static function get_completionattendance_value(stdClass $log): int { $summary = json_decode($log->meta); - if ($summary && !empty($summary->data->duration)) { - return COMPLETION_COMPLETE; - } - return COMPLETION_INCOMPLETE; + return empty($summary->data->duration) ? 0 : $summary->data->duration / 60; } /** @@ -243,7 +269,7 @@ class custom_completion extends activity_custom_completion { * @return int */ protected static function get_completionengagementpollvotes_value(stdClass $log): int { - return self::get_completionengagement_value($log, 'pollvotes'); + return self::get_completionengagement_value($log, 'poll_votes'); } /** @@ -265,9 +291,6 @@ class custom_completion extends activity_custom_completion { */ protected static function get_completionengagement_value(stdClass $log, string $type): int { $summary = json_decode($log->meta); - if ($summary && !empty($summary->data->engagement->$type)) { - return COMPLETION_COMPLETE; - } - return COMPLETION_INCOMPLETE; + return intval($summary->data->engagement->$type ?? 0); } } diff --git a/mod/bigbluebuttonbn/classes/local/proxy/bigbluebutton_proxy.php b/mod/bigbluebuttonbn/classes/local/proxy/bigbluebutton_proxy.php index fafb25c94d9..9249350ff42 100644 --- a/mod/bigbluebuttonbn/classes/local/proxy/bigbluebutton_proxy.php +++ b/mod/bigbluebuttonbn/classes/local/proxy/bigbluebutton_proxy.php @@ -242,10 +242,12 @@ class bigbluebutton_proxy extends proxy_base { $bbbcompletion = new custom_completion($cm, $userid); if ($bbbcompletion->get_overall_completion_state()) { - mtrace("Completion succeeded for user $userid"); + mtrace("Completion for userid $userid and bigbluebuttonid {$bigbluebuttonbn->id} updated."); $completion->update_state($cm, COMPLETION_COMPLETE, $userid, true); } else { - mtrace("Completion did not succeed for user $userid"); + // Still update state to current value (prevent unwanted caching). + $completion->update_state($cm, COMPLETION_UNKNOWN, $userid); + mtrace("Activity not completed for userid $userid and bigbluebuttonid {$bigbluebuttonbn->id}."); } } diff --git a/mod/bigbluebuttonbn/classes/logger.php b/mod/bigbluebuttonbn/classes/logger.php index e0122602e29..213040a5c71 100644 --- a/mod/bigbluebuttonbn/classes/logger.php +++ b/mod/bigbluebuttonbn/classes/logger.php @@ -157,7 +157,7 @@ EOF; [$wheresql, $params] = static::get_user_completion_sql_params($instance, $userid, $filters, $timestart); $select = "SELECT MAX(timecreated) "; $lastlogtime = $DB->get_field_sql($select . ' FROM {bigbluebuttonbn_logs} WHERE ' . $wheresql, $params); - return $lastlogtime; + return $lastlogtime ?? 0; } /** @@ -257,7 +257,7 @@ EOF; json_encode($meta) ); - return self::count_callback_events($meta['recordid'], 'meeting_events'); + return self::count_callback_events($meta['internalmeetingid'], 'meeting_events'); } /** @@ -441,28 +441,36 @@ EOF; /** * Helper function to count the number of callback logs matching the supplied specifications. * - * @param string $recordid + * @param string $id * @param string $callbacktype * @return int */ - protected static function count_callback_events(string $recordid, string $callbacktype = 'recording_ready'): int { + protected static function count_callback_events(string $id, string $callbacktype = 'recording_ready'): int { global $DB; - $sql = 'SELECT count(DISTINCT id) FROM {bigbluebuttonbn_logs} WHERE log = ? AND meta LIKE ? AND meta LIKE ?'; - // Callback type added on version 2.4, validate recording_ready first or assume it on records with no callback. - if ($callbacktype == 'recording_ready') { - $sql .= ' AND (meta LIKE ? OR meta NOT LIKE ? )'; - $count = - $DB->count_records_sql($sql, [ - self::EVENT_CALLBACK, '%recordid%', - "%$recordid%", - $callbacktype, 'callback' - ]); - return $count; + // Look for a log record that is of "Callback" type and is related to the given event. + $conditions = [ + "log = :logtype", + $DB->sql_like('meta', ':cbtypelike') + ]; + + $params = [ + 'logtype' => self::EVENT_CALLBACK, + 'cbtypelike' => "%meeting_events%" // All callbacks are meeting events, even recording events. + ]; + + $basesql = 'SELECT COUNT(DISTINCT id) FROM {bigbluebuttonbn_logs}'; + switch ($callbacktype) { + case 'recording_ready': + $conditions[] = $DB->sql_like('meta', ':isrecordid'); + $params['isrecordid'] = '%recordid%'; // The recordid field in the meta field (json encoded). + break; + case 'meeting_events': + $conditions[] = $DB->sql_like('meta', ':idlike'); + $params['idlike'] = "%$id%"; // The unique id of the meeting is the meta field (json encoded). + break; } - $sql .= ' AND meta LIKE ?;'; - $count = $DB->count_records_sql($sql, - [self::EVENT_CALLBACK, '%recordid%', "%$recordid%", "%$callbacktype%"]); - return $count; + $wheresql = join(' AND ', $conditions); + return $DB->count_records_sql($basesql . ' WHERE ' . $wheresql, $params); } /** diff --git a/mod/bigbluebuttonbn/classes/meeting.php b/mod/bigbluebuttonbn/classes/meeting.php index b52ce970383..fe6360d8a29 100644 --- a/mod/bigbluebuttonbn/classes/meeting.php +++ b/mod/bigbluebuttonbn/classes/meeting.php @@ -454,61 +454,31 @@ class meeting { * - Body: * * @param instance $instance - * @return void + * @param object $data + * @return string */ - public static function meeting_events(instance $instance) { + public static function meeting_events(instance $instance, object $data): string { $bigbluebuttonbn = $instance->get_instance_data(); - // Decodes the received JWT string. - try { - // Get the HTTP headers (getallheaders is a PHP function that may only work with Apache). - $headers = getallheaders(); - - // Pull the Bearer from the headers. - if (!array_key_exists('Authorization', $headers)) { - $msg = 'Authorization failed'; - header('HTTP/1.0 400 Bad Request. ' . $msg); - return; - } - $authorization = explode(" ", $headers['Authorization']); - - // Verify the authenticity of the request. - $token = \Firebase\JWT\JWT::decode( - $authorization[1], - new Key(config::get('shared_secret'), 'HS512') - ); - - // Get JSON string from the body. - $jsonstr = file_get_contents('php://input'); - - // Convert JSON string to a JSON object. - $jsonobj = json_decode($jsonstr); - } catch (Exception $e) { - $msg = 'Caught exception: ' . $e->getMessage(); - header('HTTP/1.0 400 Bad Request. ' . $msg); - return; - } - // Validate that the bigbluebuttonbn activity corresponds to the meeting_id received. - $meetingidelements = explode('[', $jsonobj->{'meeting_id'}); + $meetingidelements = explode('[', $data->{'meeting_id'}); $meetingidelements = explode('-', $meetingidelements[0]); if (!isset($bigbluebuttonbn) || $bigbluebuttonbn->meetingid != $meetingidelements[0]) { - $msg = 'The activity may have been deleted'; - header('HTTP/1.0 410 Gone. ' . $msg); - return; + return 'HTTP/1.0 410 Gone. The activity may have been deleted'; } // We make sure events are processed only once. - $overrides = ['meetingid' => $jsonobj->{'meeting_id'}]; - $meta['recordid'] = $jsonobj->{'internal_meeting_id'}; + $overrides = ['meetingid' => $data->{'meeting_id'}]; + $meta['internalmeetingid'] = $data->{'internal_meeting_id'}; $meta['callback'] = 'meeting_events'; + $meta['meetingid'] = $data->{'meeting_id'}; $eventcount = logger::log_event_callback($instance, $overrides, $meta); if ($eventcount === 1) { // Process the events. - self::process_meeting_events($instance, $jsonobj); - header('HTTP/1.0 200 Accepted. Enqueued.'); + self::process_meeting_events($instance, $data); + return 'HTTP/1.0 200 Accepted. Enqueued.'; } else { - header('HTTP/1.0 202 Accepted. Already processed.'); + return 'HTTP/1.0 202 Accepted. Already processed.'; } } diff --git a/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php b/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php index 02cf6e4033e..43cf527c4bb 100644 --- a/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php +++ b/mod/bigbluebuttonbn/lang/en/bigbluebuttonbn.php @@ -92,28 +92,28 @@ $string['privacy:metadata:bigbluebuttonbn_recordings'] = 'Stores metadata about $string['privacy:metadata:bigbluebuttonbn_recordings:userid'] = 'The user ID of the user who last changed a recording.'; $string['completionattendance'] = 'Student must attend the session for:'; -$string['completionattendance_desc'] = 'Student must enter the room and remain in the session for at least {$a} minute(s)'; +$string['completionattendance_desc'] = 'Enter and remain in the room for at least {$a} minute(s).'; $string['completionattendance_event_desc'] = 'Student has entered the room and remained in the session for at least {$a} minute(s)'; $string['completionattendancegroup'] = 'Require attendance'; $string['completionattendancegroup_help'] = 'Attending the meeting for (n) minutes is required for completion.'; $string['completionengagementchats'] = 'Chats'; -$string['completionengagementchats_desc'] = 'Student must participate in {$a} chat(s) to complete it.'; +$string['completionengagementchats_desc'] = 'Participate in {$a} chat(s).'; $string['completionengagementchats_event_desc'] = 'Has raised {$a} chat(s)'; $string['completionengagementtalks'] = 'Talk'; -$string['completionengagementtalks_desc'] = 'Student must talk {$a} time(s) to complete it'; +$string['completionengagementtalks_desc'] = 'Talk {$a} time(s)'; $string['completionengagementtalks_event_desc'] = 'Has raised {$a} talk(s)'; $string['completionengagementraisehand'] = 'Require raised hand'; -$string['completionengagementraisehand_desc'] = 'Student must raise hand {$a} time(s) to complete it.'; +$string['completionengagementraisehand_desc'] = 'Raise hand {$a} time(s).'; $string['completionengagementraisehand_event_desc'] = 'Has raised hand {$a} times'; $string['completionengagementpollvotes'] = 'Poll votes'; -$string['completionengagementpollvotes_desc'] = 'Student must vote in polls {$a} time(s) to complete it.'; -$string['completionengagementpollvotes_event_desc'] = 'Has raised {$a} poll vote(s)'; +$string['completionengagementpollvotes_desc'] = 'Vote in polls {$a} time(s).'; +$string['completionengagementpollvotes_event_desc'] = 'Has answered {$a} poll vote(s)'; $string['completionengagementemojis'] = 'Emojis'; -$string['completionengagementemojis_desc'] = 'Student must send {$a} emoji(s) into polls to complete it.'; -$string['completionengagementemojis_event_desc'] = 'Has raised {$a} emoji(s)'; +$string['completionengagementemojis_desc'] = 'Change {$a} times his/her emoji(s).'; +$string['completionengagementemojis_event_desc'] = 'Changed {$a} time his/her emoji(s)'; -$string['completionengagement_desc'] = 'Student must engage in activities during the meeting'; +$string['completionengagement_desc'] = 'Engage in activities during the meeting.'; $string['completionengagementgroup'] = 'Require participation'; $string['completionengagementgroup_help'] = 'Active participation during the session is required for completion.'; diff --git a/mod/bigbluebuttonbn/lib.php b/mod/bigbluebuttonbn/lib.php index cdd761395dd..53404c4490c 100644 --- a/mod/bigbluebuttonbn/lib.php +++ b/mod/bigbluebuttonbn/lib.php @@ -337,7 +337,14 @@ function bigbluebuttonbn_get_coursemodule_info($coursemodule) { global $DB; $dbparams = ['id' => $coursemodule->instance]; - $fields = 'id, name, intro, introformat, completionattendance'; + $customcompletionfields = custom_completion::get_defined_custom_rules(); + $fieldsarray = array_merge([ + 'id', + 'name', + 'intro', + 'introformat', + ], $customcompletionfields); + $fields = join(',', $fieldsarray); $bigbluebuttonbn = $DB->get_record('bigbluebuttonbn', $dbparams, $fields); if (!$bigbluebuttonbn) { return null; @@ -350,7 +357,10 @@ function bigbluebuttonbn_get_coursemodule_info($coursemodule) { } // Populate the custom completion rules as key => value pairs, but only if the completion mode is 'automatic'. if ($coursemodule->completion == COMPLETION_TRACKING_AUTOMATIC) { - $info->customdata['customcompletionrules']['completionattendance'] = $bigbluebuttonbn->completionattendance; + foreach ($customcompletionfields as $completiontype) { + $info->customdata['customcompletionrules'][$completiontype] = + $bigbluebuttonbn->$completiontype ?? 0; + } } return $info; diff --git a/mod/bigbluebuttonbn/mod_form.php b/mod/bigbluebuttonbn/mod_form.php index c9a26bea738..bac820a94d2 100644 --- a/mod/bigbluebuttonbn/mod_form.php +++ b/mod/bigbluebuttonbn/mod_form.php @@ -212,7 +212,7 @@ class mod_bigbluebuttonbn_mod_form extends moodleform_mod { $mform->setType('completionattendance', PARAM_INT); $mform->addGroup($attendance['group'], 'completionattendancegroup', $attendance['grouplabel'], [' '], false); $mform->addHelpButton('completionattendancegroup', 'completionattendancegroup', 'bigbluebuttonbn'); - $mform->disabledIf('completionattendancegroup', 'completionview', 'notchecked'); + $mform->disabledIf('completionattendancegroup', 'completion', 'neq', COMPLETION_AGGREGATION_ANY); $mform->disabledIf('completionattendance', 'completionattendanceenabled', 'notchecked'); // Elements for completion by Engagement. @@ -236,7 +236,7 @@ class mod_bigbluebuttonbn_mod_form extends moodleform_mod { ] ]); $mform->addHelpButton('completionengagementgroup', 'completionengagementgroup', 'bigbluebuttonbn'); - $mform->disabledIf('completionengagementgroup', 'completionview', 'notchecked'); + $mform->disabledIf('completionengagementgroup', 'completion', 'neq', COMPLETION_AGGREGATION_ANY); return ['completionattendancegroup', 'completionengagementgroup']; } diff --git a/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php b/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php index 043adea7733..7c3994409ff 100644 --- a/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php +++ b/mod/bigbluebuttonbn/tests/behat/behat_mod_bigbluebuttonbn.php @@ -26,6 +26,7 @@ require_once(__DIR__ . '/../../../../lib/behat/behat_base.php'); use Behat\Behat\Hook\Scope\BeforeScenarioScope; +use Behat\Gherkin\Node\TableNode; use Moodle\BehatExtension\Exception\SkippedException; /** @@ -171,10 +172,56 @@ XPATH * @Given the BigBlueButtonBN server has sent recording ready notifications */ public function trigger_recording_ready_notification(): void { - $this->send_mock_request('backoffice/sendNotifications', [ + $this->send_mock_request('backoffice/sendRecordingReadyNotifications', [ 'secret' => \mod_bigbluebuttonbn\local\config::DEFAULT_SHARED_SECRET, ] ); } + /** + * Trigger a meeting event on BBB side + * + * @Given /^the BigBlueButtonBN server has received the following events from user "(?P(?:[^"]|\\")*)":$/ + * @param string $username + * @param TableNode $data + */ + public function trigger_meeting_event(string $username, TableNode $data): void { + global $DB; + $user = core_user::get_user_by_username($username); + $rows = $data->getHash(); + foreach ($rows as $elementdata) { + $instanceid = $DB->get_field('bigbluebuttonbn', 'id', [ + 'name' => $elementdata['instancename'], + ]); + $instance = \mod_bigbluebuttonbn\instance::get_from_instanceid($instanceid); + $this->send_mock_request('backoffice/addMeetingEvent', [ + 'secret' => \mod_bigbluebuttonbn\local\config::DEFAULT_SHARED_SECRET, + 'meetingID' => $instance->get_meeting_id(), + 'attendeeID' => $user->id, + 'attendeeName' => fullname($user), + 'eventType' => $elementdata['eventtype'], + 'eventData' => $elementdata['eventdata'] ?? '', + ] + ); + } + } + /** + * Send all events received for this meeting back to moodle + * + * @Given /^the BigBlueButtonBN activity "(?P(?:[^"]|\\")*)" has sent recording all its events$/ + * @param string $instancename + */ + public function trigger_all_events(string $instancename): void { + global $DB; + + $instanceid = $DB->get_field('bigbluebuttonbn', 'id', [ + 'name' => $instancename, + ]); + $instance = \mod_bigbluebuttonbn\instance::get_from_instanceid($instanceid); + $this->send_mock_request('backoffice/sendAllEvents', [ + 'meetingID' => $instance->get_meeting_id(), + 'sendQuery' => true + ] + ); + } } diff --git a/mod/bigbluebuttonbn/tests/behat/completion.feature b/mod/bigbluebuttonbn/tests/behat/completion.feature new file mode 100644 index 00000000000..72ed08089ae --- /dev/null +++ b/mod/bigbluebuttonbn/tests/behat/completion.feature @@ -0,0 +1,63 @@ +@mod @mod_bigbluebuttonbn +Feature: As a user I can complete a BigblueButtonBN activity by usual or custom criteria + + Background: Make sure that a course is created + Given a BigBlueButton mock server is configured + And I enable "bigbluebuttonbn" "mod" plugin + And the following "courses" exist: + | fullname | shortname | category | enablecompletion | + | Test course | C1 | 0 | 1 | + And the following "activities" exist: + | activity | name | intro | course | idnumber | type | recordings_imported | + | bigbluebuttonbn | RoomRecordings | Test Room Recording description | C1 | bigbluebuttonbn1 | 0 | 0 | + And the following "users" exist: + | username | firstname | lastname | email | + | traverst | Terry | Travers | t.travers@example.com | + And the following "course enrolments" exist: + | user | course | role | + | traverst | C1 | student | + + Scenario: I set the completion to standard type of completion. + Given I am on the "RoomRecordings" "bigbluebuttonbn activity" page logged in as admin + And I click on "Settings" "link" + And I expand all fieldsets + And I set the following fields to these values: + | Completion tracking | Show activity as complete when conditions are met | + | Require view | 1 | + And I press "Save and display" + And I log out + Given I am on the "RoomRecordings" "bigbluebuttonbn activity" page logged in as traverst + Then I should see "Done: View" + + @javascript + Scenario: I set the completion type to custom completion + Given the following config values are set as admin: + | bigbluebuttonbn_meetingevents_enabled | 1 | + And I am on the "RoomRecordings" "bigbluebuttonbn activity" page logged in as admin + And I click on "Settings" "link" + And I expand all fieldsets + And I set the following fields to these values: + | Completion tracking | Show activity as complete when conditions are met | + | Chats | 1 | + And I press "Save and display" + # We start the meeting here so to make sure that meta_analytics-callback-url is set. + And the following "mod_bigbluebuttonbn > meeting" exists: + | activity | RoomRecordings | + And I log out + Given I am on the "RoomRecordings" "bigbluebuttonbn activity" page logged in as traverst + When I click on "Join session" "link" + And I switch to "bigbluebutton_conference" window + And I wait until the page is ready + Then I follow "End Meeting" + And the BigBlueButtonBN server has received the following events from user "traverst": + | instancename | eventtype | eventdata | + | RoomRecordings | chats | 1 | + # Selenium driver does not like the click action to be done before we + # automatically close the window so we need to make sure that the window + # is closed before. + And I close all opened windows + And I switch to the main window + Given the BigBlueButtonBN activity "RoomRecordings" has sent recording all its events + And I run all adhoc tasks + And I reload the page + Then I should see "Done: Participate in 1 chat(s)" diff --git a/mod/bigbluebuttonbn/tests/completion_test.php b/mod/bigbluebuttonbn/tests/completion/completion_test.php similarity index 64% rename from mod/bigbluebuttonbn/tests/completion_test.php rename to mod/bigbluebuttonbn/tests/completion/completion_test.php index 7083a5b40e7..4870447881e 100644 --- a/mod/bigbluebuttonbn/tests/completion_test.php +++ b/mod/bigbluebuttonbn/tests/completion/completion_test.php @@ -14,11 +14,14 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace mod_bigbluebuttonbn; +namespace mod_bigbluebuttonbn\completion; use completion_info; use context_module; -use mod_bigbluebuttonbn\completion\custom_completion; +use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; +use mod_bigbluebuttonbn\logger; +use mod_bigbluebuttonbn\meeting; use mod_bigbluebuttonbn\test\testcase_helper_trait; /** @@ -38,11 +41,12 @@ class completion_test extends \advanced_testcase { */ public function setUp(): void { parent::setUp(); + $this->initialise_mock_server(); set_config('enablecompletion', true); // Enable completion for all tests. } /** - * Completion with no rules + * Completion with no rules: the completion is completed as soons as we view the course. */ public function test_get_completion_state_no_rules() { $this->resetAfterTest(); @@ -103,7 +107,15 @@ class completion_test extends \advanced_testcase { public function test_get_completion_state_complete() { $this->resetAfterTest(); - list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance(); + list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance( + $this->get_course(), + [ + 'completion' => '2', + 'completionengagementtalks' => 2, + 'completionengagementchats' => 2, + 'completionattendance' => 15 + ] + ); $instance = instance::get_from_instanceid($bbactivity->id); $user = $this->getDataGenerator()->create_user(); @@ -114,18 +126,24 @@ class completion_test extends \advanced_testcase { $meta = [ 'origin' => 0, 'data' => [ - 'duration' => 120, + 'duration' => 300, // 300 seconds, i.e 5 mins. 'engagement' => [ 'chats' => 2, 'talks' => 2, ], ], ]; - logger::log_meeting_joined_event($instance, 0); - logger::log_event_summary($instance, $overrides, $meta); - logger::log_event_summary($instance, $overrides, $meta); - // Now 2 x 120 mins of duration. + // We setup a couple of logs as per engagement and duration. + logger::log_event_summary($instance, $overrides, $meta); + logger::log_event_summary($instance, $overrides, $meta); + $completion = new custom_completion($bbactivitycm, $user->id); + $result = $completion->get_overall_completion_state(); + $this->assertEquals(COMPLETION_INCOMPLETE, $result); + + // Now we have 15 mins. + logger::log_event_summary($instance, $overrides, $meta); + // Now that the meeting was joined, it should be complete. $completion = new custom_completion($bbactivitycm, $user->id); $result = $completion->get_overall_completion_state(); $this->assertEquals(COMPLETION_COMPLETE, $result); @@ -141,21 +159,15 @@ class completion_test extends \advanced_testcase { // Two activities, both with automatic completion. One has the 'completionsubmit' rule, one doesn't. // Inspired from the same test in forum. list($bbactivitycontext, $cm1, $bbactivity) = $this->create_instance($this->get_course(), - ['completion' => '2', 'completionattendance' => '1']); + ['completion' => '2']); + $cm1->override_customdata('customcompletionrules', [ + 'completionattendance' => '1' + ]); list($bbactivitycontext, $cm2, $bbactivity) = $this->create_instance($this->get_course(), - ['completion' => '2', 'completionattendance' => '0']); - - // Data for the stdClass input type. - // This type of input would occur when checking the default completion rules for an activity type, where we don't have - // any access to cm_info, rather the input is a stdClass containing completion and customdata attributes, just like cm_info. - $moddefaults = (object) [ - 'customdata' => [ - 'customcompletionrules' => [ - 'completionsubmit' => '1', - ], - ], - 'completion' => 2, - ]; + ['completion' => '2']); + $cm2->override_customdata('customcompletionrules', [ + 'completionattendance' => '0' + ]); $completioncm1 = new custom_completion($cm1, $user->id); // TODO: check the return value here as there might be an issue with the function compared to the forum for example. @@ -194,6 +206,12 @@ class completion_test extends \advanced_testcase { // Trigger and capture the event. $sink = $this->redirectEvents(); + // Check completion before viewing. + $completion = new completion_info($this->get_course()); + $completiondata = $completion->get_data($bbactivitycm); + $this->assertEquals(0, $completiondata->viewed); + $this->assertEquals(COMPLETION_NOT_VIEWED, $completiondata->completionstate); + bigbluebuttonbn_view($bbactivity, $this->get_course(), $bbactivitycm, context_module::instance($bbactivitycm->id)); $events = $sink->get_events(); @@ -213,7 +231,89 @@ class completion_test extends \advanced_testcase { $completion = new completion_info($this->get_course()); $completiondata = $completion->get_data($bbactivitycm); $this->assertEquals(1, $completiondata->viewed); - // A view means COMPLETE. $this->assertEquals(COMPLETION_COMPLETE, $completiondata->completionstate); } + + /** + * Completion with no rules and join meeting + * + * @param array $customcompletionrules + * @param array $events + * @param int $expectedstate + * @dataProvider custom_completion_data_provider + */ + public function test_get_completion_with_events(array $customcompletionrules, array $events, int $expectedstate) { + $this->resetAfterTest(); + list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance( + $this->get_course(), + [ + 'completion' => '2', + ] + ); + $bbactivitycm->override_customdata('customcompletionrules', $customcompletionrules); + $plugingenerator = $this->getDataGenerator()->get_plugin_generator('mod_bigbluebuttonbn'); + + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + // Now create a couple of events. + $instance = instance::get_from_instanceid($bbactivity->id); + set_config('bigbluebuttonbn_meetingevents_enabled', true); + $meeting = $plugingenerator->create_meeting([ + 'instanceid' => $instance->get_instance_id(), + 'groupid' => $instance->get_group_id(), + 'participants' => json_encode([$user->id]) + ]); + foreach ($events as $edesc) { + $plugingenerator->add_meeting_event($user, $instance, $edesc->name, $edesc->data ?? ''); + } + $result = $plugingenerator->send_all_events($instance); + $this->assertNotEmpty($result->data); + $data = json_decode(json_encode($result->data)); + meeting::meeting_events($instance, $data); + $completion = new custom_completion($bbactivitycm, $user->id); + $result = $completion->get_overall_completion_state(); + $this->assertEquals($expectedstate, $result); + } + + /** + * Data generator + * + * @return array[] + */ + public function custom_completion_data_provider() { + return [ + 'simple' => [ + 'customcompletionrules' => [ + 'completionengagementtalks' => 1, + 'completionengagementchats' => 1, + ], + 'events' => [ + (object) ['name' => 'talks'], + (object) ['name' => 'chats'] + ], + 'expectedstate' => COMPLETION_COMPLETE + ], + 'not right events' => [ + 'customcompletionrules' => [ + 'completionengagementchats' => 1, + ], + 'events' => [ + (object) ['name' => 'talks'] + ], + 'expectedstate' => COMPLETION_INCOMPLETE + ], + 'attendance' => [ + 'customcompletionrules' => [ + 'completionattendance' => 1, + ], + 'events' => [ + (object) ['name' => 'talks'], + (object) ['name' => 'attendance', 'data' => '70'] + ], + 'expectedstate' => COMPLETION_COMPLETE + ] + ]; + } } + diff --git a/mod/bigbluebuttonbn/tests/external/get_join_url_test.php b/mod/bigbluebuttonbn/tests/external/get_join_url_test.php index 56f650f5351..3bdd93242a4 100644 --- a/mod/bigbluebuttonbn/tests/external/get_join_url_test.php +++ b/mod/bigbluebuttonbn/tests/external/get_join_url_test.php @@ -215,7 +215,8 @@ class get_join_url_test extends \externallib_advanced_testcase { $course = $generator->create_course(); $record = $generator->create_module('bigbluebuttonbn', ['course' => $course->id, 'userlimit' => 2]); - $user = $generator->create_and_enrol($course, 'student'); + $user1 = $generator->create_and_enrol($course, 'student'); + $user2 = $generator->create_and_enrol($course, 'student'); $instance = instance::get_from_instanceid($record->id); $bbbgenerator = $this->getDataGenerator()->get_plugin_generator('mod_bigbluebuttonbn'); @@ -225,7 +226,7 @@ class get_join_url_test extends \externallib_advanced_testcase { 'groupid' => $instance->get_group_id(), 'participants' => 2 ]); - $this->setUser($user); + $this->setUser($user1); $joinurl = $this->get_join_url($instance->get_cm_id()); $this->assertNotNull($joinurl['warnings']); $this->assertEquals('userlimitreached', $joinurl['warnings'][0]['warningcode']); diff --git a/mod/bigbluebuttonbn/tests/generator/lib.php b/mod/bigbluebuttonbn/tests/generator/lib.php index b4b9e2d0699..0f2db867243 100644 --- a/mod/bigbluebuttonbn/tests/generator/lib.php +++ b/mod/bigbluebuttonbn/tests/generator/lib.php @@ -26,6 +26,7 @@ use core\plugininfo\mod; use mod_bigbluebuttonbn\instance; +use mod_bigbluebuttonbn\local\config; use mod_bigbluebuttonbn\logger; use mod_bigbluebuttonbn\recording; @@ -304,6 +305,12 @@ class mod_bigbluebuttonbn_generator extends \testing_module_generator { 'bbb-recording-name' => $instance->get_meeting_name(), ], ]); + if ((boolean) config::get('recordingready_enabled')) { + $roomconfig['meta']['bn-recording-ready-url'] = $instance->get_record_ready_url()->out(false); + } + if ((boolean) config::get('meetingevents_enabled')) { + $roomconfig['meta']['analytics-callback-url'] = $instance->get_meeting_event_notification_url()->out(false); + } if (!empty($roomconfig['isBreakout'])) { // If it is a breakout meeting, we do not have any way to know the real Id of the meeting // For now we will just send the parent ID and let the mock server deal with the sequence + parentID @@ -312,9 +319,7 @@ class mod_bigbluebuttonbn_generator extends \testing_module_generator { } else { $roomconfig['meetingID'] = $meetingid; } - $this->send_mock_request('backoffice/createMeeting', [], $roomconfig); - return (object) $roomconfig; } @@ -394,6 +399,44 @@ class mod_bigbluebuttonbn_generator extends \testing_module_generator { return $retvalue; } + /** + * Trigger a meeting event on BBB side + * + * @param object $user + * @param instance $instance + * @param string $eventtype + * @param string|null $eventdata + * @return void + */ + public function add_meeting_event(object $user, instance $instance, string $eventtype, string $eventdata = ''): void { + $this->send_mock_request('backoffice/addMeetingEvent', [ + 'secret' => \mod_bigbluebuttonbn\local\config::DEFAULT_SHARED_SECRET, + 'meetingID' => $instance->get_meeting_id(), + 'attendeeID' => $user->id, + 'attendeeName' => fullname($user), + 'eventType' => $eventtype, + 'eventData' => $eventdata + ] + ); + } + + /** + * Send all previously store events + * + * @param instance $instance + * @return object|null + */ + public function send_all_events(instance $instance): ?object { + if (defined('TEST_MOD_BIGBLUEBUTTONBN_MOCK_SERVER')) { + return $this->send_mock_request('backoffice/sendAllEvents', [ + 'meetingID' => $instance->get_meeting_id(), + 'sendQuery' => false, // We get the result directly here. + 'secret' => \mod_bigbluebuttonbn\local\config::DEFAULT_SHARED_SECRET, + ]); + } + return null; + } + /** * Reset the mock server */ diff --git a/mod/bigbluebuttonbn/tests/lib_test.php b/mod/bigbluebuttonbn/tests/lib_test.php index 69e64058cbd..f86477b504a 100644 --- a/mod/bigbluebuttonbn/tests/lib_test.php +++ b/mod/bigbluebuttonbn/tests/lib_test.php @@ -397,6 +397,7 @@ class lib_test extends \advanced_testcase { list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance(); $this->getDataGenerator()->enrol_user($user->id, $this->course->id); + $this->setUser($user); logger::log_meeting_joined_event(instance::get_from_instanceid($bbactivity->id), 0); $data->courseid = $this->get_course()->id; @@ -462,6 +463,7 @@ class lib_test extends \advanced_testcase { list($bbactivitycontext, $bbactivitycm, $bbactivity) = $this->create_instance(); $this->getDataGenerator()->enrol_user($user->id, $this->course->id); + $this->setUser($user); logger::log_meeting_joined_event(instance::get_from_instanceid($bbactivity->id), 0); $data->courseid = $this->get_course()->id;