From 7ee4db4c6415084a9cca7c5a5b12242c39999270 Mon Sep 17 00:00:00 2001 From: Dan Marsden Date: Mon, 30 Apr 2018 19:59:18 +1200 Subject: [PATCH] Feature: Prevent students from sharing device while self-marking. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds IP recording when self-marking and checks if another student has previously self-marked using the same IP address. Thanks to Xi’an Jiaotong Liverpool University for funding this work. --- add_form.php | 34 +++++++ backup/moodle2/backup_attendance_stepslib.php | 3 +- classes/event/session_ip_shared.php | 93 +++++++++++++++++++ classes/import/sessions.php | 23 ++++- classes/structure.php | 33 ++++++- db/install.xml | 3 + db/upgrade.php | 26 +++++- externallib.php | 2 + lang/en/attendance.php | 8 ++ locallib.php | 40 ++++++++ settings.php | 6 ++ update_form.php | 24 ++++- version.php | 2 +- 13 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 classes/event/session_ip_shared.php diff --git a/add_form.php b/add_form.php index 9968515..d74f585 100644 --- a/add_form.php +++ b/add_form.php @@ -244,6 +244,28 @@ class mod_attendance_add_form extends moodleform { $mform->disabledif('usedefaultsubnet', 'studentscanmark', 'notchecked'); $mform->disabledif('subnet', 'studentscanmark', 'notchecked'); $mform->disabledif('subnet', 'usedefaultsubnet', 'checked'); + + $mgroup3 = array(); + $mgroup3[] = & $mform->createElement('checkbox', 'preventsharedip', ''); + $mgroup3[] = & $mform->createElement('text', 'preventsharediptime', + get_string('preventsharediptime', 'attendance'), '', 'test'); + $mgroup3[] = & $mform->createElement('static', 'preventsharediptimedesc', '', + get_string('preventsharedipminutes', 'attendance')); + $mform->addGroup($mgroup3, 'preventsharedgroup', get_string('preventsharedip', 'attendance'), array(' '), false); + $mform->addHelpButton('preventsharedgroup', 'preventsharedip', 'attendance'); + $mform->setAdvanced('preventsharedgroup'); + $mform->setType('preventsharediptime', PARAM_INT); + $mform->disabledif('preventsharedgroup', 'studentscanmark', 'notchecked'); + $mform->disabledif('preventsharedip', 'studentscanmark', 'notchecked'); + $mform->disabledif('preventsharediptime', 'studentscanmark', 'notchecked'); + $mform->disabledIf('preventsharediptime', 'preventsharedip', 'notchecked'); + if (isset($pluginconfig->preventsharedip)) { + $mform->setDefault('preventsharedip', $pluginconfig->preventsharedip); + } + if (isset($pluginconfig->preventsharediptime)) { + $mform->setDefault('preventsharediptime', $pluginconfig->preventsharediptime); + } + } else { $mform->addElement('hidden', 'studentscanmark', '0'); $mform->settype('studentscanmark', PARAM_INT); @@ -254,6 +276,13 @@ class mod_attendance_add_form extends moodleform { $mform->addElement('hidden', 'subnet', ''); $mform->setType('subnet', PARAM_TEXT); + + $mform->addElement('hidden', 'preventsharedip', '0'); + $mform->setType('preventsharedip', PARAM_INT); + + $sharedtime = isset($pluginconfig->preventsharediptime) ? $pluginconfig->preventsharediptime : null; + $mform->addElement('hidden', 'preventsharediptime', $sharedtime); + $mform->setType('preventsharediptime', PARAM_INT); } $this->add_action_buttons(true, get_string('add', 'attendance')); @@ -316,6 +345,11 @@ class mod_attendance_add_form extends moodleform { } } + if (!empty($data['studentscanmark']) && !empty($data['preventsharedip']) && + empty($data['preventsharediptime'])) { + $errors['preventsharedgroup'] = get_string('iptimemissing', 'attendance'); + + } return $errors; } diff --git a/backup/moodle2/backup_attendance_stepslib.php b/backup/moodle2/backup_attendance_stepslib.php index 6aefd4c..cdf21ab 100644 --- a/backup/moodle2/backup_attendance_stepslib.php +++ b/backup/moodle2/backup_attendance_stepslib.php @@ -58,7 +58,8 @@ class backup_attendance_activity_structure_step extends backup_activity_structur $session = new backup_nested_element('session', array('id'), array( 'groupid', 'sessdate', 'duration', 'lasttaken', 'lasttakenby', 'timemodified', 'description', 'descriptionformat', 'studentscanmark', 'studentpassword', 'autoassignstatus', - 'subnet', 'automark', 'automarkcompleted', 'statusset', 'absenteereport', 'caleventid')); + 'subnet', 'automark', 'automarkcompleted', 'statusset', 'absenteereport', 'preventsharedip', + 'preventsharediptime', 'caleventid')); // XML nodes declaration - user data. $logs = new backup_nested_element('logs'); diff --git a/classes/event/session_ip_shared.php b/classes/event/session_ip_shared.php new file mode 100644 index 0000000..8d71dca --- /dev/null +++ b/classes/event/session_ip_shared.php @@ -0,0 +1,93 @@ +. + +/** + * This file contains an event for when self-marking is blocked because + * another student used the same IP address to self-mark. + * + * @package mod_attendance + * @author Dan Marsden + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace mod_attendance\event; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Event for when self-marking is blocked + * + * @property-read array $other { + * Extra information about event properties. + * + * string mode Mode of the report viewed. + * } + * @package mod_attendance + * @author Dan Marsden + * @copyright 2018 Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class session_ip_shared extends \core\event\base { + + /** + * Init method. + */ + protected function init() { + $this->data['crud'] = 'r'; + $this->data['edulevel'] = self::LEVEL_PARTICIPATING; + $this->data['objecttable'] = 'attendance_log'; + } + + /** + * Returns non-localised description of what happened. + * + * @return string + */ + public function get_description() { + return 'User with id ' . $this->userid . ' was blocked from taking attendance for sessionid: ' . $this->other['sessionid'] . + ' because user with id '.$this->other['otheruser'] . ' previously marked attendance with the same IP address.'; + } + + /** + * Returns localised general event name. + * + * @return string + */ + public static function get_name() { + return get_string('eventsessionipshared', 'mod_attendance'); + } + + /** + * Get URL related to the action + * + * @return \moodle_url + */ + public function get_url() { + return new \moodle_url('/mod/attendance/attendance.php'); + } + + /** + * Get objectid mapping + * + * @return array of parameters for object mapping. + */ + public static function get_objectid_mapping() { + return array( + 'db' => 'attendance', + 'restore' => 'attendance' + ); + } +} diff --git a/classes/import/sessions.php b/classes/import/sessions.php index 2ea7dd3..db6e31d 100644 --- a/classes/import/sessions.php +++ b/classes/import/sessions.php @@ -107,7 +107,9 @@ class sessions { get_string('subnet', 'attendance'), get_string('automark', 'attendance'), get_string('autoassignstatus', 'attendance'), - get_string('absenteereport', 'attendance') + get_string('absenteereport', 'attendance'), + get_string('preventsharedip', 'attendance'), + get_string('preventsharediptime', 'attendance') ); } @@ -143,7 +145,9 @@ class sessions { 'subnet' => $data->header12, 'automark' => $data->header13, 'autoassignstatus' => $data->header14, - 'absenteereport' => $data->header15 + 'absenteereport' => $data->header15, + 'preventsharedip' => $data->header16, + 'preventsharediptime' => $data->header17, ); } else { return array( @@ -162,7 +166,9 @@ class sessions { 'subnet' => 12, 'automark' => 13, 'autoassignstatus' => 14, - 'absenteereport' => 15 + 'absenteereport' => 15, + 'preventsharedip' => 16, + 'preventsharediptime' => 17 ); } } @@ -321,6 +327,17 @@ class sessions { } else { $session->absenteereport = $this->get_column_data($row, $mapping['absenteereport']); } + if ($mapping['preventsharedip'] == -1) { + $session->preventsharedip = $pluginconfig->preventsharedip; + } else { + $session->preventsharedip = $this->get_column_data($row, $mapping['preventsharedip']); + } + if ($mapping['preventsharediptime'] == -1) { + $session->preventsharediptime = $pluginconfig->preventsharediptime; + } else { + $session->preventsharediptime = $this->get_column_data($row, $mapping['preventsharediptime']); + } + $session->statusset = 0; $sessions[] = $session; diff --git a/classes/structure.php b/classes/structure.php index 8be5462..b52e9a2 100644 --- a/classes/structure.php +++ b/classes/structure.php @@ -493,6 +493,14 @@ class mod_attendance_structure { $sess->subnet = ''; } + if (!isset($sess->preventsharedip)) { + $sess->preventsharedip = 0; + } + + if (!isset($sess->preventsharediptime)) { + $sess->preventsharediptime = ''; + } + $event->add_record_snapshot('attendance_sessions', $sess); $event->trigger(); } @@ -529,6 +537,8 @@ class mod_attendance_structure { $sess->subnet = ''; $sess->automark = 0; $sess->automarkcompleted = 0; + $sess->preventsharedip = 0; + $sess->preventsharediptime = ''; if (!empty(get_config('attendance', 'enablewarnings'))) { $sess->absenteereport = empty($formdata->absenteereport) ? 0 : 1; } @@ -549,6 +559,13 @@ class mod_attendance_structure { if (!empty($formdata->automark)) { $sess->automark = $formdata->automark; } + if (!empty($formdata->preventsharedip)) { + $sess->preventsharedip = $formdata->preventsharedip; + } + if (!empty($formdata->preventsharediptime)) { + $sess->preventsharediptime = $formdata->preventsharediptime; + } + } $sess->timemodified = time(); @@ -592,6 +609,7 @@ class mod_attendance_structure { $record->sessionid = $mformdata->sessid; $record->timetaken = $now; $record->takenby = $USER->id; + $record->ipaddress = getremoteaddr(null); $dbsesslog = $this->get_session_log($mformdata->sessid); if (array_key_exists($record->studentid, $dbsesslog)) { @@ -1018,7 +1036,8 @@ class mod_attendance_structure { $where = "ats.attendanceid = :aid AND ats.sessdate >= :csdate"; } if ($this->get_group_mode()) { - $sql = "SELECT ats.id, ats.sessdate, ats.groupid, al.statusid, al.remarks + $sql = "SELECT ats.id, ats.sessdate, ats.groupid, al.statusid, al.remarks, + ats.preventsharediptime, ats.preventsharedip FROM {attendance_sessions} ats JOIN {attendance_log} al ON ats.id = al.sessionid AND al.studentid = :uid LEFT JOIN {groups_members} gm ON gm.userid = al.studentid AND gm.groupid = ats.groupid @@ -1033,7 +1052,8 @@ class mod_attendance_structure { 'edate' => $this->pageparams->enddate); } else { - $sql = "SELECT ats.id, ats.sessdate, ats.groupid, al.statusid, al.remarks + $sql = "SELECT ats.id, ats.sessdate, ats.groupid, al.statusid, al.remarks, + ats.preventsharediptime, ats.preventsharedip FROM {attendance_sessions} ats JOIN {attendance_log} al ON ats.id = al.sessionid AND al.studentid = :uid @@ -1075,7 +1095,8 @@ class mod_attendance_structure { $id = $DB->sql_concat(':value', 'ats.id'); if ($this->get_group_mode()) { $sql = "SELECT $id, ats.id, ats.groupid, ats.sessdate, ats.duration, ats.description, - al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus + al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus, + ats.preventsharedip, ats.preventsharediptime FROM {attendance_sessions} ats RIGHT JOIN {attendance_log} al ON ats.id = al.sessionid AND al.studentid = :uid @@ -1084,7 +1105,8 @@ class mod_attendance_structure { ORDER BY ats.sessdate ASC"; } else { $sql = "SELECT $id, ats.id, ats.groupid, ats.sessdate, ats.duration, ats.description, ats.statusset, - al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus + al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus, + ats.preventsharedip, ats.preventsharediptime FROM {attendance_sessions} ats RIGHT JOIN {attendance_log} al ON ats.id = al.sessionid AND al.studentid = :uid @@ -1114,7 +1136,8 @@ class mod_attendance_structure { $where = "ats.attendanceid = :aid AND ats.sessdate >= :csdate AND ats.groupid $gsql"; } $sql = "SELECT $id, ats.id, ats.groupid, ats.sessdate, ats.duration, ats.description, ats.statusset, - al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus + al.statusid, al.remarks, ats.studentscanmark, ats.autoassignstatus, + ats.preventsharedip, ats.preventsharediptime FROM {attendance_sessions} ats LEFT JOIN {attendance_log} al ON ats.id = al.sessionid AND al.studentid = :uid diff --git a/db/install.xml b/db/install.xml index 54ff929..e83a161 100644 --- a/db/install.xml +++ b/db/install.xml @@ -45,6 +45,8 @@ + + @@ -67,6 +69,7 @@ + diff --git a/db/upgrade.php b/db/upgrade.php index f4db45b..06d955b 100644 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -467,12 +467,36 @@ function xmldb_attendance_upgrade($oldversion=0) { if ($oldversion < 2016121328) { $table = new xmldb_table('attendance'); - $field = new xmldb_field('showextrauserdetails', XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, '1', 'showsessiondetails'); + $field = new xmldb_field('showextrauserdetails', XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, + XMLDB_NOTNULL, null, '1', 'showsessiondetails'); if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } upgrade_mod_savepoint(true, 2016121328, 'attendance'); } + if ($oldversion < 2016121330) { + $table = new xmldb_table('attendance_sessions'); + $field = new xmldb_field('preventsharedip', XMLDB_TYPE_INTEGER, '1', XMLDB_UNSIGNED, + XMLDB_NOTNULL, null, '0', 'absenteereport'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + $field = new xmldb_field('preventsharediptime', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, + null, null, null, 'preventsharedip'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $table = new xmldb_table('attendance_log'); + $field = new xmldb_field('ipaddress', XMLDB_TYPE_CHAR, '45', null, + null, null, '', 'remarks'); + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + upgrade_mod_savepoint(true, 2016121330, 'attendance'); + } + return $result; } diff --git a/externallib.php b/externallib.php index 0c753e3..f302116 100644 --- a/externallib.php +++ b/externallib.php @@ -70,6 +70,8 @@ class mod_wsattendance_external extends external_api { 'studentscanmark' => new external_value(PARAM_INT, 'Students can mark their own presence.'), 'absenteereport' => new external_value(PARAM_INT, 'Session included in absetee reports.'), 'autoassignstatus' => new external_value(PARAM_INT, 'Automatically assign a status to students.'), + 'preventsharedip' => new external_value(PARAM_INT, 'Prevent students from sharing IP addresses.'), + 'preventsharediptime' => new external_value(PARAM_INT, 'Time delay before IP address is allowed again.'), 'statusset' => new external_value(PARAM_INT, 'Session statusset.')); return $session; diff --git a/lang/en/attendance.php b/lang/en/attendance.php index 5a6d625..d66899e 100644 --- a/lang/en/attendance.php +++ b/lang/en/attendance.php @@ -203,6 +203,7 @@ $string['eventsdeleted'] = 'Calendar events deleted'; $string['eventsessionadded'] = 'Session added'; $string['eventsessiondeleted'] = 'Session deleted'; $string['eventsessionsimported'] = 'Sessions imported'; +$string['eventsessionipshared'] = 'Attendance self-marking IP conflict'; $string['eventsessionupdated'] = 'Session updated'; $string['eventstatusadded'] = 'Status added'; $string['eventstatusupdated'] = 'Status updated'; @@ -242,6 +243,7 @@ $string['invalidimportfile'] = 'File format is invalid.'; $string['invalidsessionenddate'] = 'This date can not be earlier than the session date'; $string['invalidsessionendtime'] = 'The end time must be greater than start time'; $string['invalidstatus'] = 'You have selected an invalid status, please try again'; +$string['iptimemissing'] = 'Invalid minutes to release'; $string['jumpto'] = 'Jump to'; $string['lowgrade'] = 'Low grade'; $string['maxpossible'] = 'Maximum possible'; @@ -327,6 +329,12 @@ $string['points'] = 'Points'; $string['pointsallsessions'] = 'Points over all sessions'; $string['pointssessionscompleted'] = 'Points over taken sessions'; $string['preferences_desc'] = 'Changes to status sets will affect existing attendance sessions and may affect grading.'; +$string['preventsharedip'] = 'Prevent students sharing IP address'; +$string['preventsharedip_help'] = 'Prevent students from using the same device (identified using IP address) to take attendance for other students.'; +$string['preventsharediptime'] = 'Time to allow re-use of IP address (minutes)'; +$string['preventsharediptime_help'] = 'Allow an IP address to be re-used for taking attendance in this session after this time has elapsed.'; +$string['preventsharedipminutes'] = '(minutes to release IP)'; +$string['preventsharederror'] = 'Self-marking has been disabled for a session because this device appears to have been used to record attendance for another student.'; $string['priorto'] = 'The session date is prior to the course start date ({$a}) so that the new sessions scheduled before this date will be hidden (not accessible). You can change the course start date at any time (see course settings) in order to have access to earlier sessions.

Please change the session date or just click the "Add session" button again to confirm?'; $string['processingfile'] = 'Processing file'; $string['randompassword'] = 'Random password'; diff --git a/locallib.php b/locallib.php index d725447..c2c42c6 100644 --- a/locallib.php +++ b/locallib.php @@ -425,6 +425,7 @@ function attendance_random_string($length=6) { * @return boolean */ function attendance_can_student_mark($sess) { + global $DB, $USER, $OUTPUT; $canmark = false; $attconfig = get_config('attendance'); if (!empty($attconfig->studentscanmark) && !empty($sess->studentscanmark)) { @@ -440,6 +441,31 @@ function attendance_can_student_mark($sess) { } } } + // Check if another student has marked attendance from this IP address recently. + if ($canmark && !empty($sess->preventsharedip)) { + $time = time() - ($sess->preventsharediptime * 60); + $sql = 'sessionid = ? AND studentid <> ? AND timetaken > ? AND ipaddress = ?'; + $params = array($sess->id, $USER->id, $time, getremoteaddr()); + $record = $DB->get_record_select('attendance_log', $sql, $params); + if (!empty($record)) { + // Trigger an ip_shared event. + $attendanceid = $DB->get_field('attendance_sessions', 'attendanceid', array('id' => $record->sessionid)); + $cm = get_coursemodule_from_instance('attendance', $attendanceid); + $event = \mod_attendance\event\session_ip_shared::create(array( + 'objectid' => 0, + 'context' => \context_module::instance($cm->id), + 'other' => array( + 'sessionid' => $record->sessionid, + 'otheruser' => $record->studentid + ) + )); + + $event->trigger(); + + echo $OUTPUT->notification(get_string('preventsharederror', 'attendance')); + return false; + } + } return $canmark; } @@ -601,10 +627,18 @@ function attendance_construct_sessions_data_for_add($formdata, mod_attendance_st } else if (!empty($formdata->studentpassword)) { $sess->studentpassword = $formdata->studentpassword; } + if (!empty($formdata->preventsharedip)) { + $sess->preventsharedip = $formdata->preventsharedip; + } + if (!empty($formdata->preventsharediptime)) { + $sess->preventsharediptime = $formdata->preventsharediptime; + } } else { $sess->subnet = ''; $sess->automark = 0; $sess->automarkcompleted = 0; + $sess->preventsharedip = 0; + $sess->preventsharediptime = ''; } $sess->statusset = $formdata->statusset; @@ -652,6 +686,12 @@ function attendance_construct_sessions_data_for_add($formdata, mod_attendance_st if (!empty($formdata->automark)) { $sess->automark = $formdata->automark; } + if (!empty($formdata->preventsharedip)) { + $sess->preventsharedip = $formdata->preventsharedip; + } + if (!empty($formdata->preventsharediptime)) { + $sess->preventsharediptime = $formdata->preventsharediptime; + } } $sess->statusset = $formdata->statusset; diff --git a/settings.php b/settings.php index dd3d102..3b2e2c4 100644 --- a/settings.php +++ b/settings.php @@ -122,6 +122,12 @@ if ($ADMIN->fulltree) { $settings->add(new admin_setting_configcheckbox('attendance/autoassignstatus', get_string('autoassignstatus', 'attendance'), '', 0)); + $settings->add(new admin_setting_configcheckbox('attendance/preventsharedip', + get_string('preventsharedip', 'attendance'), '', 0)); + + $settings->add(new admin_setting_configtext('attendance/preventsharediptime', + get_string('preventsharediptime', 'attendance'), get_string('preventsharediptime_help', 'attendance'), '', PARAM_RAW)); + $name = new lang_string('defaultwarningsettings', 'mod_attendance'); $description = new lang_string('defaultwarningsettings_help', 'mod_attendance'); $settings->add(new admin_setting_heading('defaultwarningsettings', $name, $description)); diff --git a/update_form.php b/update_form.php index 4a73152..fe93200 100644 --- a/update_form.php +++ b/update_form.php @@ -72,7 +72,9 @@ class mod_attendance_update_form extends moodleform { 'subnet' => $sess->subnet, 'automark' => $sess->automark, 'absenteereport' => $sess->absenteereport, - 'automarkcompleted' => 0); + 'automarkcompleted' => 0, + 'preventsharedip' => $sess->preventsharedip, + 'preventsharediptime' => $sess->preventsharediptime); if ($sess->subnet == $attendancesubnet) { $data['usedefaultsubnet'] = 1; } else { @@ -157,6 +159,21 @@ class mod_attendance_update_form extends moodleform { $mform->addElement('hidden', 'automarkcompleted', '0'); $mform->settype('automarkcompleted', PARAM_INT); + $mgroup3 = array(); + $mgroup3[] = & $mform->createElement('checkbox', 'preventsharedip', ''); + $mgroup3[] = & $mform->createElement('text', 'preventsharediptime', + get_string('preventsharediptime', 'attendance'), '', 'test'); + $mgroup3[] = & $mform->createElement('static', 'preventsharediptimedesc', '', + get_string('preventsharedipminutes', 'attendance')); + $mform->addGroup($mgroup3, 'preventsharedgroup', + get_string('preventsharedip', 'attendance'), array(' '), false); + $mform->addHelpButton('preventsharedgroup', 'preventsharedip', 'attendance'); + $mform->setAdvanced('preventsharedgroup'); + $mform->setType('preventsharediptime', PARAM_INT); + $mform->disabledif('preventsharedgroup', 'studentscanmark', 'notchecked'); + $mform->disabledif('preventsharedip', 'studentscanmark', 'notchecked'); + $mform->disabledif('preventsharediptime', 'studentscanmark', 'notchecked'); + $mform->disabledIf('preventsharediptime', 'preventsharedip', 'notchecked'); } else { $mform->addElement('hidden', 'studentscanmark', '0'); $mform->settype('studentscanmark', PARAM_INT); @@ -203,6 +220,11 @@ class mod_attendance_update_form extends moodleform { } } + if (!empty($data['studentscanmark']) && !empty($data['preventsharedip']) && + empty($data['preventsharediptime'])) { + $errors['preventsharedgroup'] = get_string('iptimemissing', 'attendance'); + + } return $errors; } } diff --git a/version.php b/version.php index 61a2af3..a71ecff 100644 --- a/version.php +++ b/version.php @@ -23,7 +23,7 @@ */ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2016121329; +$plugin->version = 2016121330; $plugin->requires = 2016111800; $plugin->release = '3.2.20'; $plugin->maturity = MATURITY_STABLE;