Browse Source

Feature: Prevent students from sharing device while self-marking.

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.
MOODLE_35_STABLE
Dan Marsden 7 years ago
parent
commit
a98bb53e64
  1. 32
      add_form.php
  2. 3
      backup/moodle2/backup_attendance_stepslib.php
  3. 93
      classes/event/session_ip_shared.php
  4. 23
      classes/import/sessions.php
  5. 33
      classes/structure.php
  6. 3
      db/install.xml
  7. 26
      db/upgrade.php
  8. 2
      externallib.php
  9. 8
      lang/en/attendance.php
  10. 40
      locallib.php
  11. 6
      settings.php
  12. 22
      update_form.php
  13. 2
      version.php

32
add_form.php

@ -241,6 +241,26 @@ class mod_attendance_add_form extends moodleform {
$mform->hideif('subnetgrp', 'studentscanmark', 'notchecked');
$mform->hideif('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->hideif('preventsharedgroup', '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);
@ -251,6 +271,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'));
@ -313,6 +340,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;
}

3
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');

93
classes/event/session_ip_shared.php

@ -0,0 +1,93 @@
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* 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 <dan@danmarsden.com>
* @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 <dan@danmarsden.com>
* @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'
);
}
}

23
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
);
}
}
@ -317,6 +323,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;

33
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

3
db/install.xml

@ -45,6 +45,8 @@
<FIELD NAME="automarkcompleted" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="statusset" TYPE="int" LENGTH="5" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Which set of statuses to use"/>
<FIELD NAME="absenteereport" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="preventsharedip" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false"/>
<FIELD NAME="preventsharediptime" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="caleventid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
</FIELDS>
<KEYS>
@ -67,6 +69,7 @@
<FIELD NAME="timetaken" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="When attendance of this student was taken"/>
<FIELD NAME="takenby" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
<FIELD NAME="remarks" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
<FIELD NAME="ipaddress" TYPE="char" LENGTH="45" NOTNULL="false" SEQUENCE="false"/>
</FIELDS>
<KEYS>
<KEY NAME="primary" TYPE="primary" FIELDS="id" COMMENT="Primary key for attendance_log"/>

26
db/upgrade.php

@ -480,12 +480,36 @@ function xmldb_attendance_upgrade($oldversion=0) {
if ($oldversion < 2018022204) {
$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, 2018022204, 'attendance');
}
if ($oldversion < 2018050100) {
$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, 2018050100, 'attendance');
}
return $result;
}

2
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;

8
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.<br><br>Please change the session date or just click the "Add session" button again to confirm?';
$string['processingfile'] = 'Processing file';
$string['randompassword'] = 'Random password';

40
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;

6
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));

22
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 {
@ -156,6 +158,19 @@ 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->hideif('preventsharedgroup', 'studentscanmark', 'notchecked');
$mform->disabledIf('preventsharediptime', 'preventsharedip', 'notchecked');
} else {
$mform->addElement('hidden', 'studentscanmark', '0');
$mform->settype('studentscanmark', PARAM_INT);
@ -202,6 +217,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;
}
}

2
version.php

@ -23,7 +23,7 @@
*/
defined('MOODLE_INTERNAL') || die();
$plugin->version = 2018032200;
$plugin->version = 2018050101;
$plugin->requires = 2017102700; // Requires 3.4.
$plugin->release = '3.4.4';
$plugin->maturity = MATURITY_ALPHA;

Loading…
Cancel
Save