Merge branch 'MDL-38855_master' of git://github.com/dmonllao/moodle
authorDan Poltawski <dan@moodle.com>
Wed, 3 Apr 2013 04:44:05 +0000 (12:44 +0800)
committerDan Poltawski <dan@moodle.com>
Wed, 3 Apr 2013 04:44:05 +0000 (12:44 +0800)
80 files changed:
admin/index.php
admin/tool/installaddon/classes/installer.php [new file with mode: 0644]
admin/tool/installaddon/classes/installfromzip_form.php [new file with mode: 0644]
admin/tool/installaddon/classes/pluginfo_client.php [new file with mode: 0644]
admin/tool/installaddon/classes/validator.php [new file with mode: 0644]
admin/tool/installaddon/deploy.php [new file with mode: 0644]
admin/tool/installaddon/index.php [new file with mode: 0644]
admin/tool/installaddon/lang/en/tool_installaddon.php [new file with mode: 0644]
admin/tool/installaddon/permcheck.php [new file with mode: 0644]
admin/tool/installaddon/pix/icon.png [new file with mode: 0644]
admin/tool/installaddon/pix/icon.svg [new file with mode: 0644]
admin/tool/installaddon/renderer.php [new file with mode: 0644]
admin/tool/installaddon/settings.php [new file with mode: 0644]
admin/tool/installaddon/styles.css [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/emptydir/emptydir/README.txt [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/installed/greenbar/lang/en/local_greenbar.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/installed/greenbar/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/multidir/one/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/multidir/two/README.txt [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/index.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bah.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bleh.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/lib.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nolang/bah/view.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/noversionmod/noversion/lang/en/noversion.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/noversiontheme/noversion/lang/en/theme_noversion.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nowrapdir/index.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nowrapdir/lang/en/foo.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/nowrapdir/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/plugindir/foobar/index.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/plugindir/foobar/lang/en/local_foobar.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/plugindir/foobar/version.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/versionphp/version1.php [new file with mode: 0644]
admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip [new file with mode: 0644]
admin/tool/installaddon/tests/installer_test.php [new file with mode: 0644]
admin/tool/installaddon/tests/validator_test.php [new file with mode: 0644]
admin/tool/installaddon/validate.php [new file with mode: 0644]
admin/tool/installaddon/version.php [new file with mode: 0644]
admin/tool/installaddon/yui/permcheck/permcheck.js [new file with mode: 0644]
config-dist.php
lib/pluginlib.php
mdeploy.php
mdeploytest.php
mod/workshop/assessment.php
mod/workshop/backup/moodle2/backup_workshop_stepslib.php
mod/workshop/backup/moodle2/restore_workshop_stepslib.php
mod/workshop/db/install.xml
mod/workshop/db/upgrade.php
mod/workshop/exassessment.php
mod/workshop/fileinfolib.php
mod/workshop/form/assessment_form.php
mod/workshop/lang/en/workshop.php
mod/workshop/lib.php
mod/workshop/locallib.php
mod/workshop/mod_form.php
mod/workshop/renderer.php
mod/workshop/styles.css
mod/workshop/version.php
question/format/xml/format.php
question/format/xml/tests/xmlformat_test.php
question/type/essay/backup/moodle1/lib.php
question/type/essay/backup/moodle2/backup_qtype_essay_plugin.class.php
question/type/essay/backup/moodle2/restore_qtype_essay_plugin.class.php
question/type/essay/db/install.xml
question/type/essay/db/upgrade.php
question/type/essay/edit_essay_form.php
question/type/essay/lang/en/qtype_essay.php
question/type/essay/question.php
question/type/essay/questiontype.php
question/type/essay/renderer.php
question/type/essay/tests/helper.php
question/type/essay/tests/question_test.php
question/type/essay/tests/walkthrough_test.php
question/type/essay/version.php
theme/bootstrap/config.php
version.php

index 35cbdaf..def20d2 100644 (file)
@@ -59,10 +59,18 @@ $confirmplugins = optional_param('confirmplugincheck', 0, PARAM_BOOL);
 $showallplugins = optional_param('showallplugins', 0, PARAM_BOOL);
 $agreelicense   = optional_param('agreelicense', 0, PARAM_BOOL);
 $fetchupdates   = optional_param('fetchupdates', 0, PARAM_BOOL);
+$newaddonreq    = optional_param('installaddonrequest', null, PARAM_RAW);
 
 // Check some PHP server settings
 
-$PAGE->set_url('/admin/index.php');
+if (is_null($newaddonreq)) {
+    $PAGE->set_url('/admin/index.php');
+} else {
+    // We need to set the eventual add-on installation request in the $PAGE's URL
+    // so that it is stored in $SESSION->wantsurl and the admin is redirected
+    // correctly once they are logged-in.
+    $PAGE->set_url('/admin/index.php', array('installaddonrequest' => $newaddonreq));
+}
 $PAGE->set_pagelayout('admin'); // Set a default pagelayout
 
 $documentationlink = '<a href="http://docs.moodle.org/en/Installation">Installation docs</a>';
@@ -423,6 +431,17 @@ if (!empty($id) and $id == $CFG->siteidentifier) {
     set_config('registered', time());
 }
 
+// Check if we are returning from an add-on installation request at moodle.org/plugins
+if (!is_null($newaddonreq)) {
+    if (!empty($CFG->disableonclickaddoninstall)) {
+        // The feature is disabled in config.php, ignore the request.
+    } else {
+        redirect(new moodle_url('/admin/tool/installaddon/index.php', array(
+            'installaddonrequest' => $newaddonreq,
+            'confirm' => 0)));
+    }
+}
+
 // setup critical warnings before printing admin tree block
 $insecuredataroot = is_dataroot_insecure(true);
 $SESSION->admin_critical_warning = ($insecuredataroot==INSECURE_DATAROOT_ERROR);
diff --git a/admin/tool/installaddon/classes/installer.php b/admin/tool/installaddon/classes/installer.php
new file mode 100644 (file)
index 0000000..fa4babb
--- /dev/null
@@ -0,0 +1,574 @@
+<?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/>.
+
+/**
+ * Provides tool_installaddon_installer related classes
+ *
+ * @package     tool_installaddon
+ * @subpackage  classes
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Implements main plugin features.
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_installer {
+
+    /** @var tool_installaddon_installfromzip */
+    protected $installfromzipform = null;
+
+    /**
+     * Factory method returning an instance of this class.
+     *
+     * @return tool_installaddon_installer
+     */
+    public static function instance() {
+        return new static();
+    }
+
+    /**
+     * Returns the URL to the main page of this admin tool
+     *
+     * @param array optional parameters
+     * @return moodle_url
+     */
+    public function index_url(array $params = null) {
+        return new moodle_url('/admin/tool/installaddon/index.php', $params);
+    }
+
+    /**
+     * Returns URL to the repository that addons can be searched in and installed from
+     *
+     * @return moodle_url
+     */
+    public function get_addons_repository_url() {
+        global $CFG;
+
+        if (!empty($CFG->config_php_settings['alternativeaddonsrepositoryurl'])) {
+            $url = $CFG->config_php_settings['alternativeaddonsrepositoryurl'];
+        } else {
+            $url = 'https://moodle.org/plugins/get.php';
+        }
+
+        if (!$this->should_send_site_info()) {
+            return new moodle_url($url);
+        }
+
+        // Append the basic information about our site.
+        $site = array(
+            'fullname' => $this->get_site_fullname(),
+            'url' => $this->get_site_url(),
+            'majorversion' => $this->get_site_major_version(),
+        );
+
+        $site = $this->encode_site_information($site);
+
+        return new moodle_url($url, array('site' => $site));
+    }
+
+    /**
+     * @return tool_installaddon_installfromzip
+     */
+    public function get_installfromzip_form() {
+        global $CFG;
+        require_once(dirname(__FILE__).'/installfromzip_form.php');
+
+        if (!is_null($this->installfromzipform)) {
+            return $this->installfromzipform;
+        }
+
+        $action = $this->index_url();
+        $customdata = array('installer' => $this);
+
+        $this->installfromzipform = new tool_installaddon_installfromzip($action, $customdata);
+
+        return $this->installfromzipform;
+    }
+
+    /**
+     * Saves the ZIP file from the {@link tool_installaddon_installfromzip} form
+     *
+     * The file is saved into the given temporary location for inspection and eventual
+     * deployment. The form is expected to be submitted and validated.
+     *
+     * @param tool_installaddon_installfromzip $form
+     * @param string $targetdir full path to the directory where the ZIP should be stored to
+     * @return string filename of the saved file relative to the given target
+     */
+    public function save_installfromzip_file(tool_installaddon_installfromzip $form, $targetdir) {
+
+        $filename = clean_param($form->get_new_filename('zipfile'), PARAM_FILE);
+        $form->save_file('zipfile', $targetdir.'/'.$filename);
+
+        return $filename;
+    }
+
+    /**
+     * Extracts the saved file previously saved by {self::save_installfromzip_file()}
+     *
+     * The list of files found in the ZIP is returned via $zipcontentfiles parameter
+     * by reference. The format of that list is array of (string)filerelpath => (bool|string)
+     * where the array value is either true or a string describing the problematic file.
+     *
+     * @see zip_packer::extract_to_pathname()
+     * @param string $zipfilepath full path to the saved ZIP file
+     * @param string $targetdir full path to the directory to extract the ZIP file to
+     * @param string $rootdir explicitly rename the root directory of the ZIP into this non-empty value
+     * @param array list of extracted files as returned by {@link zip_packer::extract_to_pathname()}
+     */
+    public function extract_installfromzip_file($zipfilepath, $targetdir, $rootdir = '') {
+        global $CFG;
+        require_once($CFG->libdir.'/filelib.php');
+
+        $fp = get_file_packer('application/zip');
+        $files = $fp->extract_to_pathname($zipfilepath, $targetdir);
+
+        if ($files) {
+            if (!empty($rootdir)) {
+                $files = $this->rename_extracted_rootdir($targetdir, $rootdir, $files);
+            }
+            return $files;
+
+        } else {
+            return array();
+        }
+    }
+
+    /**
+     * Returns localised list of available plugin types
+     *
+     * @return array (string)plugintype => (string)plugin name
+     */
+    public function get_plugin_types_menu() {
+        global $CFG;
+        require_once($CFG->libdir.'/pluginlib.php');
+
+        $pluginman = plugin_manager::instance();
+
+        $menu = array('' => get_string('choosedots'));
+        foreach (array_keys($pluginman->get_plugin_types()) as $plugintype) {
+            $menu[$plugintype] = $pluginman->plugintype_name($plugintype).' ('.$plugintype.')';
+        }
+
+        return $menu;
+    }
+
+    /**
+     * Returns the full path of the root of the given plugin type
+     *
+     * Null is returned if the plugin type is not known. False is returned if the plugin type
+     * root is expected but not found. Otherwise, string is returned.
+     *
+     * @param string $plugintype
+     * @return string|bool|null
+     */
+    public function get_plugintype_root($plugintype) {
+
+        $plugintypepath = null;
+        foreach (get_plugin_types() as $type => $fullpath) {
+            if ($type === $plugintype) {
+                $plugintypepath = $fullpath;
+                break;
+            }
+        }
+        if (is_null($plugintypepath)) {
+            return null;
+        }
+
+        if (!is_dir($plugintypepath)) {
+            return false;
+        }
+
+        return $plugintypepath;
+    }
+
+    /**
+     * Is it possible to create a new plugin directory for the given plugin type?
+     *
+     * @throws coding_exception for invalid plugin types or non-existing plugin type locations
+     * @param string $plugintype
+     * @return boolean
+     */
+    public function is_plugintype_writable($plugintype) {
+
+        $plugintypepath = $this->get_plugintype_root($plugintype);
+
+        if (is_null($plugintypepath)) {
+            throw new coding_exception('Unknown plugin type!');
+        }
+
+        if ($plugintypepath === false) {
+            throw new coding_exception('Plugin type location does not exist!');
+        }
+
+        return is_writable($plugintypepath);
+    }
+
+    /**
+     * Hook method to handle the remote request to install an add-on
+     *
+     * This is used as a callback when the admin picks a plugin version in the
+     * Moodle Plugins directory and is redirected back to their site to install
+     * it.
+     *
+     * This hook is called early from admin/tool/installaddon/index.php page so that
+     * it has opportunity to take over the UI.
+     *
+     * @param tool_installaddon_renderer $output
+     * @param string|null $request
+     * @param bool $confirmed
+     */
+    public function handle_remote_request(tool_installaddon_renderer $output, $request, $confirmed = false) {
+        global $CFG;
+        require_once(dirname(__FILE__).'/pluginfo_client.php');
+
+        if (is_null($request)) {
+            return;
+        }
+
+        $data = $this->decode_remote_request($request);
+
+        if ($data === false) {
+            echo $output->remote_request_invalid_page($this->index_url());
+            exit();
+        }
+
+        list($plugintype, $pluginname) = normalize_component($data->component);
+
+        $plugintypepath = $this->get_plugintype_root($plugintype);
+
+        if (file_exists($plugintypepath.'/'.$pluginname)) {
+            echo $output->remote_request_alreadyinstalled_page($data, $this->index_url());
+            exit();
+        }
+
+        if (!$this->is_plugintype_writable($plugintype)) {
+            $continueurl = $this->index_url(array('installaddonrequest' => $request));
+            echo $output->remote_request_permcheck_page($data, $plugintypepath, $continueurl, $this->index_url());
+            exit();
+        }
+
+        $continueurl = $this->index_url(array(
+            'installaddonrequest' => $request,
+            'confirm' => 1,
+            'sesskey' => sesskey()));
+
+        if (!$confirmed) {
+            echo $output->remote_request_confirm_page($data, $continueurl, $this->index_url());
+            exit();
+        }
+
+        // The admin has confirmed their intention to install the add-on.
+        require_sesskey();
+
+        // Fetch the plugin info. The essential information is the URL to download the ZIP
+        // and the MD5 hash of the ZIP, obtained via HTTPS.
+        $client = tool_installaddon_pluginfo_client::instance();
+
+        try {
+            $pluginfo = $client->get_pluginfo($data->component, $data->version);
+
+        } catch (tool_installaddon_pluginfo_exception $e) {
+            if (debugging()) {
+                throw $e;
+            } else {
+                echo $output->remote_request_pluginfo_exception($data, $e, $this->index_url());
+                exit();
+            }
+        }
+
+        // Fetch the ZIP with the plugin version
+        $jobid = md5(rand().uniqid('', true));
+        $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
+        $zipfilename = 'downloaded.zip';
+
+        try {
+            $this->download_file($pluginfo->downloadurl, $sourcedir.'/'.$zipfilename);
+
+        } catch (tool_installaddon_installer_exception $e) {
+            if (debugging()) {
+                throw $e;
+            } else {
+                echo $output->installer_exception($e, $this->index_url());
+                exit();
+            }
+        }
+
+        // Check the MD5 checksum
+        $md5expected = $pluginfo->downloadmd5;
+        $md5actual = md5_file($sourcedir.'/'.$zipfilename);
+        if ($md5expected !== $md5actual) {
+            $e = new tool_installaddon_installer_exception('err_zip_md5', array('expected' => $md5expected, 'actual' => $md5actual));
+            if (debugging()) {
+                throw $e;
+            } else {
+                echo $output->installer_exception($e, $this->index_url());
+                exit();
+            }
+        }
+
+        // Redirect to the validation page.
+        $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array(
+            'sesskey' => sesskey(),
+            'jobid' => $jobid,
+            'zip' => $zipfilename,
+            'type' => $plugintype));
+        redirect($nexturl);
+    }
+
+    /**
+     * Download the given file into the given destination.
+     *
+     * This is basically a simplified version of {@link download_file_content()} from
+     * Moodle itself, tuned for fetching files from moodle.org servers. Same code is used
+     * in mdeploy.php for fetching available updates.
+     *
+     * @param string $source file url starting with http(s)://
+     * @param string $target store the downloaded content to this file (full path)
+     * @throws tool_installaddon_installer_exception
+     */
+    public function download_file($source, $target) {
+        global $CFG;
+        require_once($CFG->libdir.'/filelib.php');
+
+        $targetfile = fopen($target, 'w');
+
+        if (!$targetfile) {
+            throw new tool_installaddon_installer_exception('err_download_write_file', $target);
+        }
+
+        $options = array(
+            'file' => $targetfile,
+            'timeout' => 300,
+            'followlocation' => true,
+            'maxredirs' => 3,
+            'ssl_verifypeer' => true,
+            'ssl_verifyhost' => 2,
+        );
+
+        $cacertfile = $CFG->dataroot.'/moodleorgca.crt';
+        if (is_readable($cacertfile)) {
+            // Do not use CA certs provided by the operating system. Instead,
+            // use this CA cert to verify the ZIP provider.
+            $options['cainfo'] = $cacertfile;
+        }
+
+        $curl = new curl(array('proxy' => true));
+
+        $result = $curl->download_one($source, null, $options);
+
+        $curlinfo = $curl->get_info();
+
+        fclose($targetfile);
+
+        if ($result !== true) {
+            throw new tool_installaddon_installer_exception('err_curl_exec', array(
+                'url' => $source, 'errorno' => $curl->get_errno(), 'error' => $result));
+
+        } else if (empty($curlinfo['http_code']) or $curlinfo['http_code'] != 200) {
+            throw new tool_installaddon_installer_exception('err_curl_http_code', array(
+                'url' => $source, 'http_code' => $curlinfo['http_code']));
+
+        } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
+            throw new tool_installaddon_installer_exception('err_curl_ssl_verify', array(
+                'url' => $source, 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
+        }
+    }
+
+    //// End of external API ///////////////////////////////////////////////////
+
+    /**
+     * @see self::instance()
+     */
+    protected function __construct() {
+    }
+
+    /**
+     * @return string this site full name
+     */
+    protected function get_site_fullname() {
+        global $SITE;
+
+        return strip_tags($SITE->fullname);
+    }
+
+    /**
+     * @return string this site URL
+     */
+    protected function get_site_url() {
+        global $CFG;
+
+        return $CFG->wwwroot;
+    }
+
+    /**
+     * @return string major version like 2.5, 2.6 etc.
+     */
+    protected function get_site_major_version() {
+        return moodle_major_version();
+    }
+
+    /**
+     * Encodes the given array in a way that can be safely appended as HTTP GET param
+     *
+     * Be ware! The recipient may rely on the exact way how the site information is encoded.
+     * Do not change anything here unless you know what you are doing and understand all
+     * consequences! (Don't you love warnings like that, too? :-p)
+     *
+     * @param array $info
+     * @return string
+     */
+    protected function encode_site_information(array $info) {
+        return base64_encode(json_encode($info));
+    }
+
+    /**
+     * Decide if the encoded site information should be sent to the add-ons repository site
+     *
+     * For now, we just return true. In the future, we may want to implement some
+     * privacy aware logic (based on site/user preferences for example).
+     *
+     * @return bool
+     */
+    protected function should_send_site_info() {
+        return true;
+    }
+
+    /**
+     * Renames the root directory of the extracted ZIP package.
+     *
+     * This method does not validate the presence of the single root directory
+     * (the validator does it later). It just searches for the first directory
+     * under the given location and renames it.
+     *
+     * The method will not rename the root if the requested location already
+     * exists.
+     *
+     * @param string $dirname the location of the extracted ZIP package
+     * @param string $rootdir the requested name of the root directory
+     * @param array $files list of extracted files
+     * @return array eventually amended list of extracted files
+     */
+    protected function rename_extracted_rootdir($dirname, $rootdir, array $files) {
+
+        if (!is_dir($dirname)) {
+            debugging('Unable to rename rootdir of non-existing content', DEBUG_DEVELOPER);
+            return $files;
+        }
+
+        if (file_exists($dirname.'/'.$rootdir)) {
+            debugging('Unable to rename rootdir to already existing folder', DEBUG_DEVELOPER);
+            return $files;
+        }
+
+        $found = null; // The name of the first subdirectory under the $dirname.
+        foreach (scandir($dirname) as $item) {
+            if (substr($item, 0, 1) === '.') {
+                continue;
+            }
+            if (is_dir($dirname.'/'.$item)) {
+                $found = $item;
+                break;
+            }
+        }
+
+        if (!is_null($found)) {
+            if (rename($dirname.'/'.$found, $dirname.'/'.$rootdir)) {
+                $newfiles = array();
+                foreach ($files as $filepath => $status) {
+                    $newpath = preg_replace('~^'.preg_quote($found.'/').'~', preg_quote($rootdir.'/'), $filepath);
+                    $newfiles[$newpath] = $status;
+                }
+                return $newfiles;
+            }
+        }
+
+        return $files;
+    }
+
+    /**
+     * Decode the request from the Moodle Plugins directory
+     *
+     * @param string $request submitted via 'installaddonrequest' HTTP parameter
+     * @return stdClass|bool false on error, object otherwise
+     */
+    protected function decode_remote_request($request) {
+
+        $data = base64_decode($request, true);
+
+        if ($data === false) {
+            return false;
+        }
+
+        $data = json_decode($data);
+
+        if (is_null($data)) {
+            return false;
+        }
+
+        if (!isset($data->name) or !isset($data->component) or !isset($data->version)) {
+            return false;
+        }
+
+        $data->name = s(strip_tags($data->name));
+
+        if ($data->component !== clean_param($data->component, PARAM_COMPONENT)) {
+            return false;
+        }
+
+        list($plugintype, $pluginname) = normalize_component($data->component);
+
+        if ($plugintype === 'core') {
+            return false;
+        }
+
+        if ($data->component !== $plugintype.'_'.$pluginname) {
+            return false;
+        }
+
+        // Keep this regex in sync with the one used by the download.moodle.org/api/x.y/pluginfo.php
+        if (!preg_match('/^[0-9]+$/', $data->version)) {
+            return false;
+        }
+
+        return $data;
+    }
+}
+
+
+/**
+ * General exception thrown by {@link tool_installaddon_installer} class
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_installer_exception extends moodle_exception {
+
+    /**
+     * @param string $errorcode exception description identifier
+     * @param mixed $debuginfo debugging data to display
+     */
+    public function __construct($errorcode, $a=null, $debuginfo=null) {
+        parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
+    }
+}
diff --git a/admin/tool/installaddon/classes/installfromzip_form.php b/admin/tool/installaddon/classes/installfromzip_form.php
new file mode 100644 (file)
index 0000000..18552fa
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * @package     tool_installaddon
+ * @subpackage  classes
+ * @category    form
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir.'/formslib.php');
+
+/**
+ * Defines a simple form for uploading the add-on ZIP package
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_installfromzip extends moodleform {
+
+    /**
+     * Defines the form elements
+     */
+    public function definition() {
+
+        $mform = $this->_form;
+        $installer = $this->_customdata['installer'];
+
+        $mform->addElement('header', 'general', get_string('installfromzip', 'tool_installaddon'));
+        $mform->addHelpButton('general', 'installfromzip', 'tool_installaddon');
+
+        $options = $installer->get_plugin_types_menu();
+        $mform->addElement('select', 'plugintype', get_string('installfromziptype', 'tool_installaddon'), $options,
+            array('id' => 'tool_installaddon_installfromzip_plugintype'));
+        $mform->addHelpButton('plugintype', 'installfromziptype', 'tool_installaddon');
+        $mform->addRule('plugintype', null, 'required', null, 'client');
+
+        $mform->addElement('static', 'permcheck', '',
+            html_writer::span(get_string('permcheck', 'tool_installaddon'), '',
+                array('id' => 'tool_installaddon_installfromzip_permcheck')));
+
+        $mform->addElement('filepicker', 'zipfile', get_string('installfromzipfile', 'tool_installaddon'),
+            null, array('accepted_types' => '.zip'));
+        $mform->addHelpButton('zipfile', 'installfromzipfile', 'tool_installaddon');
+        $mform->addRule('zipfile', null, 'required', null, 'client');
+
+        $mform->addElement('text', 'rootdir', get_string('installfromziprootdir', 'tool_installaddon'));
+        $mform->addHelpButton('rootdir', 'installfromziprootdir', 'tool_installaddon');
+        $mform->setType('rootdir', PARAM_PLUGIN);
+        $mform->setAdvanced('rootdir');
+
+        $mform->addElement('checkbox', 'acknowledgement', get_string('acknowledgement', 'tool_installaddon'),
+            ' '.get_string('acknowledgementtext', 'tool_installaddon'));
+        $mform->addRule('acknowledgement', get_string('acknowledgementmust', 'tool_installaddon'), 'required', null, 'client');
+
+        $this->add_action_buttons(false, get_string('installfromzipsubmit', 'tool_installaddon'));
+    }
+
+    /**
+     * Validate the form fields
+     *
+     * @param array $data
+     * @param array $files
+     * @return array (string)field name => (string)validation error text
+     */
+    public function validation($data, $files) {
+
+        $installer = $this->_customdata['installer'];
+        $errors = parent::validation($data, $files);
+
+        if (!$installer->is_plugintype_writable($data['plugintype'])) {
+            $path = $installer->get_plugintype_root($data['plugintype']);
+            $errors['plugintype'] = get_string('permcheckresultno', 'tool_installaddon', array('path' => $path));
+        }
+
+        return $errors;
+    }
+}
diff --git a/admin/tool/installaddon/classes/pluginfo_client.php b/admin/tool/installaddon/classes/pluginfo_client.php
new file mode 100644 (file)
index 0000000..49cc334
--- /dev/null
@@ -0,0 +1,216 @@
+<?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/>.
+
+/**
+ * Provides tool_installaddon_pluginfo_client and related classes
+ *
+ * @package     tool_installaddon
+ * @subpackage  classes
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Implements a client for https://download.moodle.org/api/x.y/pluginfo.php service
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_pluginfo_client {
+
+    /**
+     * Factory method returning an instance of this class.
+     *
+     * @return tool_installaddon_pluginfo_client
+     */
+    public static function instance() {
+        return new static();
+    }
+
+    /**
+     * Return the information about the plugin
+     *
+     * @throws tool_installaddon_pluginfo_exception
+     * @param string $component
+     * @param string $version
+     * @return stdClass the pluginfo structure
+     */
+    public function get_pluginfo($component, $version) {
+
+        $response = $this->call_service($component, $version);
+        $response = $this->decode_response($response);
+        $this->validate_response($response);
+
+        return $response->pluginfo;
+    }
+
+    // End of external API /////////////////////////////////////////////////
+
+    /**
+     * @see self::instance()
+     */
+    protected function __construct() {
+    }
+
+    /**
+     * Calls the pluginfo.php service and returns the raw response
+     *
+     * @param string $component
+     * @param string $version
+     * @return string
+     */
+    protected function call_service($component, $version) {
+        global $CFG;
+        require_once($CFG->libdir.'/filelib.php');
+
+        $curl = new curl(array('proxy' => true));
+
+        $response = $curl->get(
+            $this->service_request_url(),
+            $this->service_request_params($component, $version),
+            $this->service_request_options());
+
+        $curlerrno = $curl->get_errno();
+        $curlinfo = $curl->get_info();
+
+        if (!empty($curlerrno)) {
+            throw new tool_installaddon_pluginfo_exception('err_curl_exec', array(
+                'url' => $curlinfo['url'], 'errno' => $curlerrno, 'error' => $curl->error));
+
+        } else if ($curlinfo['http_code'] != 200) {
+            throw new tool_installaddon_pluginfo_exception('err_curl_http_code', array(
+                'url' => $curlinfo['url'], 'http_code' => $curlinfo['http_code']));
+
+        } else if (isset($curlinfo['ssl_verify_result']) and $curlinfo['ssl_verify_result'] != 0) {
+            throw new tool_installaddon_pluginfo_exception('err_curl_ssl_verify', array(
+                'url' => $curlinfo['url'], 'ssl_verify_result' => $curlinfo['ssl_verify_result']));
+        }
+
+        return $response;
+    }
+
+    /**
+     * Return URL to the pluginfo.php service
+     *
+     * @return moodle_url
+     */
+    protected function service_request_url() {
+        global $CFG;
+
+        if (!empty($CFG->config_php_settings['alternativepluginfoserviceurl'])) {
+            $url = $CFG->config_php_settings['alternativepluginfoserviceurl'];
+        } else {
+            $url = 'https://download.moodle.org/api/1.2/pluginfo.php';
+        }
+
+        return new moodle_url($url);
+    }
+
+    /**
+     * Return list of pluginfo service parameters
+     *
+     * @param string $component
+     * @param string $version
+     * @return array
+     */
+    protected function service_request_params($component, $version) {
+
+        $params = array();
+        $params['format'] = 'json';
+        $params['plugin'] = $component.'@'.$version;
+
+        return $params;
+    }
+
+    /**
+     * Return cURL options for the service request
+     *
+     * @return array of (string)param => (string)value
+     */
+    protected function service_request_options() {
+        global $CFG;
+
+        $options = array(
+            'CURLOPT_SSL_VERIFYHOST' => 2,      // this is the default in {@link curl} class but just in case
+            'CURLOPT_SSL_VERIFYPEER' => true,
+        );
+
+        $cacertfile = $CFG->dataroot.'/moodleorgca.crt';
+        if (is_readable($cacertfile)) {
+            // Do not use CA certs provided by the operating system. Instead,
+            // use this CA cert to verify the updates provider.
+            $options['CURLOPT_CAINFO'] = $cacertfile;
+        }
+
+        return $options;
+    }
+
+    /**
+     * Decode the raw service response
+     *
+     * @param string $raw
+     * @return stdClass
+     */
+    protected function decode_response($raw) {
+        return json_decode($raw);
+    }
+
+    /**
+     * Validate decoded service response
+     *
+     * @param stdClass $response
+     */
+    protected function validate_response($response) {
+
+        if (empty($response)) {
+            throw new tool_installaddon_pluginfo_exception('err_response_empty');
+        }
+
+        if (empty($response->status) or $response->status !== 'OK') {
+            throw new tool_installaddon_pluginfo_exception('err_response_status', $response->status);
+        }
+
+        if (empty($response->apiver) or $response->apiver !== '1.2') {
+            throw new tool_installaddon_pluginfo_exception('err_response_api_version', $response->apiver);
+        }
+
+        if (empty($response->pluginfo->component) or empty($response->pluginfo->downloadurl)
+                or empty($response->pluginfo->downloadmd5)) {
+            throw new tool_installaddon_pluginfo_exception('err_response_pluginfo');
+        }
+    }
+}
+
+
+/**
+ * General exception thrown by {@link tool_installaddon_pluginfo_client} class
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_pluginfo_exception extends moodle_exception {
+
+    /**
+     * @param string $errorcode exception description identifier
+     * @param mixed $debuginfo debugging data to display
+     */
+    public function __construct($errorcode, $a=null, $debuginfo=null) {
+        parent::__construct($errorcode, 'tool_installaddon', '', $a, print_r($debuginfo, true));
+    }
+}
diff --git a/admin/tool/installaddon/classes/validator.php b/admin/tool/installaddon/classes/validator.php
new file mode 100644 (file)
index 0000000..557fbc1
--- /dev/null
@@ -0,0 +1,575 @@
+<?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/>.
+
+/**
+ * Provides validation class to check the plugin ZIP contents
+ *
+ * Uses fragments of the local_plugins_archive_validator class copyrighted by
+ * Marina Glancy that is part of the local_plugins plugin.
+ *
+ * @package     tool_installaddon
+ * @subpackage  classes
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if (!defined('T_ML_COMMENT')) {
+   define('T_ML_COMMENT', T_COMMENT);
+} else {
+   define('T_DOC_COMMENT', T_ML_COMMENT);
+}
+
+/**
+ * Validates the contents of extracted plugin ZIP file
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_validator {
+
+    /** Critical error message level, causes the validation fail. */
+    const ERROR     = 'error';
+
+    /** Warning message level, validation does not fail but the admin should be always informed. */
+    const WARNING   = 'warning';
+
+    /** Information message level that the admin should be aware of. */
+    const INFO      = 'info';
+
+    /** Debugging message level, should be displayed in debugging mode only. */
+    const DEBUG     = 'debug';
+
+    /** @var string full path to the extracted ZIP contents */
+    protected $extractdir = null;
+
+    /** @var array as returned by {@link zip_packer::extract_to_pathname()} */
+    protected $extractfiles = null;
+
+    /** @var bool overall result of validation */
+    protected $result = null;
+
+    /** @var string the name of the plugin root directory */
+    protected $rootdir = null;
+
+    /** @var array explicit list of expected/required characteristics of the ZIP */
+    protected $assertions = null;
+
+    /** @var array of validation log messages */
+    protected $messages = array();
+
+    /** @var array|null array of relevant data obtained from version.php */
+    protected $versionphp = null;
+
+    /** @var string|null the name of found English language file without the .php extension */
+    protected $langfilename = null;
+
+    /** @var moodle_url|null URL to continue with the installation of validated add-on */
+    protected $continueurl = null;
+
+    /**
+     * Factory method returning instance of the validator
+     *
+     * @param string $zipcontentpath full path to the extracted ZIP contents
+     * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
+     * @return tool_installaddon_validator
+     */
+    public static function instance($zipcontentpath, array $zipcontentfiles) {
+        return new static($zipcontentpath, $zipcontentfiles);
+    }
+
+    /**
+     * Set the expected plugin type, fail the validation otherwise
+     *
+     * @param string $required plugin type
+     */
+    public function assert_plugin_type($required) {
+        $this->assertions['plugintype'] = $required;
+    }
+
+    /**
+     * Set the expectation that the plugin can be installed into the given Moodle version
+     *
+     * @param string $required Moodle version we are about to install to
+     */
+    public function assert_moodle_version($required) {
+        $this->assertions['moodleversion'] = $required;
+    }
+
+    /**
+     * Execute the validation process against all explicit and implicit requirements
+     *
+     * Returns true if the validation passes (all explicit and implicit requirements
+     * pass) and the plugin can be installed. Returns false if the validation fails
+     * (some explicit or implicit requirement fails) and the plugin must not be
+     * installed.
+     *
+     * @return bool
+     */
+    public function execute() {
+
+        $this->result = (
+                $this->validate_files_layout()
+            and $this->validate_version_php()
+            and $this->validate_language_pack()
+            and $this->validate_target_location()
+        );
+
+        return $this->result;
+    }
+
+    /**
+     * Returns overall result of the validation.
+     *
+     * Null is returned if the validation has not been executed yet. Otherwise
+     * this method returns true (the installation can continue) or false (it is not
+     * safe to continue with the installation).
+     *
+     * @return bool|null
+     */
+    public function get_result() {
+        return $this->result;
+    }
+
+    /**
+     * Return the list of validation log messages
+     *
+     * Each validation message is a plain object with properties level, msgcode
+     * and addinfo.
+     *
+     * @return array of (int)index => (stdClass) validation message
+     */
+    public function get_messages() {
+        return $this->messages;
+    }
+
+    /**
+     * Return the information provided by the the plugin's version.php
+     *
+     * If version.php was not found in the plugin (which is tolerated for
+     * themes only at the moment), null is returned. Otherwise the array
+     * is returned. It may be empty if no information was parsed (which
+     * should not happen).
+     *
+     * @return null|array
+     */
+    public function get_versionphp_info() {
+        return $this->versionphp;
+    }
+
+    /**
+     * Returns the name of the English language file without the .php extension
+     *
+     * This can be used as a suggestion for fixing the plugin root directory in the
+     * ZIP file during the upload. If no file was found, or multiple PHP files are
+     * located in lang/en/ folder, then null is returned.
+     *
+     * @return null|string
+     */
+    public function get_language_file_name() {
+        return $this->langfilename;
+    }
+
+    /**
+     * Returns the rootdir of the extracted package (after eventual renaming)
+     *
+     * @return string|null
+     */
+    public function get_rootdir() {
+        return $this->rootdir;
+    }
+
+    /**
+     * Sets the URL to continue to after successful validation
+     *
+     * @param moodle_url $url
+     */
+    public function set_continue_url(moodle_url $url) {
+        $this->continueurl = $url;
+    }
+
+    /**
+     * Get the URL to continue to after successful validation
+     *
+     * Null is returned if the URL has not been explicitly set by the caller.
+     *
+     * @return moodle_url|null
+     */
+    public function get_continue_url() {
+        return $this->continueurl;
+    }
+
+    // End of external API /////////////////////////////////////////////////////
+
+    /**
+     * @param string $zipcontentpath full path to the extracted ZIP contents
+     * @param array $zipcontentfiles (string)filerelpath => (bool|string)true or error
+     */
+    protected function __construct($zipcontentpath, array $zipcontentfiles) {
+        $this->extractdir = $zipcontentpath;
+        $this->extractfiles = $zipcontentfiles;
+    }
+
+    // Validation methods //////////////////////////////////////////////////////
+
+    /**
+     * @return bool false if files in the ZIP do not have required layout
+     */
+    protected function validate_files_layout() {
+
+        if (!is_array($this->extractfiles) or count($this->extractfiles) < 4) {
+            // We need the English language pack with the name of the plugin at least
+            $this->add_message(self::ERROR, 'filesnumber');
+            return false;
+        }
+
+        foreach ($this->extractfiles as $filerelname => $filestatus) {
+            if ($filestatus !== true) {
+                $this->add_message(self::ERROR, 'filestatus', array('file' => $filerelname, 'status' => $filestatus));
+                return false;
+            }
+        }
+
+        foreach (array_keys($this->extractfiles) as $filerelname) {
+            if (!file_exists($this->extractdir.'/'.$filerelname)) {
+                $this->add_message(self::ERROR, 'filenotexists', array('file' => $filerelname));
+                return false;
+            }
+        }
+
+        foreach (array_keys($this->extractfiles) as $filerelname) {
+            $matches = array();
+            if (!preg_match("#^([^/]+)/#", $filerelname, $matches) or (!is_null($this->rootdir) and $this->rootdir !== $matches[1])) {
+                $this->add_message(self::ERROR, 'onedir');
+                return false;
+            }
+            $this->rootdir = $matches[1];
+        }
+
+        if ($this->rootdir !== clean_param($this->rootdir, PARAM_PLUGIN)) {
+            $this->add_message(self::ERROR, 'rootdirinvalid', $this->rootdir);
+            return false;
+        } else {
+            $this->add_message(self::INFO, 'rootdir', $this->rootdir);
+        }
+
+        return is_dir($this->extractdir.'/'.$this->rootdir);
+    }
+
+    /**
+     * @return bool false if the version.php file does not declare required information
+     */
+    protected function validate_version_php() {
+
+        if (!isset($this->assertions['plugintype'])) {
+            throw new coding_exception('Required plugin type must be set before calling this');
+        }
+
+        if (!isset($this->assertions['moodleversion'])) {
+            throw new coding_exception('Required Moodle version must be set before calling this');
+        }
+
+        $fullpath = $this->extractdir.'/'.$this->rootdir.'/version.php';
+
+        if (!file_exists($fullpath)) {
+            // This is tolerated for themes only.
+            if ($this->assertions['plugintype'] === 'theme') {
+                $this->add_message(self::DEBUG, 'missingversionphp');
+                return true;
+            } else {
+                $this->add_message(self::ERROR, 'missingversionphp');
+                return false;
+            }
+        }
+
+        $this->versionphp = array();
+        $info = $this->parse_version_php($fullpath);
+
+        if ($this->assertions['plugintype'] === 'mod') {
+            $type = 'module';
+        } else {
+            $type = 'plugin';
+        }
+
+        if (!isset($info[$type.'->version'])) {
+            if ($type === 'module' and isset($info['plugin->version'])) {
+                // Expect the activity module using $plugin in version.php instead of $module.
+                $type = 'plugin';
+                $this->versionphp['version'] = $info[$type.'->version'];
+                $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
+            } else {
+                $this->add_message(self::ERROR, 'missingversion');
+                return false;
+            }
+        } else {
+            $this->versionphp['version'] = $info[$type.'->version'];
+            $this->add_message(self::INFO, 'pluginversion', $this->versionphp['version']);
+        }
+
+        if (isset($info[$type.'->requires'])) {
+            $this->versionphp['requires'] = $info[$type.'->requires'];
+            if ($this->versionphp['requires'] > $this->assertions['moodleversion']) {
+                $this->add_message(self::ERROR, 'requiresmoodle', $this->versionphp['requires']);
+                return false;
+            }
+            $this->add_message(self::INFO, 'requiresmoodle', $this->versionphp['requires']);
+        }
+
+        if (isset($info[$type.'->component'])) {
+            $this->versionphp['component'] = $info[$type.'->component'];
+            list($reqtype, $reqname) = normalize_component($this->versionphp['component']);
+            if ($reqtype !== $this->assertions['plugintype']) {
+                $this->add_message(self::ERROR, 'componentmismatchtype', array(
+                    'expected' => $this->assertions['plugintype'],
+                    'found' => $reqtype));
+                return false;
+            }
+            if ($reqname !== $this->rootdir) {
+                $this->add_message(self::ERROR, 'componentmismatchname', $reqname);
+                return false;
+            }
+            $this->add_message(self::INFO, 'componentmatch', $this->versionphp['component']);
+        }
+
+        if (isset($info[$type.'->maturity'])) {
+            $this->versionphp['maturity'] = $info[$type.'->maturity'];
+            if ($this->versionphp['maturity'] === 'MATURITY_STABLE') {
+                $this->add_message(self::INFO, 'maturity', $this->versionphp['maturity']);
+            } else {
+                $this->add_message(self::WARNING, 'maturity', $this->versionphp['maturity']);
+            }
+        }
+
+        if (isset($info[$type.'->release'])) {
+            $this->versionphp['release'] = $info[$type.'->release'];
+            $this->add_message(self::INFO, 'release', $this->versionphp['release']);
+        }
+
+        return true;
+    }
+
+    /**
+     * @return bool false if the English language pack is not provided correctly
+     */
+    protected function validate_language_pack() {
+
+        if (!isset($this->assertions['plugintype'])) {
+            throw new coding_exception('Required plugin type must be set before calling this');
+        }
+
+        if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'])
+                or $this->extractfiles[$this->rootdir.'/lang/en/'] !== true
+                or !is_dir($this->extractdir.'/'.$this->rootdir.'/lang/en')) {
+            $this->add_message(self::ERROR, 'missinglangenfolder');
+            return false;
+        }
+
+        $langfiles = array();
+        foreach (array_keys($this->extractfiles) as $extractfile) {
+            $matches = array();
+            if (preg_match('#^'.preg_quote($this->rootdir).'/lang/en/([^/]+).php?$#i', $extractfile, $matches)) {
+                $langfiles[] = $matches[1];
+            }
+        }
+
+        if (empty($langfiles)) {
+            $this->add_message(self::ERROR, 'missinglangenfile');
+            return false;
+        } else if (count($langfiles) > 1) {
+            $this->add_message(self::WARNING, 'multiplelangenfiles');
+        } else {
+            $this->langfilename = $langfiles[0];
+            $this->add_message(self::DEBUG, 'foundlangfile', $this->langfilename);
+        }
+
+        if ($this->assertions['plugintype'] === 'mod') {
+            $expected = $this->rootdir.'.php';
+        } else {
+            $expected = $this->assertions['plugintype'].'_'.$this->rootdir.'.php';
+        }
+
+        if (!isset($this->extractfiles[$this->rootdir.'/lang/en/'.$expected])
+                or $this->extractfiles[$this->rootdir.'/lang/en/'.$expected] !== true
+                or !is_file($this->extractdir.'/'.$this->rootdir.'/lang/en/'.$expected)) {
+            $this->add_message(self::ERROR, 'missingexpectedlangenfile', $expected);
+            return false;
+        }
+
+        return true;
+    }
+
+
+    /**
+     * @return bool false of the given add-on can't be installed into its location
+     */
+    public function validate_target_location() {
+
+        if (!isset($this->assertions['plugintype'])) {
+            throw new coding_exception('Required plugin type must be set before calling this');
+        }
+
+        $plugintypepath = $this->get_plugintype_location($this->assertions['plugintype']);
+
+        if (is_null($plugintypepath)) {
+            $this->add_message(self::ERROR, 'unknowntype', $this->assertions['plugintype']);
+            return false;
+        }
+
+        if (!is_dir($plugintypepath)) {
+            throw new coding_exception('Plugin type location does not exist!');
+        }
+
+        $target = $plugintypepath.'/'.$this->rootdir;
+
+        if (file_exists($target)) {
+            $this->add_message(self::ERROR, 'targetexists', $target);
+            return false;
+        }
+
+        if (is_writable($plugintypepath)) {
+            $this->add_message(self::INFO, 'pathwritable', $plugintypepath);
+        } else {
+            $this->add_message(self::ERROR, 'pathwritable', $plugintypepath);
+            return false;
+        }
+
+        return true;
+    }
+
+    // Helper methods //////////////////////////////////////////////////////////
+
+    /**
+     * Get as much information from existing version.php as possible
+     *
+     * @param string full path to the version.php file
+     * @return array of found meta-info declarations
+     */
+    protected function parse_version_php($fullpath) {
+
+        $content = $this->get_stripped_file_contents($fullpath);
+
+        preg_match_all('#\$((plugin|module)\->(version|maturity|release|requires))=()(\d+(\.\d+)?);#m', $content, $matches1);
+        preg_match_all('#\$((plugin|module)\->(maturity))=()(MATURITY_\w+);#m', $content, $matches2);
+        preg_match_all('#\$((plugin|module)\->(release))=([\'"])(.*?)\4;#m', $content, $matches3);
+        preg_match_all('#\$((plugin|module)\->(component))=([\'"])(.+?_.+?)\4;#m', $content, $matches4);
+
+        if (count($matches1[1]) + count($matches2[1]) + count($matches3[1]) + count($matches4[1])) {
+            $info = array_combine(
+                array_merge($matches1[1], $matches2[1], $matches3[1], $matches4[1]),
+                array_merge($matches1[5], $matches2[5], $matches3[5], $matches4[5])
+            );
+
+        } else {
+            $info = array();
+        }
+
+        return $info;
+    }
+
+    /**
+     * Append the given message to the messages log
+     *
+     * @param string $level e.g. self::ERROR
+     * @param string $msgcode may form a string
+     * @param string|array|object $a optional additional info suitable for {@link get_string()}
+     */
+    protected function add_message($level, $msgcode, $a = null) {
+        $msg = (object)array(
+            'level'     => $level,
+            'msgcode'   => $msgcode,
+            'addinfo'   => $a,
+        );
+        $this->messages[] = $msg;
+    }
+
+    /**
+     * Returns bare PHP code from the given file
+     *
+     * Returns contents without PHP opening and closing tags, text outside php code,
+     * comments and extra whitespaces.
+     *
+     * @param string $fullpath full path to the file
+     * @return string
+     */
+    protected function get_stripped_file_contents($fullpath) {
+
+        $source = file_get_contents($fullpath);
+        $tokens = token_get_all($source);
+        $output = '';
+        $doprocess = false;
+        foreach ($tokens as $token) {
+            if (is_string($token)) {
+                // Simple one character token.
+                $id = -1;
+                $text = $token;
+            } else {
+                // Token array.
+                list($id, $text) = $token;
+            }
+            switch ($id) {
+            case T_WHITESPACE:
+            case T_COMMENT:
+            case T_ML_COMMENT:
+            case T_DOC_COMMENT:
+                // Ignore whitespaces, inline comments, multiline comments and docblocks.
+                break;
+            case T_OPEN_TAG:
+                // Start processing.
+                $doprocess = true;
+                break;
+            case T_CLOSE_TAG:
+                // Stop processing.
+                $doprocess = false;
+                break;
+            default:
+                // Anything else is within PHP tags, return it as is.
+                if ($doprocess) {
+                    $output .= $text;
+                    if ($text === 'function') {
+                        // Explicitly keep the whitespace that would be ignored.
+                        $output .= ' ';
+                    }
+                }
+                break;
+            }
+        }
+
+        return $output;
+    }
+
+
+    /**
+     * Returns the full path to the root directory of the given plugin type
+     *
+     * @param string $plugintype
+     * @return string|null
+     */
+    public function get_plugintype_location($plugintype) {
+
+        $plugintypepath = null;
+
+        foreach (get_plugin_types() as $type => $fullpath) {
+            if ($type === $plugintype) {
+                $plugintypepath = $fullpath;
+                break;
+            }
+        }
+
+        return $plugintypepath;
+    }
+}
diff --git a/admin/tool/installaddon/deploy.php b/admin/tool/installaddon/deploy.php
new file mode 100644 (file)
index 0000000..932e9f2
--- /dev/null
@@ -0,0 +1,76 @@
+<?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/>.
+
+/**
+ * Deploy the validated contents of the ZIP package to the $CFG->dirroot
+ *
+ * @package     tool_installaddon
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(dirname(__FILE__) . '/../../../config.php');
+require_once($CFG->libdir.'/filelib.php');
+require_once(dirname(__FILE__).'/classes/installer.php');
+require_once(dirname(__FILE__).'/classes/validator.php');
+
+require_login();
+require_capability('moodle/site:config', context_system::instance());
+
+if (!empty($CFG->disableonclickaddoninstall)) {
+    notice(get_string('featuredisabled', 'tool_installaddon'));
+}
+
+require_sesskey();
+
+$jobid = required_param('jobid', PARAM_ALPHANUM);
+$plugintype = required_param('type', PARAM_ALPHANUMEXT);
+$pluginname = required_param('name', PARAM_PLUGIN);
+
+$zipcontentpath = $CFG->tempdir.'/tool_installaddon/'.$jobid.'/contents';
+
+if (!is_dir($zipcontentpath)) {
+    debugging('Invalid location of the extracted ZIP package: '.s($zipcontentpath), DEBUG_DEVELOPER);
+    redirect(new moodle_url('/admin/tool/installaddon/index.php'),
+        get_string('invaliddata', 'core_error'));
+}
+
+if (!is_dir($zipcontentpath.'/'.$pluginname)) {
+    debugging('Invalid location of the plugin root directory: '.$zipcontentpath.'/'.$pluginname, DEBUG_DEVELOPER);
+    redirect(new moodle_url('/admin/tool/installaddon/index.php'),
+        get_string('invaliddata', 'core_error'));
+}
+
+$installer = tool_installaddon_installer::instance();
+
+if (!$installer->is_plugintype_writable($plugintype)) {
+    debugging('Plugin type location not writable', DEBUG_DEVELOPER);
+    redirect(new moodle_url('/admin/tool/installaddon/index.php'),
+        get_string('invaliddata', 'core_error'));
+}
+
+$plugintypepath = $installer->get_plugintype_root($plugintype);
+
+if (file_exists($plugintypepath.'/'.$pluginname)) {
+    debugging('Target location already exists', DEBUG_DEVELOPER);
+    redirect(new moodle_url('/admin/tool/installaddon/index.php'),
+        get_string('invaliddata', 'core_error'));
+}
+
+rename($zipcontentpath.'/'.$pluginname, $plugintypepath.'/'.$pluginname);
+fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid);
+redirect(new moodle_url('/admin'));
diff --git a/admin/tool/installaddon/index.php b/admin/tool/installaddon/index.php
new file mode 100644 (file)
index 0000000..cb0d2a6
--- /dev/null
@@ -0,0 +1,67 @@
+<?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/>.
+
+/**
+ * The main screen of the tool.
+ *
+ * @package     tool_installaddon
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(dirname(__FILE__) . '/../../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+require_once(dirname(__FILE__).'/classes/installer.php');
+
+admin_externalpage_setup('tool_installaddon_index');
+
+if (!empty($CFG->disableonclickaddoninstall)) {
+    notice(get_string('featuredisabled', 'tool_installaddon'));
+}
+
+$installer = tool_installaddon_installer::instance();
+
+$output = $PAGE->get_renderer('tool_installaddon');
+$output->set_installer_instance($installer);
+
+// Handle the eventual request for installing from remote repository.
+$remoterequest = optional_param('installaddonrequest', null, PARAM_RAW);
+$confirmed = optional_param('confirm', false, PARAM_BOOL);
+$installer->handle_remote_request($output, $remoterequest, $confirmed);
+
+$form = $installer->get_installfromzip_form();
+
+if ($form->is_cancelled()) {
+    redirect($PAGE->url);
+
+} else if ($data = $form->get_data()) {
+    // Save the ZIP file into a temporary location.
+    $jobid = md5(rand().uniqid('', true));
+    $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
+    $zipfilename = $installer->save_installfromzip_file($form, $sourcedir);
+    // Redirect to the validation page.
+    $nexturl = new moodle_url('/admin/tool/installaddon/validate.php', array(
+        'sesskey' => sesskey(),
+        'jobid' => $jobid,
+        'zip' => $zipfilename,
+        'type' => $data->plugintype,
+        'rootdir' => $data->rootdir));
+    redirect($nexturl);
+}
+
+// Output starts here.
+echo $output->index_page();
diff --git a/admin/tool/installaddon/lang/en/tool_installaddon.php b/admin/tool/installaddon/lang/en/tool_installaddon.php
new file mode 100644 (file)
index 0000000..bca494f
--- /dev/null
@@ -0,0 +1,102 @@
+<?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/>.
+
+/**
+ * Strings for the tool_installaddon component.
+ *
+ * @package     tool_installaddon
+ * @category    string
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['acknowledgement'] = 'Acknowledgement';
+$string['acknowledgementmust'] = 'You must acknowledge this';
+$string['acknowledgementtext'] = 'I understand that it is my responsibility to have full backups of this site prior to installing add-ons. I accept and understand that add-ons (especially but not only those originating in unofficial sources) may contain security holes, can make the site unavailable, or cause private data leaks or loss.';
+$string['featuredisabled'] = 'Add-on installer is disabled at this site.';
+$string['installaddon'] = 'Install add-on!';
+$string['installaddons'] = 'Install add-ons';
+$string['installexception'] = 'Oops... An error occured while trying to install the add-on. Turn debugging mode on to see more details about the error.';
+$string['installfromrepo'] = 'Install add-ons from Moodle plugins directory';
+$string['installfromrepo_help'] = 'You will be redirected to the Moodle plugins directory to search for and install an add-on. Note that your site fullname, URL and major version will be sent as well, to make the installation process easier for you.';
+$string['installfromzip'] = 'Install add-on from the ZIP file';
+$string['installfromzip_help'] = 'Alternatively to installing add-ons directly from the Moodle plugins directory, you can install add-ons from manually uploaded ZIP packages. Such ZIP packages are expected to have same structure as the ones available in the Moodle plugins directory.';
+$string['installfromzipfile'] = 'ZIP package';
+$string['installfromzipfile_help'] = 'The plugin ZIP package must contain just one directory with the name of the plugin. The ZIP will be extracted into the appropriate location for the given plugin type. Packages downloaded from the Moodle plugins directory have this format.';
+$string['installfromziprootdir'] = 'Rename the root directory';
+$string['installfromziprootdir_help'] = 'Some ZIP packages, such as those generated by Github, may contain incorrect name of the root directory. You can rename the root directory of the extracted package to the correct value defined in this field.';
+$string['installfromzipsubmit'] = 'Install add-on from the ZIP file';
+$string['installfromziptype'] = 'Plugin type';
+$string['installfromziptype_help'] = 'Choose the correct type of plugin you are about to install. The installation procedure may fail badly when incorrect plugin type is provided.';
+$string['permcheck'] = 'Make sure the plugin type root location is writable by the web server process';
+$string['permcheckerror'] = 'Error while checking for write permission';
+$string['permcheckprogress'] = 'Checking for write permission ...';
+$string['permcheckresultno'] = 'Plugin type location <em>{$a->path}</em> not writable';
+$string['permcheckresultyes'] = 'Plugin type location <em>{$a->path}</em> is writable';
+$string['pluginname'] = 'Add-on installer';
+$string['remoterequestalreadyinstalled'] = 'There is a request to install add-on {$a->name} ({$a->component}) version {$a->version} from the Moodle plugins directory to this site. This plugin is <strong>already installed</strong> at this site.';
+$string['remoterequestconfirm'] = 'There is a request to install add-on <strong>{$a->name}</strong> ({$a->component}) version {$a->version} from the Moodle plugins directory to this site. If you continue, the add-on ZIP package will be downloaded for validation. Nothing will be installed yet.';
+$string['remoterequestinvalid'] = 'There is a request to install add-on from the Moodle plugins directory to this site. Unfortunately, the request is not valid. The add-on cannot be installed.';
+$string['remoterequestpermcheck'] = 'There is a request to install add-on {$a->name} ({$a->component}) version {$a->version} from the Moodle plugins directory to this site. The plugin type location <strong>{$a->typepath}</strong> is <strong>not writable</strong> though. You need to give the write access for the web server user to the plugin type location now. Once the write access is granted, press the continue button to repeat the check.';
+$string['remoterequestpluginfoexception'] = 'Oops... An error occured while trying to obtain information about the add-on {$a->name} ({$a->component}) version {$a->version}. The add-on cannot be installed. Turn debugging mode on to see more details about the error.';
+$string['validation'] = 'Add-on package validation';
+$string['validationmsg_componentmatch'] = 'Full component name';
+$string['validationmsg_componentmismatchname'] = 'Add-on name mismatch';
+$string['validationmsg_componentmismatchname_help'] = 'Some ZIP packages, such as those generated by Github, may contain incorrect name of the root directory. You have to fix the name of the root directory to match the declared add-on name.';
+$string['validationmsg_componentmismatchname_info'] = 'The add-on declares its name is \'{$a}\' but that does not match the name of the root directory.';
+$string['validationmsg_componentmismatchtype'] = 'Add-on type mismatch';
+$string['validationmsg_componentmismatchtype_info'] = 'You have selected the type \'{$a->expected}\' but the add-on declares its type is \'{$a->found}\'.';
+$string['validationmsg_filenotexists'] = 'Extracted file not found';
+$string['validationmsg_filesnumber'] = 'Not enough files found in the package';
+$string['validationmsg_filestatus'] = 'Unable to extract all files';
+$string['validationmsg_filestatus_info'] = 'Attempting to extract file {$a->file} resulted in error \'{$a->status}\'.';
+$string['validationmsg_maturity'] = 'Declared maturity level';
+$string['validationmsg_maturity_help'] = 'The add-on can declare its maturity level. If the maintainer considers the add-on stable, the declared maturity level will read MATURITY_STABLE. All other maturity levels (such as alpha or beta) should be considered unstable and a warning is raised.';
+$string['validationmsg_missingexpectedlangenfile'] = 'English language file name mismatch';
+$string['validationmsg_missingexpectedlangenfile_info'] = 'The given add-on type would need to provide the English language file {$a}.';
+$string['validationmsg_missinglangenfile'] = 'No English language file found';
+$string['validationmsg_missinglangenfolder'] = 'Missing English language folder';
+$string['validationmsg_missingversion'] = 'Add-on does not declare its version';
+$string['validationmsg_missingversionphp'] = 'File version.php not found';
+$string['validationmsg_multiplelangenfiles'] = 'Multiple English language files found';
+$string['validationmsg_onedir'] = 'Invalid structure of the ZIP package.';
+$string['validationmsg_onedir_help'] = 'The ZIP package must contain just one root directory that holds the add-on code. The name of that root directory must match the name of the plugin.';
+$string['validationmsg_pathwritable'] = 'Write access check';
+$string['validationmsg_pluginversion'] = 'Add-on version';
+$string['validationmsg_release'] = 'Add-on release';
+$string['validationmsg_requiresmoodle'] = 'Required Moodle version';
+$string['validationmsg_rootdir'] = 'Name of the add-on to be installed';
+$string['validationmsg_rootdir_help'] = 'The name of the root directory in the ZIP package forms the name of the add-on to be installed. If the name is not correct, you may wish to rename the root directory in the ZIP prior to installing the add-on.';
+$string['validationmsg_rootdirinvalid'] = 'Invalid name of the add-on';
+$string['validationmsg_rootdirinvalid_help'] = 'The name of the root directory in the ZIP package violates formal syntax requirements. Some ZIP packages, such as those generated by Github, may contain incorrect name of the root directory. You have to fix the name of the root directory to match the add-on name.';
+$string['validationmsg_targetexists'] = 'Target location already exists';
+$string['validationmsg_targetexists_help'] = 'The directory that the add-on is to be installed to, must not exist yet.';
+$string['validationmsg_unknowntype'] = 'Unknown plugin type';
+$string['validationmsglevel_debug'] = 'Debug';
+$string['validationmsglevel_error'] = 'Error';
+$string['validationmsglevel_info'] = 'OK';
+$string['validationmsglevel_warning'] = 'Warning';
+$string['validationresult0'] = 'Validation failed!';
+$string['validationresult0_help'] = 'Some serious problem was detected. It is not safe to install the add-on. See the validation log messages for more details.';
+$string['validationresult1'] = 'Validation passed!';
+$string['validationresult1_help'] = 'No serious problems were detected. You can continue with the add-on installation. See the validation log messages for more details and eventual warnings.';
+$string['validationresult1_help'] = 'The add-on package has been validated and no serious problems were detected.';
+$string['validationresultinfo'] = 'Info';
+$string['validationresultmsg'] = 'Message';
+$string['validationresultstatus'] = 'Status';
diff --git a/admin/tool/installaddon/permcheck.php b/admin/tool/installaddon/permcheck.php
new file mode 100644 (file)
index 0000000..f38d109
--- /dev/null
@@ -0,0 +1,74 @@
+<?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/>.
+
+/**
+ * Checks the write permission for the given plugin type
+ *
+ * @package     tool_installaddon
+ * @subpackage  ajax
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('AJAX_SCRIPT', true);
+
+require(dirname(__FILE__) . '/../../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+require_once(dirname(__FILE__).'/classes/installer.php');
+
+require_login();
+
+if (!has_capability('moodle/site:config', context_system::instance())) {
+    header('HTTP/1.1 403 Forbidden');
+    die();
+}
+
+if (!empty($CFG->disableonclickaddoninstall)) {
+    header('HTTP/1.1 403 Forbidden');
+    die();
+}
+
+if (!confirm_sesskey()) {
+    header('HTTP/1.1 403 Forbidden');
+    die();
+}
+
+$plugintype = optional_param('plugintype', null, PARAM_ALPHANUMEXT);
+if (is_null($plugintype)) {
+    header('HTTP/1.1 400 Bad Request');
+    die();
+}
+
+$installer = tool_installaddon_installer::instance();
+
+$plugintypepath = $installer->get_plugintype_root($plugintype);
+
+if (empty($plugintypepath)) {
+    header('HTTP/1.1 400 Bad Request');
+    die();
+}
+
+$response = array('path' => $plugintypepath);
+
+if ($installer->is_plugintype_writable($plugintype)) {
+    $response['writable'] = 1;
+} else {
+    $response['writable'] = 0;
+}
+
+header('Content-Type: application/json; charset: utf-8');
+echo json_encode($response);
diff --git a/admin/tool/installaddon/pix/icon.png b/admin/tool/installaddon/pix/icon.png
new file mode 100644 (file)
index 0000000..f2d450b
Binary files /dev/null and b/admin/tool/installaddon/pix/icon.png differ
diff --git a/admin/tool/installaddon/pix/icon.svg b/admin/tool/installaddon/pix/icon.svg
new file mode 100644 (file)
index 0000000..19dead1
--- /dev/null
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="utf-8"?>\r
+<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In  -->\r
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
+       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
+]>\r
+<svg version="1.1"\r
+        xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"\r
+        x="0px" y="0px" width="16px" height="16px" viewBox="0 0 16 16" style="overflow:visible;enable-background:new 0 0 16 16;"\r
+        xml:space="preserve" preserveAspectRatio="xMinYMid meet">\r
+<defs>\r
+</defs>\r
+<path style="fill:#999999;" d="M16,9v6c0,0.5-0.5,1-1,1h-1H2H1c-0.5,0-1-0.5-1-1V9c0-0.5,0.5-1,1-1h1c0.5,0,1,0.5,1,1v4h10V9\r
+       c0-0.5,0.5-1,1-1h1C15.5,8,16,8.5,16,9z M12.4,5.1l-0.7-0.7c-0.4-0.4-1-0.4-1.4,0L9.5,5.2V1c0-0.5-0.4-1-1-1h-1c-0.5,0-1,0.5-1,1\r
+       v4.2L5.7,4.4C5.3,4,4.7,4,4.3,4.4L3.6,5.1c-0.4,0.4-0.4,1,0,1.4l3.7,3.7c0.2,0.2,0.5,0.3,0.7,0.3c0.3,0,0.5-0.1,0.7-0.3l3.7-3.7\r
+       C12.8,6.2,12.8,5.5,12.4,5.1z"/>\r
+</svg>\r
diff --git a/admin/tool/installaddon/renderer.php b/admin/tool/installaddon/renderer.php
new file mode 100644 (file)
index 0000000..c4c9706
--- /dev/null
@@ -0,0 +1,398 @@
+<?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/>.
+
+/**
+ * Output rendering for the plugin.
+ *
+ * @package     tool_installaddon
+ * @category    output
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Implements the plugin renderer
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_renderer extends plugin_renderer_base {
+
+    /** @var tool_installaddon_installer */
+    protected $installer = null;
+
+    /** @var tool_installaddon_validator */
+    protected $validator = null;
+
+    /**
+     * Sets the tool_installaddon_installer instance being used.
+     *
+     * @throws coding_exception if the installer has been already set
+     * @param tool_installaddon_installer $installer
+     */
+    public function set_installer_instance(tool_installaddon_installer $installer) {
+        if (is_null($this->installer)) {
+            $this->installer = $installer;
+        } else {
+            throw new coding_exception('Attempting to reset the installer instance.');
+        }
+    }
+
+    /**
+     * Sets the tool_installaddon_validator instance being used.
+     *
+     * @throws coding_exception if the validator has been already set
+     * @param tool_installaddon_validator $validator
+     */
+    public function set_validator_instance(tool_installaddon_validator $validator) {
+        if (is_null($this->validator)) {
+            $this->validator = $validator;
+        } else {
+            throw new coding_exception('Attempting to reset the validator instance.');
+        }
+    }
+
+    /**
+     * Defines the index page layout
+     *
+     * @return string
+     */
+    public function index_page() {
+
+        if (is_null($this->installer)) {
+            throw new coding_exception('Installer instance has not been set.');
+        }
+
+        $permcheckurl = new moodle_url('/admin/tool/installaddon/permcheck.php');
+        $this->page->requires->yui_module('moodle-tool_installaddon-permcheck', 'M.tool_installaddon.permcheck.init',
+            array(array('permcheckurl' => $permcheckurl->out())));
+        $this->page->requires->strings_for_js(
+            array('permcheckprogress', 'permcheckresultno', 'permcheckresultyes', 'permcheckerror'), 'tool_installaddon');
+
+        $out = $this->output->header();
+        $out .= $this->index_page_heading();
+        $out .= $this->index_page_repository();
+        $out .= $this->index_page_upload();
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Defines the validation results page layout
+     *
+     * @return string
+     */
+    public function validation_page() {
+
+        if (is_null($this->installer)) {
+            throw new coding_exception('Installer instance has not been set.');
+        }
+
+        if (is_null($this->validator)) {
+            throw new coding_exception('Validator instance has not been set.');
+        }
+
+        $out = $this->output->header();
+        $out .= $this->validation_page_heading();
+        $out .= $this->validation_page_messages();
+        $out .= $this->validation_page_continue();
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Inform the user about invalid remote installation request.
+     *
+     * @param moodle_url $continueurl
+     * @return string
+     */
+    public function remote_request_invalid_page(moodle_url $continueurl) {
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->box(get_string('remoterequestinvalid', 'tool_installaddon'), 'generalbox', 'notice');
+        $out .= $this->output->continue_button($continueurl, 'get');
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Inform the user that such plugin is already installed
+     *
+     * @param stdClass $data decoded request data
+     * @param moodle_url $continueurl
+     * @return string
+     */
+    public function remote_request_alreadyinstalled_page(stdClass $data, moodle_url $continueurl) {
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->box(get_string('remoterequestalreadyinstalled', 'tool_installaddon', $data), 'generalbox', 'notice');
+        $out .= $this->output->continue_button($continueurl, 'get');
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Let the user confirm the remote installation request.
+     *
+     * @param stdClass $data decoded request data
+     * @param moodle_url $continueurl
+     * @param moodle_url $cancelurl
+     * @return string
+     */
+    public function remote_request_confirm_page(stdClass $data, moodle_url $continueurl, moodle_url $cancelurl) {
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->confirm(get_string('remoterequestconfirm', 'tool_installaddon', $data), $continueurl, $cancelurl);
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Inform the user that the target plugin type location is not writable.
+     *
+     * @param stdClass $data decoded request data
+     * @param string $plugintypepath full path to the plugin type location
+     * @param moodle_url $continueurl to repeat the write permission check
+     * @param moodle_url $cancelurl to cancel the installation
+     * @return string
+     */
+    public function remote_request_permcheck_page(stdClass $data, $plugintypepath, moodle_url $continueurl, moodle_url $cancelurl) {
+
+        $data->typepath = $plugintypepath;
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->confirm(get_string('remoterequestpermcheck', 'tool_installaddon', $data), $continueurl, $cancelurl);
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Inform the user about pluginfo service call exception
+     *
+     * This implementation does not actually use the passed exception. Custom renderers might want to
+     * display additional data obtained via {@link get_exception_info()}. Also note, this method is called
+     * in non-debugging mode only. If debugging is allowed at the site, default exception handler is triggered.
+     *
+     * @param stdClass $data decoded request data
+     * @param tool_installaddon_pluginfo_exception $e thrown exception
+     * @param moodle_url $continueurl
+     * @return string
+     */
+    public function remote_request_pluginfo_exception(stdClass $data, tool_installaddon_pluginfo_exception $e, moodle_url $continueurl) {
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->box(get_string('remoterequestpluginfoexception', 'tool_installaddon', $data), 'generalbox', 'notice');
+        $out .= $this->output->continue_button($continueurl, 'get');
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    /**
+     * Inform the user about the installer exception
+     *
+     * This implementation does not actually use the passed exception. Custom renderers might want to
+     * display additional data obtained via {@link get_exception_info()}. Also note, this method is called
+     * in non-debugging mode only. If debugging is allowed at the site, default exception handler is triggered.
+     *
+     * @param tool_installaddon_installer_exception $e thrown exception
+     * @param moodle_url $continueurl
+     * @return string
+     */
+    public function installer_exception(tool_installaddon_installer_exception $e, moodle_url $continueurl) {
+
+        $out = $this->output->header();
+        $out .= $this->output->heading(get_string('installfromrepo', 'tool_installaddon'));
+        $out .= $this->output->box(get_string('installexception', 'tool_installaddon'), 'generalbox', 'notice');
+        $out .= $this->output->continue_button($continueurl, 'get');
+        $out .= $this->output->footer();
+
+        return $out;
+    }
+
+    // End of the external API /////////////////////////////////////////////////
+
+    /**
+     * Renders the index page heading
+     *
+     * @return string
+     */
+    protected function index_page_heading() {
+        return $this->output->heading(get_string('pluginname', 'tool_installaddon'));
+    }
+
+    /**
+     * Renders the widget for browsing the add-on repository
+     *
+     * @return string
+     */
+    protected function index_page_repository() {
+
+        $url = $this->installer->get_addons_repository_url();
+
+        $out = $this->box(
+            $this->output->single_button($url, get_string('installfromrepo', 'tool_installaddon'), 'get').
+            $this->output->help_icon('installfromrepo', 'tool_installaddon'),
+            'generalbox', 'installfromrepobox'
+        );
+
+        return $out;
+    }
+
+    /**
+     * Renders the widget for uploading the add-on ZIP package
+     *
+     * @return string
+     */
+    protected function index_page_upload() {
+
+        $form = $this->installer->get_installfromzip_form();
+
+        ob_start();
+        $form->display();
+        $out = ob_get_clean();
+
+        $out = $this->box($out, 'generalbox', 'installfromzipbox');
+
+        return $out;
+    }
+
+    /**
+     * Renders the page title and the overall validation verdict
+     *
+     * @return string
+     */
+    protected function validation_page_heading() {
+
+        $heading = $this->output->heading(get_string('validation', 'tool_installaddon'));
+
+        if ($this->validator->get_result()) {
+            $status = $this->output->container(
+                html_writer::span(get_string('validationresult1', 'tool_installaddon'), 'verdict').
+                    $this->output->help_icon('validationresult1', 'tool_installaddon'),
+                array('validationresult', 'success')
+            );
+        } else {
+            $status = $this->output->container(
+                html_writer::span(get_string('validationresult0', 'tool_installaddon'), 'verdict').
+                    $this->output->help_icon('validationresult0', 'tool_installaddon'),
+                array('validationresult', 'failure')
+            );
+        }
+
+        return $heading . $status;
+    }
+
+    /**
+     * Renders validation log messages.
+     *
+     * @return string
+     */
+    protected function validation_page_messages() {
+
+        $validator = $this->validator; // We need this to be able to use their constants.
+        $messages = $validator->get_messages();
+
+        if (empty($messages)) {
+            return '';
+        }
+
+        $table = new html_table();
+        $table->attributes['class'] = 'validationmessages generaltable';
+        $table->head = array(
+            get_string('validationresultstatus', 'tool_installaddon'),
+            get_string('validationresultmsg', 'tool_installaddon'),
+            get_string('validationresultinfo', 'tool_installaddon')
+        );
+        $table->colclasses = array('msgstatus', 'msgtext', 'msginfo');
+
+        $stringman = get_string_manager();
+
+        foreach ($messages as $message) {
+
+            if ($message->level === $validator::DEBUG and !debugging()) {
+                continue;
+            }
+
+            $msgstatus = get_string('validationmsglevel_'.$message->level, 'tool_installaddon');
+            $msgtext = $msgtext = s($message->msgcode);
+            if (is_null($message->addinfo)) {
+                $msginfo = '';
+            } else {
+                $msginfo = html_writer::tag('pre', s(print_r($message->addinfo, true)));
+            }
+            $msghelp = '';
+
+            // Replace the message code with the string if it is defined.
+            if ($stringman->string_exists('validationmsg_'.$message->msgcode, 'tool_installaddon')) {
+                $msgtext = get_string('validationmsg_'.$message->msgcode, 'tool_installaddon');
+                // And check for the eventual help, too.
+                if ($stringman->string_exists('validationmsg_'.$message->msgcode.'_help', 'tool_installaddon')) {
+                    $msghelp = $this->output->help_icon('validationmsg_'.$message->msgcode, 'tool_installaddon');
+                }
+            }
+
+            // Re-format the message info using a string if it is define.
+            if (!is_null($message->addinfo) and $stringman->string_exists('validationmsg_'.$message->msgcode.'_info', 'tool_installaddon')) {
+                $msginfo = get_string('validationmsg_'.$message->msgcode.'_info', 'tool_installaddon', $message->addinfo);
+            }
+
+            $row = new html_table_row(array($msgstatus, $msgtext.$msghelp, $msginfo));
+            $row->attributes['class'] = 'level-'.$message->level.' '.$message->msgcode;
+
+            $table->data[] = $row;
+        }
+
+        return html_writer::table($table);
+    }
+
+    /**
+     * Renders widgets to continue from the validation results page
+     *
+     * @return string
+     */
+    protected function validation_page_continue() {
+
+        $conturl = $this->validator->get_continue_url();
+        if (is_null($conturl)) {
+            $contbutton = '';
+        } else {
+            $contbutton = $this->output->single_button(
+                $conturl, get_string('installaddon', 'tool_installaddon'), 'post',
+                array('class' => 'singlebutton continuebutton'));
+        }
+
+        $cancelbutton = $this->output->single_button(
+            new moodle_url('/admin/tool/installaddon/index.php'), get_string('cancel', 'core'), 'get',
+            array('class' => 'singlebutton cancelbutton'));
+
+        return $this->output->container($cancelbutton.$contbutton, 'postvalidationbuttons');
+    }
+}
diff --git a/admin/tool/installaddon/settings.php b/admin/tool/installaddon/settings.php
new file mode 100644 (file)
index 0000000..b533360
--- /dev/null
@@ -0,0 +1,39 @@
+<?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/>.
+
+/**
+ * Puts the plugin actions into the admin settings tree.
+ *
+ * @package     tool_installaddon
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig and empty($CFG->disableonclickaddoninstall)) {
+
+    $ADMIN->add('modules', new admin_externalpage('tool_installaddon_index',
+        get_string('installaddons', 'tool_installaddon'),
+        "$CFG->wwwroot/$CFG->admin/tool/installaddon/index.php"), 'modsettings');
+
+    $ADMIN->add('modules', new admin_externalpage('tool_installaddon_validate',
+        get_string('validation', 'tool_installaddon'),
+        "$CFG->wwwroot/$CFG->admin/tool/installaddon/validate.php",
+        'moodle/site:config',
+        true), 'modsettings');
+}
diff --git a/admin/tool/installaddon/styles.css b/admin/tool/installaddon/styles.css
new file mode 100644 (file)
index 0000000..05155ca
--- /dev/null
@@ -0,0 +1,68 @@
+#page-admin-tool-installaddon-index #installfromrepobox {
+    text-align: center;
+    padding-top: 2em;
+    padding-bottom: 2em;
+}
+
+#page-admin-tool-installaddon-index #installfromrepobox .singlebutton {
+    display: inline-block;
+}
+
+#page-admin-tool-installaddon-index #installfromrepobox .singlebutton input[type=submit] {
+    padding: 1em;
+}
+
+#page-admin-tool-installaddon-validate .validationresult {
+    margin: 2em auto;
+    text-align: center;
+}
+
+#page-admin-tool-installaddon-validate .validationresult .verdict {
+    margin: 0em 0.5em;
+    padding: 0.5em;
+    border: 2px solid;
+    -webkit-border-radius: 5px;
+    -moz-border-radius: 5px;
+    border-radius: 5px;
+    font-weight: bold;
+}
+
+#page-admin-tool-installaddon-validate .validationresult.success .verdict {
+    background-color: #e7f1c3;
+    border-color: #aaeeaa;
+}
+
+#page-admin-tool-installaddon-validate .validationresult.failure .verdict {
+    background-color: #ffd3d9;
+    border-color: #eeaaaa;
+}
+
+#page-admin-tool-installaddon-validate .validationmessages {
+    margin: 0px auto;
+}
+
+#page-admin-tool-installaddon-validate .validationmessages .level-error .msgstatus {
+    background-color: #ffd3d9;
+}
+
+#page-admin-tool-installaddon-validate .validationmessages .level-warning .msgstatus {
+    background-color: #f3f2aa;
+}
+
+#page-admin-tool-installaddon-validate .validationmessages .level-info .msgstatus {
+    background-color: #e7f1c3;
+}
+
+#page-admin-tool-installaddon-validate .validationmessages .level-debug .msgstatus {
+    background-color: #d2ebff;
+}
+
+#page-admin-tool-installaddon-validate .postvalidationbuttons {
+    text-align: center;
+    margin: 1em auto;
+}
+
+#page-admin-tool-installaddon-validate .postvalidationbuttons .singlebutton {
+    display: inline-block;
+    margin: 1em 1em;
+}
diff --git a/admin/tool/installaddon/tests/fixtures/emptydir/emptydir/README.txt b/admin/tool/installaddon/tests/fixtures/emptydir/emptydir/README.txt
new file mode 100644 (file)
index 0000000..158d760
--- /dev/null
@@ -0,0 +1 @@
+Plugin must have more than one file.
diff --git a/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php b/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/lang/en/repository_mahara.php
new file mode 100644 (file)
index 0000000..b3d9bbc
--- /dev/null
@@ -0,0 +1 @@
+<?php
diff --git a/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/version.php b/admin/tool/installaddon/tests/fixtures/github/moodle-repository_mahara-master/version.php
new file mode 100644 (file)
index 0000000..c2ce9d0
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$plugin->component = 'repository_mahara';
+$plugin->version = 2014010100;
diff --git a/admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php b/admin/tool/installaddon/tests/fixtures/installed/greenbar/index.php
new file mode 100644 (file)
index 0000000..c65b6f7
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+echo 'One, my little hobbit, never installs malicisous add-ons';
diff --git a/admin/tool/installaddon/tests/fixtures/installed/greenbar/lang/en/local_greenbar.php b/admin/tool/installaddon/tests/fixtures/installed/greenbar/lang/en/local_greenbar.php
new file mode 100644 (file)
index 0000000..e11876b
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginname'] = 'Foo bar!';
diff --git a/admin/tool/installaddon/tests/fixtures/installed/greenbar/version.php b/admin/tool/installaddon/tests/fixtures/installed/greenbar/version.php
new file mode 100644 (file)
index 0000000..8b91a85
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$plugin->version = 2013031900;
+$plugin->component = 'local_greenbar';
diff --git a/admin/tool/installaddon/tests/fixtures/multidir/one/version.php b/admin/tool/installaddon/tests/fixtures/multidir/one/version.php
new file mode 100644 (file)
index 0000000..84af5f2
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$plugin->component = 'local_one';
diff --git a/admin/tool/installaddon/tests/fixtures/multidir/two/README.txt b/admin/tool/installaddon/tests/fixtures/multidir/two/README.txt
new file mode 100644 (file)
index 0000000..84e1459
--- /dev/null
@@ -0,0 +1 @@
+Only one dir is allowed
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/index.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/index.php
new file mode 100644 (file)
index 0000000..b3d9bbc
--- /dev/null
@@ -0,0 +1 @@
+<?php
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bah.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bah.php
new file mode 100644 (file)
index 0000000..b3d9bbc
--- /dev/null
@@ -0,0 +1 @@
+<?php
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bleh.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/lang/en/bleh.php
new file mode 100644 (file)
index 0000000..4f3b6fc
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginnname'] = 'Root directory mismatch';
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/lib.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/lib.php
new file mode 100644 (file)
index 0000000..50cce95
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/version.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/version.php
new file mode 100644 (file)
index 0000000..6c648f9
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+$module->version = 2014122455;
+$plugin->version = 2014122455;
diff --git a/admin/tool/installaddon/tests/fixtures/nolang/bah/view.php b/admin/tool/installaddon/tests/fixtures/nolang/bah/view.php
new file mode 100644 (file)
index 0000000..654f070
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+echo 'Do not use hardcoded strings, provide the language pack';
diff --git a/admin/tool/installaddon/tests/fixtures/noversionmod/noversion/lang/en/noversion.php b/admin/tool/installaddon/tests/fixtures/noversionmod/noversion/lang/en/noversion.php
new file mode 100644 (file)
index 0000000..82700cb
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginversion'] = 'Activity module with no version.php';
diff --git a/admin/tool/installaddon/tests/fixtures/noversiontheme/noversion/lang/en/theme_noversion.php b/admin/tool/installaddon/tests/fixtures/noversiontheme/noversion/lang/en/theme_noversion.php
new file mode 100644 (file)
index 0000000..78436a1
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginversion'] = 'A theme with no version.php';
diff --git a/admin/tool/installaddon/tests/fixtures/nowrapdir/index.php b/admin/tool/installaddon/tests/fixtures/nowrapdir/index.php
new file mode 100644 (file)
index 0000000..a37d47e
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+// index.php
diff --git a/admin/tool/installaddon/tests/fixtures/nowrapdir/lang/en/foo.php b/admin/tool/installaddon/tests/fixtures/nowrapdir/lang/en/foo.php
new file mode 100644 (file)
index 0000000..0b2f728
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginname'] = 'Foo!';
diff --git a/admin/tool/installaddon/tests/fixtures/nowrapdir/version.php b/admin/tool/installaddon/tests/fixtures/nowrapdir/version.php
new file mode 100644 (file)
index 0000000..d31292c
--- /dev/null
@@ -0,0 +1,3 @@
+<?php // $Id$
+
+// I don't miss CVS, do you?
diff --git a/admin/tool/installaddon/tests/fixtures/plugindir/foobar/index.php b/admin/tool/installaddon/tests/fixtures/plugindir/foobar/index.php
new file mode 100644 (file)
index 0000000..c65b6f7
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+echo 'One, my little hobbit, never installs malicisous add-ons';
diff --git a/admin/tool/installaddon/tests/fixtures/plugindir/foobar/lang/en/local_foobar.php b/admin/tool/installaddon/tests/fixtures/plugindir/foobar/lang/en/local_foobar.php
new file mode 100644 (file)
index 0000000..e11876b
--- /dev/null
@@ -0,0 +1,3 @@
+<?php
+
+$string['pluginname'] = 'Foo bar!';
diff --git a/admin/tool/installaddon/tests/fixtures/plugindir/foobar/version.php b/admin/tool/installaddon/tests/fixtures/plugindir/foobar/version.php
new file mode 100644 (file)
index 0000000..ebad339
--- /dev/null
@@ -0,0 +1,9 @@
+<?php
+
+$module->version = 10; // Ignored, this should use $plugin
+$plugin->version = 2013031900;
+$plugin->component = 'local_foobar';
+$plugin->requires = 2013031200;
+$module->release = 'We are not an activity module!';
+$plugin->maturity = MATURITY_ALPHA;
+//$plugin->release = 'And this is commented';
diff --git a/admin/tool/installaddon/tests/fixtures/versionphp/version1.php b/admin/tool/installaddon/tests/fixtures/versionphp/version1.php
new file mode 100644 (file)
index 0000000..61646f0
--- /dev/null
@@ -0,0 +1,52 @@
+<h1>Example version.php file</h1>
+
+<p>version.php is required for all plugins but themes.</p>
+
+<h2>Example of values</h2>
+
+<pre>
+    $plugin->version = 2011051000;
+    $plugin->requires = 2010112400;
+    $plugin->cron = 0;
+    $plugin->component = 'plugintype_pluginname';
+    $plugin->maturity = MATURITY_STABLE;
+    $plugin->release = '2.x (Build: 2011051000)';
+    $plugin->dependencies = array('mod_forum' => ANY_VERSION, 'mod_data' => 2010020300);
+</pre>
+
+Replace $plugin with $module for activity modules, as in
+
+<pre>
+    $module->version = 2012122400;
+</pre><?php // $Id$ $module->version = 1;
+
+    $plugin->component
+        = 'old_foobar';//$plugin->component='commented';
+
+    $plugin->component      =   
+        'block_foobar';
+    
+$plugin->version = 2013010100;
+ ////////$plugin->version = 0;
+    /* for activity
+       modules use:
+    $module->version = 2014131300;
+
+    ***/
+$plugin->version = "2010091855";        // Do not use quotes here.
+$plugin->version = '2010091856.9'; // Do not use quotes here.
+
+
+$plugin->requires = /* 2012010100  */ 2012122401  ;
+
+$module->maturity = MATURITY_STABLE;
+$module->maturity = 50; // If both present, the constant wins (on contrary to what PHP would do)
+$module->maturity = 'MATURITY_BETA'; // Do not use quotes here.
+
+$plugin->maturity = 10;
+$plugin->maturity = MATURITY_ALPHA;
+
+
+
+$module->release = 2.3;         $plugin->release  = 'v2.4';
+$module->release = "v2.3";      $plugin->release    = 2.4;
diff --git a/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip b/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip
new file mode 100644 (file)
index 0000000..a209025
Binary files /dev/null and b/admin/tool/installaddon/tests/fixtures/zips/invalidroot.zip differ
diff --git a/admin/tool/installaddon/tests/installer_test.php b/admin/tool/installaddon/tests/installer_test.php
new file mode 100644 (file)
index 0000000..fd8d1d1
--- /dev/null
@@ -0,0 +1,158 @@
+<?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/>.
+
+/**
+ * Provides the unit tests class and some helper classes
+ *
+ * @package     tool_installaddon
+ * @category    test
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/installaddon/classes/installer.php');
+
+
+/**
+ * Unit tests for the {@link tool_installaddon_installer} class
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_installer_test extends advanced_testcase {
+
+    public function test_get_addons_repository_url() {
+        $installer = testable_tool_installaddon_installer::instance();
+        $url = $installer->get_addons_repository_url();
+        $query = parse_url($url, PHP_URL_QUERY);
+        $this->assertEquals(1, preg_match('~^site=(.+)$~', $query, $matches));
+        $site = rawurldecode($matches[1]);
+        $site = json_decode(base64_decode($site), true);
+        $this->assertEquals('array', gettype($site));
+        $this->assertEquals(3, count($site));
+        $this->assertSame('Nasty site', $site['fullname']);
+        $this->assertSame('file:///etc/passwd', $site['url']);
+        $this->assertSame("2.5'; DROP TABLE mdl_user; --", $site['majorversion']);
+    }
+
+    public function test_extract_installfromzip_file() {
+        $jobid = md5(rand().uniqid('test_', true));
+        $sourcedir = make_temp_directory('tool_installaddon/'.$jobid.'/source');
+        $contentsdir = make_temp_directory('tool_installaddon/'.$jobid.'/contents');
+        copy(dirname(__FILE__).'/fixtures/zips/invalidroot.zip', $sourcedir.'/testinvalidroot.zip');
+
+        $installer = tool_installaddon_installer::instance();
+        $files = $installer->extract_installfromzip_file($sourcedir.'/testinvalidroot.zip', $contentsdir, 'fixed_root');
+        $this->assertEquals('array', gettype($files));
+        $this->assertEquals(4, count($files));
+        $this->assertSame(true, $files['fixed_root/']);
+        $this->assertSame(true, $files['fixed_root/lang/']);
+        $this->assertSame(true, $files['fixed_root/lang/en/']);
+        $this->assertSame(true, $files['fixed_root/lang/en/fixed_root.php']);
+        foreach ($files as $file => $status) {
+            if (substr($file, -1) === '/') {
+                $this->assertTrue(is_dir($contentsdir.'/'.$file));
+            } else {
+                $this->assertTrue(is_file($contentsdir.'/'.$file));
+            }
+        }
+    }
+
+    public function test_decode_remote_request() {
+        $installer = testable_tool_installaddon_installer::instance();
+
+        $request = base64_encode(json_encode(array(
+            'name' => '<h1>Stamp collection</h1>"; DELETE FROM mdl_users; --',
+            'component' => 'mod_stampcoll',
+            'version' => 2013032800,
+        )));
+        $request = $installer->testable_decode_remote_request($request);
+        $this->assertTrue(is_object($request));
+        // One, my little hobbit, never trusts the input parameters!
+        $this->assertEquals('Stamp collection&quot;; DELETE FROM mdl_users; --', $request->name);
+        $this->assertEquals('mod_stampcoll', $request->component);
+        $this->assertEquals(2013032800, $request->version);
+
+        $request = base64_encode(json_encode(array(
+            'name' => 'Theme with invalid version number',
+            'component' => 'theme_invalid',
+            'version' => '1.0',
+        )));
+        $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+        $request = base64_encode(json_encode(array(
+            'name' => 'Invalid activity name',
+            'component' => 'mod_invalid_activity',
+            'version' => 2013032800,
+        )));
+        $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+        $request = base64_encode(json_encode(array(
+            'name' => 'Moodle 3.0',
+            'component' => 'core',
+            'version' => 2022010100,
+        )));
+        $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+        $request = base64_encode(json_encode(array(
+            'name' => 'Invalid core subsystem',
+            'component' => 'core_cache',
+            'version' => 2014123400,
+        )));
+        $this->assertSame(false, $installer->testable_decode_remote_request($request));
+
+        $request = base64_encode(json_encode(array(
+            'name' => 'Non-existing plugintype',
+            'component' => 'david_mudrak',
+            'version' => 2012123199,
+        )));
+        $this->assertSame(false, $installer->testable_decode_remote_request($request));
+    }
+}
+
+
+/**
+ * Testable subclass of the tested class
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_tool_installaddon_installer extends tool_installaddon_installer {
+
+    public function get_site_fullname() {
+        return strip_tags('<h1 onmouseover="alert(\'Hello Moodle.org!\');">Nasty site</h1>');
+    }
+
+    public function get_site_url() {
+        return 'file:///etc/passwd';
+    }
+
+    public function get_site_major_version() {
+        return "2.5'; DROP TABLE mdl_user; --";
+    }
+
+    public function testable_decode_remote_request($request) {
+        return parent::decode_remote_request($request);
+    }
+
+    protected function should_send_site_info() {
+        return true;
+    }
+}
diff --git a/admin/tool/installaddon/tests/validator_test.php b/admin/tool/installaddon/tests/validator_test.php
new file mode 100644 (file)
index 0000000..c008d9c
--- /dev/null
@@ -0,0 +1,335 @@
+<?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/>.
+
+/**
+ * Provides the unit tests class and some helper classes
+ *
+ * @package     tool_installaddon
+ * @category    test
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/installaddon/classes/validator.php');
+
+
+/**
+ * Unit tests for the {@link tool_installaddon_installer} class
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_installaddon_validator_test extends basic_testcase {
+
+    public function test_validate_files_layout() {
+        $fixtures = dirname(__FILE__).'/fixtures';
+
+        // Non-existing directory.
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nulldir', array(
+            'null/' => true,
+            'null/lang/' => true,
+            'null/lang/en/' => true,
+            'null/lang/en/null.php' => true));
+        $this->assertEquals('testable_tool_installaddon_validator', get_class($validator));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR,
+            'filenotexists', array('file' => 'null/')));
+
+        // Missing expected file
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+            'foobar/' => true,
+            'foobar/version.php' => true,
+            'foobar/index.php' => true,
+            'foobar/lang/' => true,
+            'foobar/lang/en/' => true,
+            'foobar/lang/en/local_foobar.php' => true,
+            'foobar/NOTEXISTS.txt' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR,
+            'filenotexists', array('file' => 'foobar/NOTEXISTS.txt')));
+
+        // Errors during ZIP extraction
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/multidir', array(
+            'one/' => true,
+            'one/version.php' => 'Can not write target file',
+            'two/' => true,
+            'two/README.txt' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'filestatus',
+            array('file' => 'one/version.php', 'status' => 'Can not write target file')));
+
+        // Insufficient number of extracted files
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/emptydir', array(
+            'emptydir/' => true,
+            'emptydir/README.txt' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'filesnumber'));
+
+        // No wrapping directory
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nowrapdir', array(
+            'version.php' => true,
+            'index.php' => true,
+            'lang/' => true,
+            'lang/en/' => true,
+            'lang/en/foo.php' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'onedir'));
+
+        // Multiple directories
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/multidir', array(
+            'one/' => true,
+            'one/version.php' => true,
+            'two/' => true,
+            'two/README.txt' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'onedir'));
+
+        // Invalid root directory name
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/github', array(
+            'moodle-repository_mahara-master/' => true,
+            'moodle-repository_mahara-master/lang/' => true,
+            'moodle-repository_mahara-master/lang/en/' => true,
+            'moodle-repository_mahara-master/lang/en/repository_mahara.php' => true,
+            'moodle-repository_mahara-master/version.php' => true));
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'rootdirinvalid',
+            'moodle-repository_mahara-master'));
+    }
+
+    public function test_validate_version_php() {
+        $fixtures = dirname(__FILE__).'/fixtures';
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversiontheme', array(
+            'noversion/' => true,
+            'noversion/lang/' => true,
+            'noversion/lang/en/' => true,
+            'noversion/lang/en/theme_noversion.php' => true));
+        $validator->assert_plugin_type('theme');
+        $validator->assert_moodle_version(0);
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'missingversionphp'));
+        $this->assertTrue(is_null($validator->get_versionphp_info()));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversionmod', array(
+            'noversion/' => true,
+            'noversion/lang/' => true,
+            'noversion/lang/en/' => true,
+            'noversion/lang/en/noversion.php' => true));
+        $validator->assert_plugin_type('mod');
+        $validator->assert_moodle_version(0);
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missingversionphp'));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+            'foobar/' => true,
+            'foobar/version.php' => true,
+            'foobar/index.php' => true,
+            'foobar/lang/' => true));
+        $validator->assert_plugin_type('block');
+        $validator->assert_moodle_version('2013031400.00');
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'componentmismatchtype',
+            array('expected' => 'block', 'found' => 'local')));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+            'foobar/' => true,
+            'foobar/version.php' => true,
+            'foobar/index.php' => true,
+            'foobar/lang/' => true,
+            'foobar/lang/en/' => true,
+            'foobar/lang/en/local_foobar.php' => true));
+        $validator->assert_plugin_type('local');
+        $validator->assert_moodle_version('2013031400.00');
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($validator->get_result());
+        $this->assertEquals('foobar', $validator->get_rootdir());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::INFO, 'rootdir', 'foobar'));
+        $versionphpinfo = $validator->get_versionphp_info();
+        $this->assertEquals('array', gettype($versionphpinfo));
+        $this->assertEquals(4, count($versionphpinfo));
+        $this->assertEquals(2013031900, $versionphpinfo['version']);
+        $this->assertEquals(2013031200, $versionphpinfo['requires']);
+        $this->assertEquals('local_foobar', $versionphpinfo['component']);
+        $this->assertEquals('MATURITY_ALPHA', $versionphpinfo['maturity']); // Note we get the constant name here.
+        $this->assertEquals(MATURITY_ALPHA, constant($versionphpinfo['maturity'])); // This is how to get the real value.
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::WARNING, 'maturity', 'MATURITY_ALPHA'));
+    }
+
+    public function test_validate_language_pack() {
+        $fixtures = dirname(__FILE__).'/fixtures';
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+            'bah/' => true,
+            'bah/index.php' => true,
+            'bah/view.php' => true,
+            'bah/version.php' => true));
+        $validator->assert_plugin_type('mod');
+        $validator->assert_moodle_version(0);
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missinglangenfolder'));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+            'bah/' => true,
+            'bah/version.php' => true,
+            'bah/lang/' => true,
+            'bah/lang/en/' => true));
+        $validator->assert_plugin_type('mod');
+        $validator->assert_moodle_version(0);
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missinglangenfile'));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+            'bah/' => true,
+            'bah/version.php' => true,
+            'bah/lang/' => true,
+            'bah/lang/en/' => true,
+            'bah/lang/en/bleh.php' => true,
+            'bah/lang/en/bah.php' => true));
+        $validator->assert_plugin_type('mod');
+        $validator->assert_moodle_version(0);
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::WARNING, 'multiplelangenfiles'));
+        $this->assertTrue(is_null($validator->get_language_file_name()));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/nolang', array(
+            'bah/' => true,
+            'bah/version.php' => true,
+            'bah/lang/' => true,
+            'bah/lang/en/' => true,
+            'bah/lang/en/bah.php' => true));
+        $validator->assert_plugin_type('block');
+        $validator->assert_moodle_version(0);
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'missingexpectedlangenfile', 'block_bah.php'));
+        $this->assertEquals('bah', $validator->get_language_file_name());
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/noversiontheme', array(
+            'noversion/' => true,
+            'noversion/lang/' => true,
+            'noversion/lang/en/' => true,
+            'noversion/lang/en/theme_noversion.php' => true));
+        $validator->assert_plugin_type('theme');
+        $validator->assert_moodle_version(0);
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'foundlangfile', 'theme_noversion'));
+        $this->assertEquals('theme_noversion', $validator->get_language_file_name());
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+            'foobar/' => true,
+            'foobar/version.php' => true,
+            'foobar/index.php' => true,
+            'foobar/lang/' => true,
+            'foobar/lang/en/' => true,
+            'foobar/lang/en/local_foobar.php' => true));
+        $validator->assert_plugin_type('local');
+        $validator->assert_moodle_version('2013031400.00');
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::DEBUG, 'foundlangfile', 'local_foobar'));
+        $this->assertEquals('local_foobar', $validator->get_language_file_name());
+    }
+
+    public function test_validate_target_location() {
+        $fixtures = dirname(__FILE__).'/fixtures';
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/installed', array(
+            'greenbar/' => true,
+            'greenbar/version.php' => true,
+            'greenbar/index.php' => true,
+            'greenbar/lang/' => true,
+            'greenbar/lang/en/' => true,
+            'greenbar/lang/en/local_greenbar.php' => true));
+        $validator->assert_plugin_type('local');
+        $validator->assert_moodle_version('2013031400.00');
+        $this->assertFalse($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::ERROR, 'targetexists',
+            $validator->get_plugintype_location('local').'/greenbar'));
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures.'/plugindir', array(
+            'foobar/' => true,
+            'foobar/version.php' => true,
+            'foobar/index.php' => true,
+            'foobar/lang/' => true,
+            'foobar/lang/en/' => true,
+            'foobar/lang/en/local_foobar.php' => true));
+        $validator->assert_plugin_type('local');
+        $validator->assert_moodle_version('2013031400.00');
+        $this->assertTrue($validator->execute());
+        $this->assertTrue($this->has_message($validator->get_messages(), $validator::INFO, 'pathwritable',
+            $validator->get_plugintype_location('local')));
+    }
+
+    public function test_parse_version_php() {
+        $fixtures = dirname(__FILE__).'/fixtures/versionphp';
+
+        $validator = testable_tool_installaddon_validator::instance($fixtures, array());
+        $this->assertEquals('testable_tool_installaddon_validator', get_class($validator));
+
+        $info = $validator->testable_parse_version_php($fixtures.'/version1.php');
+        $this->assertEquals('array', gettype($info));
+        $this->assertEquals(7, count($info));
+        $this->assertEquals('block_foobar', $info['plugin->component']);    // Later in the file.
+        $this->assertEquals('2013010100', $info['plugin->version']);        // Numeric wins over strings.
+        $this->assertEquals('2012122401', $info['plugin->requires']);       // Commented.
+        $this->assertEquals('MATURITY_STABLE', $info['module->maturity']);  // Constant wins regardless the order (non-PHP behaviour).
+        $this->assertEquals('MATURITY_ALPHA', $info['plugin->maturity']);   // Constant wins regardless the order (non-PHP behaviour).
+        $this->assertEquals('v2.3', $info['module->release']);              // String wins over numeric (non-PHP behaviour).
+        $this->assertEquals('v2.4', $info['plugin->release']);              // String wins over numeric (non-PHP behaviour).
+    }
+
+    // Helper methods //////////////////////////////////////////////////////////
+
+    protected function has_message(array $messages, $level, $msgcode, $addinfo = null) {
+        foreach ($messages as $message) {
+            if ($message->level === $level and $message->msgcode === $msgcode and $message->addinfo === $addinfo) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
+
+
+/**
+ * Provides access to protected methods we want to explicitly test
+ *
+ * @copyright 2013 David Mudrak <david@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class testable_tool_installaddon_validator extends tool_installaddon_validator {
+
+    public function testable_parse_version_php($fullpath) {
+        return parent::parse_version_php($fullpath);
+    }
+
+    public function get_plugintype_location($plugintype) {
+
+        $testableroot = make_temp_directory('testable_tool_installaddon_validator/plugintypes');
+        if (!is_dir($testableroot.'/'.$plugintype)) {
+            make_temp_directory('testable_tool_installaddon_validator/plugintypes/'.$plugintype);
+        }
+
+        if ($plugintype === 'local') {
+            // We need the following for the test_validate_target_location() method
+            make_temp_directory('testable_tool_installaddon_validator/plugintypes/local/greenbar');
+        }
+
+        return $testableroot.'/'.$plugintype;
+    }
+}
diff --git a/admin/tool/installaddon/validate.php b/admin/tool/installaddon/validate.php
new file mode 100644 (file)
index 0000000..2fc66cf
--- /dev/null
@@ -0,0 +1,80 @@
+<?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/>.
+
+/**
+ * The ZIP package validation.
+ *
+ * @package     tool_installaddon
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require(dirname(__FILE__) . '/../../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+require_once($CFG->libdir.'/filelib.php');
+require_once(dirname(__FILE__).'/classes/installer.php');
+require_once(dirname(__FILE__).'/classes/validator.php');
+
+navigation_node::override_active_url(new moodle_url('/admin/tool/installaddon/index.php'));
+admin_externalpage_setup('tool_installaddon_validate');
+
+if (!empty($CFG->disableonclickaddoninstall)) {
+    notice(get_string('featuredisabled', 'tool_installaddon'));
+}
+
+require_sesskey();
+
+$jobid = required_param('jobid', PARAM_ALPHANUM);
+$zipfilename = required_param('zip', PARAM_FILE);
+$plugintype = required_param('type', PARAM_ALPHANUMEXT);
+$rootdir = optional_param('rootdir', '', PARAM_PLUGIN);
+
+$zipfilepath = $CFG->tempdir.'/tool_installaddon/'.$jobid.'/source/'.$zipfilename;
+if (!file_exists($zipfilepath)) {
+    redirect(new moodle_url('/admin/tool/installaddon/index.php'),
+        get_string('invaliddata', 'core_error'));
+}
+
+$installer = tool_installaddon_installer::instance();
+
+// Extract the ZIP contents.
+fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid.'/contents');
+$zipcontentpath = make_temp_directory('tool_installaddon/'.$jobid.'/contents');
+$zipcontentfiles = $installer->extract_installfromzip_file($zipfilepath, $zipcontentpath, $rootdir);
+
+// Validate the contents of the plugin ZIP file.
+$validator = tool_installaddon_validator::instance($zipcontentpath, $zipcontentfiles);
+$validator->assert_plugin_type($plugintype);
+$validator->assert_moodle_version($CFG->version);
+$result = $validator->execute();
+
+if ($result) {
+    $validator->set_continue_url(new moodle_url('/admin/tool/installaddon/deploy.php', array(
+        'sesskey' => sesskey(),
+        'jobid' => $jobid,
+        'type' => $plugintype,
+        'name' => $validator->get_rootdir())));
+
+} else {
+    fulldelete($CFG->tempdir.'/tool_installaddon/'.$jobid);
+}
+
+// Display the validation results.
+$output = $PAGE->get_renderer('tool_installaddon');
+$output->set_installer_instance($installer);
+$output->set_validator_instance($validator);
+echo $output->validation_page();
diff --git a/admin/tool/installaddon/version.php b/admin/tool/installaddon/version.php
new file mode 100644 (file)
index 0000000..94ab2e5
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * @package     tool_installaddon
+ * @copyright   2013 David Mudrak <david@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->component  = 'tool_installaddon';
+$plugin->version    = 2013031400;
+$plugin->requires   = 2013031400;
+$plugin->maturity   = MATURITY_BETA;
diff --git a/admin/tool/installaddon/yui/permcheck/permcheck.js b/admin/tool/installaddon/yui/permcheck/permcheck.js
new file mode 100644 (file)
index 0000000..ad0e4bd
--- /dev/null
@@ -0,0 +1,105 @@
+/**
+ * Check for write permission for the selected plugin type
+ *
+ * @module      moodle-tool_installaddon-permcheck
+ * @author      David Mudrak <david@moodle.com>
+ */
+YUI.add('moodle-tool_installaddon-permcheck', function(Y) {
+
+    M.tool_installaddon = M.tool_installaddon || {};
+
+    /**
+     * @class permcheck
+     * @static
+     */
+    M.tool_installaddon.permcheck = {
+
+        /**
+         * @method init
+         * @param {Object} config Configuration passed from the PHP
+         */
+        init : function(config) {
+            this.config = config;
+            var plugintypesel = Y.one('#tool_installaddon_installfromzip_plugintype');
+            if (plugintypesel) {
+                plugintypesel.on('change', this.check_for_permission, this);
+            }
+        },
+
+        /**
+         * @method check_for_permission
+         * @param {Event} e
+         */
+        check_for_permission : function(e) {
+            var plugintype = e.currentTarget.get('value');
+            if (plugintype == '') {
+                return;
+            }
+            Y.log('Selected plugin type: ' + plugintype, 'debug', 'moodle-tool_installaddon-permcheck');
+            Y.io(this.config.permcheckurl, {
+                'method' : 'GET',
+                'data' : {
+                    'sesskey' : M.cfg.sesskey,
+                    'plugintype' : plugintype
+                },
+                'arguments' : {
+                    'plugintypeselector' : e.currentTarget,
+                    'showresult' : function(msg, status) {
+                        var resultline = Y.one('#tool_installaddon_installfromzip_permcheck');
+                        if (resultline) {
+                            if (status === 'success') {
+                                resultline.setContent('<span class="success"><img src="' + M.util.image_url('i/tick_green_big') + '" /> ' +
+                                    msg + '</span>');
+                            } else if (status === 'progress') {
+                                resultline.setContent('<span class="progress"><img src="' + M.cfg.loadingicon + '" /> ' +
+                                    msg + '</span>');
+                            } else {
+                                resultline.setContent('<span class="error"><img src="' + M.util.image_url('i/cross_red_big') + '" /> ' +
+                                    msg + '</span>');
+                            }
+                        }
+                    }
+                },
+                'on' : {
+                    'start' : function(transid, args) {
+                        args.showresult(M.util.get_string('permcheckprogress', 'tool_installaddon'), 'progress');
+                    },
+                    'success': function(transid, outcome, args) {
+                        var response;
+                        try {
+                            response = Y.JSON.parse(outcome.responseText);
+                            if (response.error) {
+                                Y.log(response.error, 'error', 'moodle-tool_installaddon-permcheck');
+                                args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon', response), 'error');
+                            } else if (response.path && response.writable == 1) {
+                                args.showresult(M.util.get_string('permcheckresultyes', 'tool_installaddon', response), 'success');
+                            } else if (response.path && response.writable == 0) {
+                                args.showresult(M.util.get_string('permcheckresultno', 'tool_installaddon', response), 'error');
+                            } else {
+                                Y.log(response, 'debug', 'moodle-tool_installaddon-permcheck');
+                                args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon', response), 'error');
+                            }
+
+                        } catch (e) {
+                            Y.log(e, 'error', 'moodle-tool_installaddon-permcheck');
+                            args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon'), 'error');
+                        }
+                    },
+                    'failure': function(transid, outcome, args) {
+                        Y.log(outcome.statusText, 'error', 'moodle-tool_installaddon-permcheck');
+                        args.showresult(M.util.get_string('permcheckerror', 'tool_installaddon'));
+                    }
+                }
+            });
+        },
+
+        /**
+         * @property
+         * @type {Object}
+         */
+        config : null
+    };
+
+}, '@VERSION@', {
+    requires:['node', 'event', 'io-base']
+});
index c9ce761..7653437 100644 (file)
@@ -438,6 +438,16 @@ $CFG->admin = 'admin';
 //
 //      $CFG->disableupdatenotifications = true;
 //
+// Use the following flag to completely disable the Automatic updates deployment
+// feature and hide it from the server administration UI.
+//
+//      $CFG->disableupdateautodeploy = true;
+//
+// Use the following flag to completely disable the On-click add-on installation
+// feature and hide it from the server administration UI.
+//
+//      $CFG->disableonclickaddoninstall = true;
+//
 // As of version 2.4 Moodle serves icons as SVG images if the users browser appears
 // to support SVG.
 // For those wanting to control the serving of SVG images the following setting can
index 5b23b23..7aa8d31 100644 (file)
@@ -96,6 +96,17 @@ class plugin_manager {
         }
     }
 
+    /**
+     * Returns the result of {@link get_plugin_types()} ordered for humans
+     *
+     * @see self::reorder_plugin_types()
+     * @param bool $fullpaths false means relative paths from dirroot
+     * @return array (string)name => (string)location
+     */
+    public function get_plugin_types($fullpaths = true) {
+        return $this->reorder_plugin_types(get_plugin_types($fullpaths));
+    }
+
     /**
      * Returns a tree of known plugins and information about them
      *
@@ -119,8 +130,7 @@ class plugin_manager {
                 }
             }
             $this->pluginsinfo = array();
-            $plugintypes = get_plugin_types();
-            $plugintypes = $this->reorder_plugin_types($plugintypes);
+            $plugintypes = $this->get_plugin_types();
             foreach ($plugintypes as $plugintype => $plugintyperootdir) {
                 if (in_array($plugintype, array('base', 'general'))) {
                     throw new coding_exception('Illegal usage of reserved word for plugin type');
@@ -219,6 +229,35 @@ class plugin_manager {
         return $this->pluginsinfo[$type][$name]->displayname;
     }
 
+    /**
+     * Returns a localized name of a plugin typed in singular form
+     *
+     * Most plugin types define their names in core_plugin lang file. In case of subplugins,
+     * we try to ask the parent plugin for the name. In the worst case, we will return
+     * the value of the passed $type parameter.
+     *
+     * @param string $type the type of the plugin, e.g. mod or workshopform
+     * @return string
+     */
+    public function plugintype_name($type) {
+
+        if (get_string_manager()->string_exists('type_' . $type, 'core_plugin')) {
+            // for most plugin types, their names are defined in core_plugin lang file
+            return get_string('type_' . $type, 'core_plugin');
+
+        } else if ($parent = $this->get_parent_of_subplugin($type)) {
+            // if this is a subplugin, try to ask the parent plugin for the name
+            if (get_string_manager()->string_exists('subplugintype_' . $type, $parent)) {
+                return $this->plugin_name($parent) . ' / ' . get_string('subplugintype_' . $type, $parent);
+            } else {
+                return $this->plugin_name($parent) . ' / ' . $type;
+            }
+
+        } else {
+            return $type;
+        }
+    }
+
     /**
      * Returns a localized name of a plugin type in plural form
      *
@@ -570,9 +609,10 @@ class plugin_manager {
             ),
 
             'tool' => array(
-                'assignmentupgrade', 'behat', 'capability', 'customlang', 'dbtransfer',
-                'generator', 'health', 'innodb', 'langimport', 'multilangupgrade', 'phpunit',
-                'profiling', 'qeupgradehelper', 'replace', 'spamcleaner', 'timezoneimport',
+                'assignmentupgrade', 'behat', 'capability', 'customlang',
+                'dbtransfer', 'generator', 'health', 'innodb', 'installaddon',
+                'langimport', 'multilangupgrade', 'phpunit', 'profiling',
+                'qeupgradehelper', 'replace', 'spamcleaner', 'timezoneimport',
                 'unittest', 'uploaduser', 'unsuproles', 'xmldb'
             ),
 
index 303d3d1..6c54d59 100644 (file)
 /**
  * Moodle deployment utility
  *
- * This script looks after deploying available updates to the local Moodle site.
+ * This script looks after deploying new add-ons and available updates for them
+ * to the local Moodle site. It can operate via both HTTP and CLI mode.
+ * Moodle itself calls this utility via the HTTP mode when the admin is about to
+ * install or update an add-on. You can use the CLI mode in your custom deployment
+ * shell scripts.
  *
  * CLI usage example:
+ *
+ *  $ sudo -u apache php mdeploy.php --install \
+ *                                   --package=https://moodle.org/plugins/download.php/...zip \
+ *                                   --typeroot=/var/www/moodle/htdocs/blocks
+ *                                   --name=loancalc
+ *                                   --md5=...
+ *                                   --dataroot=/var/www/moodle/data
+ *
  *  $ sudo -u apache php mdeploy.php --upgrade \
  *                                   --package=https://moodle.org/plugins/download.php/...zip \
- *                                   --dataroot=/home/mudrd8mz/moodledata/moodle24
+ *                                   --typeroot=/var/www/moodle/htdocs/blocks
+ *                                   --name=loancalc
+ *                                   --md5=...
+ *                                   --dataroot=/var/www/moodle/data
+ *
+ * When called via HTTP, additional parameters returnurl, passfile and password must be
+ * provided. Optional proxy configuration can be passed using parameters proxy, proxytype
+ * and proxyuserpwd.
+ *
+ * Changes
+ *
+ * 1.1 - Added support to install a new plugin from the Moodle Plugins directory.
+ * 1.0 - Initial version used in Moodle 2.4 to deploy available updates.
  *
  * @package     core
+ * @subpackage  mdeploy
+ * @version     1.1
  * @copyright   2012 David Mudrak <david@moodle.com>
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -775,7 +801,53 @@ class worker extends singleton_pattern {
             $this->done();
 
         } else if ($this->input->get_option('install')) {
-            // Installing a new plugin not implemented yet.
+            $this->log('Plugin installation requested');
+
+            $plugintyperoot = $this->input->get_option('typeroot');
+            $pluginname     = $this->input->get_option('name');
+            $source         = $this->input->get_option('package');
+            $md5remote      = $this->input->get_option('md5');
+
+            // Check if the plugin location if available for us.
+            $pluginlocation = $plugintyperoot.'/'.$pluginname;
+
+            $this->log('New plugin code location: '.$pluginlocation);
+
+            if (file_exists($pluginlocation)) {
+                throw new filesystem_exception('Unable to prepare the plugin location (directory already exists)');
+            }
+
+            if (!$this->create_directory_precheck($pluginlocation)) {
+                throw new filesystem_exception('Unable to prepare the plugin location (cannot create new directory)');
+            }
+
+            // Fetch the ZIP file into a temporary location.
+            $target = $this->target_location($source);
+            $this->log('Downloading package '.$source);
+
+            if ($this->download_file($source, $target)) {
+                $this->log('Package downloaded into '.$target);
+            } else {
+                $this->log('cURL error ' . $this->curlerrno . ' ' . $this->curlerror);
+                $this->log('Unable to download the file');
+                throw new download_file_exception('Unable to download the package');
+            }
+
+            // Compare MD5 checksum of the ZIP file
+            $md5local = md5_file($target);
+
+            if ($md5local !== $md5remote) {
+                $this->log('MD5 checksum failed. Expected: '.$md5remote.' Got: '.$md5local);
+                throw new checksum_exception('MD5 checksum failed');
+            }
+            $this->log('MD5 checksum ok');
+
+            // Unzip the plugin package file into the plugin location.
+            $this->unzip_plugin($target, $plugintyperoot, $pluginlocation, false);
+            $this->log('Package successfully extracted');
+
+            // Redirect to the given URL (in HTTP) or exit (in CLI).
+            $this->done();
         }
 
         // Print help in CLI by default.
@@ -1126,18 +1198,36 @@ class worker extends singleton_pattern {
     }
 
     /**
-     * Checks to see if a source foldr could be safely moved into the given new location
+     * Checks to see if a source folder could be safely moved into the given new location
      *
      * @param string $destination full path to the new expected location of a folder
      * @return bool
      */
     protected function move_directory_target_precheck($target) {
 
-        if (file_exists($target)) {
+        // Check if the target folder does not exist yet, can be created
+        // and removed again.
+        $result = $this->create_directory_precheck($target);
+
+        // At the moment, it seems to be enough to check. We may want to add
+        // more steps in the future.
+
+        return $result;
+    }
+
+    /**
+     * Make sure the given directory can be created (and removed)
+     *
+     * @param string $path full path to the folder
+     * @return bool
+     */
+    protected function create_directory_precheck($path) {
+
+        if (file_exists($path)) {
             return false;
         }
 
-        $result = mkdir($target, 02777) && rmdir($target);
+        $result = mkdir($path, 02777) && rmdir($path);
 
         return $result;
     }
@@ -1265,7 +1355,7 @@ class worker extends singleton_pattern {
      * @param string $ziplocation full path to the ZIP file
      * @param string $plugintyperoot full path to the plugin's type location
      * @param string $expectedlocation expected full path to the plugin after it is extracted
-     * @param string $backuplocation location of the previous version of the plugin
+     * @param string|bool $backuplocation location of the previous version of the plugin or false for no backup
      */
     protected function unzip_plugin($ziplocation, $plugintyperoot, $expectedlocation, $backuplocation) {
 
@@ -1273,7 +1363,9 @@ class worker extends singleton_pattern {
         $result = $zip->open($ziplocation);
 
         if ($result !== true) {
-            $this->move_directory($backuplocation, $expectedlocation);
+            if ($backuplocation !== false) {
+                $this->move_directory($backuplocation, $expectedlocation);
+            }
             throw new zip_exception('Unable to open the zip package');
         }
 
@@ -1292,7 +1384,9 @@ class worker extends singleton_pattern {
         if (!$zip->extractTo($plugintyperoot)) {
             $zip->close();
             $this->remove_directory($expectedlocation, true); // just in case something was created
-            $this->move_directory_into($backuplocation, $expectedlocation);
+            if ($backuplocation !== false) {
+                $this->move_directory_into($backuplocation, $expectedlocation);
+            }
             throw new zip_exception('Unable to extract the zip package');
         }
 
index 76800eb..7fb81c5 100644 (file)
@@ -106,6 +106,13 @@ class testable_worker extends worker {
     public function remove_directory($path, $keeppathroot = false) {
         return parent::remove_directory($path, $keeppathroot);
     }
+
+    /**
+     * Provides access to the protected method.
+     */
+    public function create_directory_precheck($path) {
+        return parent::create_directory_precheck($path);
+    }
 }
 
 
@@ -284,4 +291,13 @@ class mdeploytest extends PHPUnit_Framework_TestCase {
         $this->assertTrue($worker->remove_directory($root.'/c'));
         $this->assertFalse(is_dir($root.'/c'));
     }
+
+    public function test_create_directory_precheck() {
+        $worker = testable_worker::instance();
+
+        $root = sys_get_temp_dir().'/'.uniqid('mdeploytest', true);
+        $this->assertFalse(file_exists($root));
+        $this->assertTrue($worker->create_directory_precheck($root));
+        $this->assertFalse(file_exists($root)); // The precheck is supposed to remove it again.
+    }
 }
index 64cbf3f..ddddfe0 100644 (file)
@@ -137,7 +137,24 @@ if (is_null($assessment->grade) and !$assessmenteditable) {
     // load the assessment form and process the submitted data eventually
     $mform = $strategy->get_assessment_form($PAGE->url, 'assessment', $assessment, $assessmenteditable,
                                         array('editableweight' => $cansetassessmentweight, 'pending' => !empty($pending)));
-    $mform->set_data(array('weight' => $assessment->weight)); // other values are set by subplugins
+
+    // Set data managed by the workshop core, subplugins set their own data themselves.
+    $currentdata = (object)array(
+        'weight' => $assessment->weight,
+        'feedbackauthor' => $assessment->feedbackauthor,
+        'feedbackauthorformat' => $assessment->feedbackauthorformat,
+    );
+    if ($assessmenteditable and $workshop->overallfeedbackmode) {
+        $currentdata = file_prepare_standard_editor($currentdata, 'feedbackauthor', $workshop->overall_feedback_content_options(),
+            $workshop->context, 'mod_workshop', 'overallfeedback_content', $assessment->id);
+        if ($workshop->overallfeedbackfiles) {
+            $currentdata = file_prepare_standard_filemanager($currentdata, 'feedbackauthorattachment',
+                $workshop->overall_feedback_attachment_options(), $workshop->context, 'mod_workshop', 'overallfeedback_attachment',
+                $assessment->id);
+        }
+    }
+    $mform->set_data($currentdata);
+
     if ($mform->is_cancelled()) {
         redirect($workshop->view_url());
     } elseif ($assessmenteditable and ($data = $mform->get_data())) {
@@ -146,10 +163,34 @@ if (is_null($assessment->grade) and !$assessmenteditable) {
         } else {
             $workshop->log('update assessment', $workshop->assess_url($assessment->id), $assessment->submissionid);
         }
+
+        // Let the grading strategy subplugin save its data.
         $rawgrade = $strategy->save_assessment($assessment, $data);
+
+        // Store the data managed by the workshop core.
+        $coredata = (object)array('id' => $assessment->id);
+        if (isset($data->feedbackauthor_editor)) {
+            $coredata->feedbackauthor_editor = $data->feedbackauthor_editor;
+            $coredata = file_postupdate_standard_editor($coredata, 'feedbackauthor', $workshop->overall_feedback_content_options(),
+                $workshop->context, 'mod_workshop', 'overallfeedback_content', $assessment->id);
+            unset($coredata->feedbackauthor_editor);
+        }
+        if (isset($data->feedbackauthorattachment_filemanager)) {
+            $coredata->feedbackauthorattachment_filemanager = $data->feedbackauthorattachment_filemanager;
+            $coredata = file_postupdate_standard_filemanager($coredata, 'feedbackauthorattachment',
+                $workshop->overall_feedback_attachment_options(), $workshop->context, 'mod_workshop', 'overallfeedback_attachment',
+                $assessment->id);
+            unset($coredata->feedbackauthorattachment_filemanager);
+            if (empty($coredata->feedbackauthorattachment)) {
+                $coredata->feedbackauthorattachment = 0;
+            }
+        }
         if (isset($data->weight) and $cansetassessmentweight) {
-            $DB->set_field('workshop_assessments', 'weight', $data->weight, array('id' => $assessment->id));
+            $coredata->weight = $data->weight;
         }
+        $DB->update_record('workshop_assessments', $coredata);
+
+        // And finally redirect the user's browser.
         if (!is_null($rawgrade) and isset($data->saveandclose)) {
             redirect($workshop->view_url());
         } else if (!is_null($rawgrade) and isset($data->saveandshownext)) {
index 7f194fb..8f2868b 100644 (file)
@@ -56,7 +56,8 @@ class backup_workshop_activity_structure_step extends backup_activity_structure_
             'strategy', 'evaluation', 'gradedecimals', 'nattachments',
             'latesubmissions', 'maxbytes', 'examplesmode', 'submissionstart',
             'submissionend', 'assessmentstart', 'assessmentend',
-            'conclusion', 'conclusionformat'));
+            'conclusion', 'conclusionformat', 'overallfeedbackmode',
+            'overallfeedbackfiles', 'overallfeedbackmaxbytes'));
 
         // assessment forms definition
         $this->add_subplugin_structure('workshopform', $workshop, true);
@@ -72,7 +73,8 @@ class backup_workshop_activity_structure_step extends backup_activity_structure_
 
         // reference assessment of the example submission
         $referenceassessment  = new backup_nested_element('referenceassessment', array('id'), array(
-            'timecreated', 'timemodified', 'grade'));
+            'timecreated', 'timemodified', 'grade', 'feedbackauthor', 'feedbackauthorformat',
+            'feedbackauthorattachment'));
 
         // dimension grades for the reference assessment (that is how the form is filled)
         $this->add_subplugin_structure('workshopform', $referenceassessment, true);
@@ -86,8 +88,8 @@ class backup_workshop_activity_structure_step extends backup_activity_structure_
         $exampleassessment  = new backup_nested_element('exampleassessment', array('id'), array(
             'reviewerid', 'weight', 'timecreated', 'timemodified', 'grade',
             'gradinggrade', 'gradinggradeover', 'gradinggradeoverby',
-            'feedbackauthor', 'feedbackauthorformat', 'feedbackreviewer',
-            'feedbackreviewerformat'));
+            'feedbackauthor', 'feedbackauthorformat', 'feedbackauthorattachment',
+            'feedbackreviewer', 'feedbackreviewerformat'));
 
         // dimension grades for the assessment of example submission (that is assessment forms are filled)
         $this->add_subplugin_structure('workshopform', $exampleassessment, true);
@@ -105,8 +107,8 @@ class backup_workshop_activity_structure_step extends backup_activity_structure_
         $assessment  = new backup_nested_element('assessment', array('id'), array(
             'reviewerid', 'weight', 'timecreated', 'timemodified', 'grade',
             'gradinggrade', 'gradinggradeover', 'gradinggradeoverby',
-            'feedbackauthor', 'feedbackauthorformat', 'feedbackreviewer',
-            'feedbackreviewerformat'));
+            'feedbackauthor', 'feedbackauthorformat', 'feedbackauthorattachment',
+            'feedbackreviewer', 'feedbackreviewerformat'));
 
         // dimension grades for the assessment (that is assessment forms are filled)
         $this->add_subplugin_structure('workshopform', $assessment, true);
@@ -200,9 +202,18 @@ class backup_workshop_activity_structure_step extends backup_activity_structure_
         $examplesubmission->annotate_files('mod_workshop', 'submission_content', 'id');
         $examplesubmission->annotate_files('mod_workshop', 'submission_attachment', 'id');
 
+        $referenceassessment->annotate_files('mod_workshop', 'overallfeedback_content', 'id');
+        $referenceassessment->annotate_files('mod_workshop', 'overallfeedback_attachment', 'id');
+
+        $exampleassessment->annotate_files('mod_workshop', 'overallfeedback_content', 'id');
+        $exampleassessment->annotate_files('mod_workshop', 'overallfeedback_attachment', 'id');
+
         $submission->annotate_files('mod_workshop', 'submission_content', 'id');
         $submission->annotate_files('mod_workshop', 'submission_attachment', 'id');
 
+        $assessment->annotate_files('mod_workshop', 'overallfeedback_content', 'id');
+        $assessment->annotate_files('mod_workshop', 'overallfeedback_attachment', 'id');
+
         // return the root element (workshop), wrapped into standard activity structure
         return $this->prepare_activity_structure($workshop);
     }
index 616bed8..fdb441f 100644 (file)
@@ -145,7 +145,7 @@ class restore_workshop_activity_structure_step extends restore_activity_structur
         $data->timemodified = $this->apply_date_offset($data->timemodified);
 
         $newitemid = $DB->insert_record('workshop_assessments', $data);
-        $this->set_mapping('workshop_referenceassessment', $oldid, $newitemid);
+        $this->set_mapping('workshop_referenceassessment', $oldid, $newitemid, true); // Mapping with files
     }
 
     protected function process_workshop_exampleassessment($data) {
@@ -160,7 +160,7 @@ class restore_workshop_activity_structure_step extends restore_activity_structur
         $data->timemodified = $this->apply_date_offset($data->timemodified);
 
         $newitemid = $DB->insert_record('workshop_assessments', $data);
-        $this->set_mapping('workshop_exampleassessment', $oldid, $newitemid);
+        $this->set_mapping('workshop_exampleassessment', $oldid, $newitemid, true); // Mapping with files
     }
 
     protected function process_workshop_submission($data) {
@@ -191,7 +191,7 @@ class restore_workshop_activity_structure_step extends restore_activity_structur
         $data->timemodified = $this->apply_date_offset($data->timemodified);
 
         $newitemid = $DB->insert_record('workshop_assessments', $data);
-        $this->set_mapping('workshop_assessment', $oldid, $newitemid);
+        $this->set_mapping('workshop_assessment', $oldid, $newitemid, true); // Mapping with files
     }
 
     protected function process_workshop_aggregation($data) {
@@ -218,8 +218,20 @@ class restore_workshop_activity_structure_step extends restore_activity_structur
         $this->add_related_files('mod_workshop', 'submission_content', 'workshop_examplesubmission');
         $this->add_related_files('mod_workshop', 'submission_attachment', 'workshop_examplesubmission');
 
+        // Add reference assessment related files, matching by 'workshop_referenceassessment' itemname
+        $this->add_related_files('mod_workshop', 'overallfeedback_content', 'workshop_referenceassessment');
+        $this->add_related_files('mod_workshop', 'overallfeedback_attachment', 'workshop_referenceassessment');
+
+        // Add example assessment related files, matching by 'workshop_exampleassessment' itemname
+        $this->add_related_files('mod_workshop', 'overallfeedback_content', 'workshop_exampleassessment');
+        $this->add_related_files('mod_workshop', 'overallfeedback_attachment', 'workshop_exampleassessment');
+
         // Add submission related files, matching by 'workshop_submission' itemname
         $this->add_related_files('mod_workshop', 'submission_content', 'workshop_submission');
         $this->add_related_files('mod_workshop', 'submission_attachment', 'workshop_submission');
+
+        // Add assessment related files, matching by 'workshop_assessment' itemname
+        $this->add_related_files('mod_workshop', 'overallfeedback_content', 'workshop_assessment');
+        $this->add_related_files('mod_workshop', 'overallfeedback_attachment', 'workshop_assessment');
     }
 }
index ee231bd..260787f 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/workshop/db" VERSION="20121024" COMMENT="XMLDB file for Moodle mod/workshop"
+<XMLDB PATH="mod/workshop/db" VERSION="20130325" COMMENT="XMLDB file for Moodle mod/workshop"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -36,6 +36,9 @@
         <FIELD NAME="phaseswitchassessment" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Automatically switch to the assessment phase after the submissions deadline"/>
         <FIELD NAME="conclusion" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="A text to be displayed at the end of the workshop."/>
         <FIELD NAME="conclusionformat" TYPE="int" LENGTH="3" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="The format of the conclusion field content."/>
+        <FIELD NAME="overallfeedbackmode" TYPE="int" LENGTH="3" NOTNULL="false" DEFAULT="1" SEQUENCE="false" COMMENT="Mode of the overall feedback support."/>
+        <FIELD NAME="overallfeedbackfiles" TYPE="int" LENGTH="3" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Number of allowed attachments to the overall feedback."/>
+        <FIELD NAME="overallfeedbackmaxbytes" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="100000" SEQUENCE="false" COMMENT="Maximum size of one file attached to the overall feedback."/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
@@ -85,6 +88,7 @@
         <FIELD NAME="gradinggradeoverby" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="The id of the user who has overridden the grade for submission."/>
         <FIELD NAME="feedbackauthor" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The comment/feedback from the reviewer for the author."/>
         <FIELD NAME="feedbackauthorformat" TYPE="int" LENGTH="3" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="feedbackauthorattachment" TYPE="int" LENGTH="3" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Are there some files attached to the feedbackauthor field? Sets to 1 by file_postupdate_standard_filemanager()."/>
         <FIELD NAME="feedbackreviewer" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The comment/feedback from the teacher for the reviewer. For example the reason why the grade for assessment was overridden"/>
         <FIELD NAME="feedbackreviewerformat" TYPE="int" LENGTH="3" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
       </FIELDS>
index ba21f49..950149c 100644 (file)
@@ -112,6 +112,43 @@ function xmldb_workshop_upgrade($oldversion) {
     // Moodle v2.4.0 release upgrade line
     // Put any upgrade step following this
 
+    /**
+     * Add overall feedback related fields into the workshop table.
+     */
+    if ($oldversion < 2013032500) {
+        $table = new xmldb_table('workshop');
+
+        $field = new xmldb_field('overallfeedbackmode', XMLDB_TYPE_INTEGER, '3', null, null, null, '1', 'conclusionformat');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        $field = new xmldb_field('overallfeedbackfiles', XMLDB_TYPE_INTEGER, '3', null, null, null, '0', 'overallfeedbackmode');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        $field = new xmldb_field('overallfeedbackmaxbytes', XMLDB_TYPE_INTEGER, '10', null, null, null, '100000', 'overallfeedbackfiles');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        upgrade_mod_savepoint(true, 2013032500, 'workshop');
+    }
+
+    /**
+     * Add feedbackauthorattachment field into the workshop_assessments table.
+     */
+    if ($oldversion < 2013032501) {
+        $table = new xmldb_table('workshop_assessments');
+        $field = new xmldb_field('feedbackauthorattachment', XMLDB_TYPE_INTEGER, '3', null, null, null, '0', 'feedbackauthorformat');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        upgrade_mod_savepoint(true, 2013032501, 'workshop');
+    }
+
 
     return true;
 }
index 559ec94..6112a41 100644 (file)
@@ -67,6 +67,23 @@ $strategy = $workshop->grading_strategy_instance();
 
 // load the assessment form and process the submitted data eventually
 $mform = $strategy->get_assessment_form($PAGE->url, 'assessment', $assessment, $assessmenteditable);
+
+// Set data managed by the workshop core, subplugins set their own data themselves.
+$currentdata = (object)array(
+    'feedbackauthor' => $assessment->feedbackauthor,
+    'feedbackauthorformat' => $assessment->feedbackauthorformat,
+);
+if ($assessmenteditable and $workshop->overallfeedbackmode) {
+    $currentdata = file_prepare_standard_editor($currentdata, 'feedbackauthor', $workshop->overall_feedback_content_options(),
+        $workshop->context, 'mod_workshop', 'overallfeedback_content', $assessment->id);
+    if ($workshop->overallfeedbackfiles) {
+        $currentdata = file_prepare_standard_filemanager($currentdata, 'feedbackauthorattachment',
+            $workshop->overall_feedback_attachment_options(), $workshop->context, 'mod_workshop', 'overallfeedback_attachment',
+            $assessment->id);
+    }
+}
+$mform->set_data($currentdata);
+
 if ($mform->is_cancelled()) {
     redirect($workshop->view_url());
 } elseif ($assessmenteditable and ($data = $mform->get_data())) {
@@ -83,11 +100,34 @@ if ($mform->is_cancelled()) {
             $workshop->log('update example assessment', $workshop->exassess_url($assessment->id), $assessment->submissionid);
         }
     }
+
+    // Let the grading strategy subplugin save its data.
     $rawgrade = $strategy->save_assessment($assessment, $data);
+
+    // Store the data managed by the workshop core.
+    $coredata = (object)array('id' => $assessment->id);
+    if (isset($data->feedbackauthor_editor)) {
+        $coredata->feedbackauthor_editor = $data->feedbackauthor_editor;
+        $coredata = file_postupdate_standard_editor($coredata, 'feedbackauthor', $workshop->overall_feedback_content_options(),
+            $workshop->context, 'mod_workshop', 'overallfeedback_content', $assessment->id);
+        unset($coredata->feedbackauthor_editor);
+    }
+    if (isset($data->feedbackauthorattachment_filemanager)) {
+        $coredata->feedbackauthorattachment_filemanager = $data->feedbackauthorattachment_filemanager;
+        $coredata = file_postupdate_standard_filemanager($coredata, 'feedbackauthorattachment',
+            $workshop->overall_feedback_attachment_options(), $workshop->context, 'mod_workshop', 'overallfeedback_attachment',
+            $assessment->id);
+        unset($coredata->feedbackauthorattachment_filemanager);
+        if (empty($coredata->feedbackauthorattachment)) {
+            $coredata->feedbackauthorattachment = 0;
+        }
+    }
     if ($canmanage) {
-        // remember the last one who edited the reference assessment
-        $DB->set_field('workshop_assessments', 'reviewerid', $USER->id, array('id' => $assessment->id));
+        // Remember the last one who edited the reference assessment.
+        $coredata->reviewerid = $USER->id;
     }
+    $DB->update_record('workshop_assessments', $coredata);
+
     if (!is_null($rawgrade) and isset($data->saveandclose)) {
         if ($canmanage) {
             redirect($workshop->view_url());
index 0427819..5b85279 100644 (file)
@@ -179,3 +179,164 @@ class workshop_file_info_submissions_container extends file_info {
         return $this->browser->get_file_info($this->context);
     }
 }
+
+
+/**
+ * Represents virtual root node for all files in overall feedbacks
+ *
+ * Overall feedback uses two fileareas: overallfeedback_content for editor's embeded media
+ * and overallfeedback_attachment for attachments. In both, the itemid represents the assessment id.
+ * This container is used to display the list of all submissions in these areas (ie when
+ * these areas are browsed with itemid == null).
+ */
+class workshop_file_info_overallfeedback_container extends file_info {
+    protected $course;
+    protected $cm;
+    protected $areas;
+    protected $filearea;
+
+    public function __construct($browser, $course, $cm, $context, $areas, $filearea) {
+        parent::__construct($browser, $context);
+        $this->course   = $course;
+        $this->cm       = $cm;
+        $this->areas    = $areas;
+        $this->filearea = $filearea;
+    }
+
+    /**
+     * Returns list of standard virtual file/directory identification
+     *
+     * The difference from stored_file parameters is that null values
+     * are allowed in all fields.
+     *
+     * @return array with keys contextid, filearea, itemid, filepath and filename
+     */
+    public function get_params() {
+        return array('contextid' => $this->context->id,
+                     'component' => 'mod_workshop',
+                     'filearea' => $this->filearea,
+                     'itemid' => null,
+                     'filepath' => null,
+                     'filename' => null);
+    }
+
+    /**
+     * Returns localised visible name
+     *
+     * @return string
+     */
+    public function get_visible_name() {
+        return $this->areas[$this->filearea];
+    }
+
+    /**
+     * Can I add new files or directories?
+     *
+     * @return bool
+     */
+    public function is_writable() {
+        return false;
+    }
+
+    /**
+     * Is directory?
+     *
+     * @return bool
+     */
+    public function is_directory() {
+        return true;
+    }
+
+
+    /**
+     * Returns list of children nodes
+     *
+     * @return array of file_info instances
+     */
+    public function get_children() {
+        return $this->get_filtered_children('*', false, true);
+    }
+
+    /**
+     * Helper function to return files matching extensions or their count
+     *
+     * @param string|array $extensions, either '*' or array of lowercase extensions, i.e. array('.gif','.jpg')
+     * @param bool|int $countonly if false returns the children, if an int returns just the
+     *    count of children but stops counting when $countonly number of children is reached
+     * @param bool $returnemptyfolders if true returns items that don't have matching files inside
+     * @return array|int array of file_info instances or the count
+     */
+    private function get_filtered_children($extensions = '*', $countonly = false, $returnemptyfolders = false) {
+        global $DB;
+
+        $params = array('contextid' => $this->context->id,
+            'component' => 'mod_workshop',
+            'filearea' => $this->filearea);
+        $sql = "SELECT DISTINCT itemid
+                  FROM {files}
+                 WHERE contextid = :contextid
+                       AND component = :component
+                       AND filearea = :filearea";
+        if (!$returnemptyfolders) {
+            $sql .= " AND filename <> :emptyfilename";
+            $params['emptyfilename'] = '.';
+        }
+        list($sql2, $params2) = $this->build_search_files_sql($extensions);
+        $sql .= ' '.$sql2;
+        $params = array_merge($params, $params2);
+        if ($countonly !== false) {
+            $sql .= " ORDER BY itemid DESC";
+        }
+
+        $rs = $DB->get_recordset_sql($sql, $params);
+        $children = array();
+        foreach ($rs as $record) {
+            if (($child = $this->browser->get_file_info($this->context, 'mod_workshop', $this->filearea, $record->itemid))
+                    && ($returnemptyfolders || $child->count_non_empty_children($extensions))) {
+                $children[] = $child;
+                if ($countonly !== false && count($children) >= $countonly) {
+                    break;
+                }
+            }
+        }
+        $rs->close();
+        if ($countonly !== false) {
+            return count($children);
+        }
+        return $children;
+    }
+
+    /**
+     * Returns list of children which are either files matching the specified extensions
+     * or folders that contain at least one such file.
+     *
+     * @param string|array $extensions, either '*' or array of lowercase extensions, i.e. array('.gif','.jpg')
+     * @return array of file_info instances
+     */
+    public function get_non_empty_children($extensions = '*') {
+        return $this->get_filtered_children($extensions, false);
+    }
+
+    /**
+     * Returns the number of children which are either files matching the specified extensions
+     * or folders containing at least one such file.
+     *
+     * NOTE: We don't need the exact number of non empty children if it is >=2
+     * In this function 1 is never returned to avoid skipping the single subfolder
+     *
+     * @param string|array $extensions, for example '*' or array('.gif','.jpg')
+     * @param int $limit stop counting after at least $limit non-empty children are found
+     * @return int
+     */
+    public function count_non_empty_children($extensions = '*', $limit = 1) {
+        return $this->get_filtered_children($extensions, $limit);
+    }
+
+    /**
+     * Returns parent file_info instance
+     * @return file_info or null for root
+     */
+    public function get_parent() {
+        return $this->browser->get_file_info($this->context);
+    }
+}
index e7b797a..82e1b6b 100644 (file)
@@ -68,7 +68,21 @@ class workshop_assessment_form extends moodleform {
         $mform->addElement('hidden', 'strategy', $this->workshop->strategy);
         $mform->setType('strategy', PARAM_PLUGIN);
 
-        if (!empty($this->options['editableweight']) and !$mform->isFrozen()) {
+        if ($this->workshop->overallfeedbackmode and $this->is_editable()) {
+            $mform->addElement('header', 'overallfeedbacksection', get_string('overallfeedback', 'mod_workshop'));
+            $mform->addElement('editor', 'feedbackauthor_editor', get_string('feedbackauthor', 'mod_workshop'), null,
+                $this->workshop->overall_feedback_content_options());
+            if ($this->workshop->overallfeedbackmode == 2) {
+                $mform->addRule('feedbackauthor_editor', null, 'required', null, 'client');
+            }
+            if ($this->workshop->overallfeedbackfiles) {
+                $mform->addElement('filemanager', 'feedbackauthorattachment_filemanager',
+                    get_string('feedbackauthorattachment', 'mod_workshop'), null,
+                    $this->workshop->overall_feedback_attachment_options());
+            }
+        }
+
+        if (!empty($this->options['editableweight']) and $this->is_editable()) {
             $mform->addElement('header', 'assessmentsettings', get_string('assessmentweight', 'workshop'));
             $mform->addElement('select', 'weight',
                     get_string('assessmentweight', 'workshop'), workshop::available_assessment_weights_list());
@@ -100,4 +114,12 @@ class workshop_assessment_form extends moodleform {
         // By default, do nothing.
     }
 
+    /**
+     * Is the form frozen (read-only)?
+     *
+     * @return boolean
+     */
+    public function is_editable() {
+        return !$this->_form->isFrozen();
+    }
 }
index b1b2101..c438f07 100644 (file)
@@ -38,6 +38,8 @@ $string['alreadygraded'] = 'Already graded';
 $string['areaconclusion'] = 'Conclusion text';
 $string['areainstructauthors'] = 'Instructions for submission';
 $string['areainstructreviewers'] = 'Instructions for assessment';
+$string['areaoverallfeedbackattachment'] = 'Overall feedback attachments';
+$string['areaoverallfeedbackcontent'] = 'Overall feedback texts';
 $string['areasubmissionattachment'] = 'Submission attachments';
 $string['areasubmissioncontent'] = 'Submission texts';
 $string['assess'] = 'Assess';
@@ -124,6 +126,7 @@ $string['examplesmode'] = 'Mode of examples assessment';
 $string['examplesubmissions'] = 'Example submissions';
 $string['examplesvoluntary'] = 'Assessment of example submission is voluntary';
 $string['feedbackauthor'] = 'Feedback for the author';
+$string['feedbackauthorattachment'] = 'Attachment';
 $string['feedbackby'] = 'Feedback by {$a}';
 $string['feedbackreviewer'] = 'Feedback for the reviewer';
 $string['formataggregatedgrade'] = '{$a->grade}';
@@ -184,6 +187,14 @@ $string['notoverridden'] = 'Not overriden';
 $string['noworkshops'] = 'There are no workshops in this course';
 $string['noyoursubmission'] = 'You have not submitted your work yet';
 $string['nullgrade'] = '-';
+$string['overallfeedback'] = 'Overall feedback';
+$string['overallfeedbackfiles'] = 'Maximum number of overall feedback attachments';
+$string['overallfeedbackmaxbytes'] = 'Maximum file size';
+$string['overallfeedbackmode'] = 'Overall feedback mode';
+$string['overallfeedbackmode_0'] = 'Disabled';
+$string['overallfeedbackmode_1'] = 'Enabled and optional';
+$string['overallfeedbackmode_2'] = 'Enabled and required';
+$string['overallfeedbackmode_help'] = 'If enabled, a text field is displayed at the bottom of the assessment form. Reviewers can put the overall assessment of the submission there, or provide additional explanation of their assessment.';
 $string['page-mod-workshop-x'] = 'Any workshop module page';
 $string['participant'] = 'Participant';
 $string['participantrevierof'] = 'Participant is reviewer of';
index 6959e54..a1c94c0 100644 (file)
@@ -1184,6 +1184,8 @@ function workshop_get_file_areas($course, $cm, $context) {
     $areas['submission_content']       = get_string('areasubmissioncontent', 'workshop');
     $areas['submission_attachment']    = get_string('areasubmissionattachment', 'workshop');
     $areas['conclusion']               = get_string('areaconclusion', 'workshop');
+    $areas['overallfeedback_content']  = get_string('areaoverallfeedbackcontent', 'workshop');
+    $areas['overallfeedback_attachment'] = get_string('areaoverallfeedbackattachment', 'workshop');
 
     return $areas;
 }
@@ -1304,6 +1306,55 @@ function workshop_pluginfile($course, $cm, $context, $filearea, array $args, $fo
             }
         }
 
+        $fs = get_file_storage();
+        $relativepath = implode('/', $args);
+        $fullpath = "/$context->id/mod_workshop/$filearea/$itemid/$relativepath";
+        if (!$file = $fs->get_file_by_hash(sha1($fullpath)) or $file->is_directory()) {
+            return false;
+        }
+        // finally send the file
+        // these files are uploaded by students - forcing download for security reasons
+        send_stored_file($file, 0, 0, true, $options);
+
+    } else if ($filearea === 'overallfeedback_content' or $filearea === 'overallfeedback_attachment') {
+        $itemid = (int)array_shift($args);
+        if (!$workshop = $DB->get_record('workshop', array('id' => $cm->instance))) {
+            return false;
+        }
+        if (!$assessment = $DB->get_record('workshop_assessments', array('id' => $itemid))) {
+            return false;
+        }
+        if (!$submission = $DB->get_record('workshop_submissions', array('id' => $assessment->submissionid, 'workshopid' => $workshop->id))) {
+            return false;
+        }
+
+        if ($USER->id == $assessment->reviewerid) {
+            // Reviewers can always see their own files.
+        } else if ($USER->id == $submission->authorid and $workshop->phase == 50) {
+            // Authors can see the feedback once the workshop is closed.
+        } else if (!empty($submission->example) and $assessment->weight == 1) {
+            // Reference assessments of example submissions can be displayed.
+        } else if (!has_capability('mod/workshop:viewallassessments', $context)) {
+            send_file_not_found();
+        } else {
+            $gmode = groups_get_activity_groupmode($cm, $course);
+            if ($gmode == SEPARATEGROUPS and !has_capability('moodle/site:accessallgroups', $context)) {
+                // Check there is at least one common group with both the $USER
+                // and the submission author.
+                $sql = "SELECT 'x'
+                          FROM {workshop_submissions} s
+                          JOIN {user} a ON (a.id = s.authorid)
+                          JOIN {groups_members} agm ON (a.id = agm.userid)
+                          JOIN {user} u ON (u.id = ?)
+                          JOIN {groups_members} ugm ON (u.id = ugm.userid)
+                         WHERE s.example = 0 AND s.workshopid = ? AND s.id = ? AND agm.groupid = ugm.groupid";
+                $params = array($USER->id, $workshop->id, $submission->id);
+                if (!$DB->record_exists_sql($sql, $params)) {
+                    send_file_not_found();
+                }
+            }
+        }
+
         $fs = get_file_storage();
         $relativepath = implode('/', $args);
         $fullpath = "/$context->id/mod_workshop/$filearea/$itemid/$relativepath";
@@ -1337,6 +1388,7 @@ function workshop_pluginfile($course, $cm, $context, $filearea, array $args, $fo
  */
 function workshop_get_file_info($browser, $areas, $course, $cm, $context, $filearea, $itemid, $filepath, $filename) {
     global $CFG, $DB, $USER;
+
     /** @var array internal cache for author names */
     static $submissionauthors = array();
 
@@ -1427,6 +1479,60 @@ function workshop_get_file_info($browser, $areas, $course, $cm, $context, $filea
         return new file_info_stored($browser, $context, $storedfile, $urlbase, $topvisiblename, true, true, false, false);
     }
 
+    if ($filearea === 'overallfeedback_content' or $filearea === 'overallfeedback_attachment') {
+
+        if (!has_capability('mod/workshop:viewallassessments', $context)) {
+            return null;
+        }
+
+        if (is_null($itemid)) {
+            // No itemid (assessmentid) passed, display the list of all assessments.
+            require_once($CFG->dirroot . '/mod/workshop/fileinfolib.php');
+            return new workshop_file_info_overallfeedback_container($browser, $course, $cm, $context, $areas, $filearea);
+        }
+
+        // Make sure the user can see the particular assessment in separate groups mode.
+        $gmode = groups_get_activity_groupmode($cm, $course);
+        if ($gmode == SEPARATEGROUPS and !has_capability('moodle/site:accessallgroups', $context)) {
+            // Check there is at least one common group with both the $USER
+            // and the submission author.
+            $sql = "SELECT 'x'
+                      FROM {workshop_submissions} s
+                      JOIN {user} a ON (a.id = s.authorid)
+                      JOIN {groups_members} agm ON (a.id = agm.userid)
+                      JOIN {user} u ON (u.id = ?)
+                      JOIN {groups_members} ugm ON (u.id = ugm.userid)
+                     WHERE s.example = 0 AND s.workshopid = ? AND s.id = ? AND agm.groupid = ugm.groupid";
+            $params = array($USER->id, $cm->instance, $itemid);
+            if (!$DB->record_exists_sql($sql, $params)) {
+                return null;
+            }
+        }
+
+        // We are inside a particular assessment container.
+        $filepath = is_null($filepath) ? '/' : $filepath;
+        $filename = is_null($filename) ? '.' : $filename;
+
+        if (!$storedfile = $fs->get_file($context->id, 'mod_workshop', $filearea, $itemid, $filepath, $filename)) {
+            if ($filepath === '/' and $filename === '.') {
+                $storedfile = new virtual_root_file($context->id, 'mod_workshop', $filearea, $itemid);
+            } else {
+                // Not found
+                return null;
+            }
+        }
+
+        // Check to see if the user can manage files or is the owner.
+        if (!has_capability('moodle/course:managefiles', $context) and $storedfile->get_userid() != $USER->id) {
+            return null;
+        }
+
+        $urlbase = $CFG->wwwroot . '/pluginfile.php';
+
+        // Do not allow manual modification of any files.
+        return new file_info_stored($browser, $context, $storedfile, $urlbase, $itemid, true, true, false, false);
+    }
+
     if ($filearea == 'instructauthors' or $filearea == 'instructreviewers' or $filearea == 'conclusion') {
         // always only itemid 0
 
index 7cebddc..cb9bf6d 100644 (file)
@@ -155,6 +155,15 @@ class workshop {
     /** @var int format of the conclusion text */
     public $conclusionformat;
 
+    /** @var int the mode of the overall feedback */
+    public $overallfeedbackmode;
+
+    /** @var int maximum number of overall feedback attachments */
+    public $overallfeedbackfiles;
+
+    /** @var int maximum size of one file attached to the overall feedback */
+    public $overallfeedbackmaxbytes;
+
     /**
      * @var workshop_strategy grading strategy instance
      * Do not use directly, get the instance using {@link workshop::grading_strategy_instance()}
@@ -855,7 +864,7 @@ class workshop {
      */
     public function prepare_submission(stdClass $record, $showauthor = false) {
 
-        $submission         = new workshop_submission($record, $showauthor);
+        $submission         = new workshop_submission($this, $record, $showauthor);
         $submission->url    = $this->submission_url($record->id);
 
         return $submission;
@@ -870,7 +879,7 @@ class workshop {
      */
     public function prepare_submission_summary(stdClass $record, $showauthor = false) {
 
-        $summary        = new workshop_submission_summary($record, $showauthor);
+        $summary        = new workshop_submission_summary($this, $record, $showauthor);
         $summary->url   = $this->submission_url($record->id);
 
         return $summary;
@@ -884,7 +893,7 @@ class workshop {
      */
     public function prepare_example_submission(stdClass $record) {
 
-        $example = new workshop_example_submission($record);
+        $example = new workshop_example_submission($this, $record);
 
         return $example;
     }
@@ -899,7 +908,7 @@ class workshop {
      */
     public function prepare_example_summary(stdClass $example) {
 
-        $summary = new workshop_example_submission_summary($example);
+        $summary = new workshop_example_submission_summary($this, $example);
 
         if (is_null($example->grade)) {
             $summary->status = 'notgraded';
@@ -936,7 +945,7 @@ class workshop {
      */
     public function prepare_assessment(stdClass $record, $form, array $options = array()) {
 
-        $assessment             = new workshop_assessment($record, $options);
+        $assessment             = new workshop_assessment($this, $record, $options);
         $assessment->url        = $this->assess_url($record->id);
         $assessment->maxgrade   = $this->real_grade(100);
 
@@ -974,7 +983,7 @@ class workshop {
      */
     public function prepare_example_assessment(stdClass $record, $form = null, array $options = array()) {
 
-        $assessment             = new workshop_example_assessment($record, $options);
+        $assessment             = new workshop_example_assessment($this, $record, $options);
         $assessment->url        = $this->exassess_url($record->id);
         $assessment->maxgrade   = $this->real_grade(100);
 
@@ -1010,7 +1019,7 @@ class workshop {
      */
     public function prepare_example_reference_assessment(stdClass $record, $form = null, array $options = array()) {
 
-        $assessment             = new workshop_example_reference_assessment($record, $options);
+        $assessment             = new workshop_example_reference_assessment($this, $record, $options);
         $assessment->maxgrade   = $this->real_grade(100);
 
         if (!empty($options['showform']) and !($form instanceof workshop_assessment_form)) {
@@ -1236,7 +1245,7 @@ class workshop {
         $assessment->reviewerid             = $reviewerid;
         $assessment->timecreated            = $now;         // do not set timemodified here
         $assessment->weight                 = $weight;
-        $assessment->generalcommentformat   = editors_get_preferred_format();
+        $assessment->feedbackauthorformat   = editors_get_preferred_format();
         $assessment->feedbackreviewerformat = editors_get_preferred_format();
 
         return $DB->insert_record('workshop_assessments', $assessment, true, $bulk);
@@ -2293,6 +2302,35 @@ class workshop {
         return false;
     }
 
+    /**
+     * Return the editor options for the overall feedback for the author.
+     *
+     * @return array
+     */
+    public function overall_feedback_content_options() {
+        return array(
+            'subdirs' => 0,
+            'maxbytes' => $this->overallfeedbackmaxbytes,
+            'maxfiles' => $this->overallfeedbackfiles,
+            'changeformat' => 1,
+            'context' => $this->context,
+        );
+    }
+
+    /**
+     * Return the filemanager options for the overall feedback for the author.
+     *
+     * @return array
+     */
+    public function overall_feedback_attachment_options() {
+        return array(
+            'subdirs' => 1,
+            'maxbytes' => $this->overallfeedbackmaxbytes,
+            'maxfiles' => $this->overallfeedbackfiles,
+            'return_types' => FILE_INTERNAL,
+        );
+    }
+
     ////////////////////////////////////////////////////////////////////////////////
     // Internal methods (implementation details)                                  //
     ////////////////////////////////////////////////////////////////////////////////
@@ -2987,14 +3025,20 @@ abstract class workshop_submission_base {
     /* @var array of columns from workshop_submissions that are assigned as properties */
     protected $fields = array();
 
+    /** @var workshop */
+    protected $workshop;
+
     /**
      * Copies the properties of the given database record into properties of $this instance
      *
+     * @param workshop $workshop
      * @param stdClass $submission full record
      * @param bool $showauthor show the author-related information
      * @param array $options additional properties
      */
-    public function __construct(stdClass $submission, $showauthor = false) {
+    public function __construct(workshop $workshop, stdClass $submission, $showauthor = false) {
+
+        $this->workshop = $workshop;
 
         foreach ($this->fields as $field) {
             if (!property_exists($submission, $field)) {
@@ -3207,15 +3251,20 @@ abstract class workshop_assessment_base {
     /* @var array of columns that are assigned as properties */
     protected $fields = array();
 
+    /** @var workshop */
+    protected $workshop;
+
     /**
      * Copies the properties of the given database record into properties of $this instance
      *
      * The $options keys are: showreviewer, showauthor
+     * @param workshop $workshop
      * @param stdClass $assessment full record
      * @param array $options additional properties
      */
-    public function __construct(stdClass $record, array $options = array()) {
+    public function __construct(workshop $workshop, stdClass $record, array $options = array()) {
 
+        $this->workshop = $workshop;
         $this->validate_raw_record($record);
 
         foreach ($this->fields as $field) {
@@ -3295,9 +3344,94 @@ class workshop_assessment extends workshop_assessment_base implements renderable
     /** @var float */
     public $gradinggradeover;
 
+    /** @var string */
+    public $feedbackauthor;
+
+    /** @var int */
+    public $feedbackauthorformat;
+
+    /** @var int */
+    public $feedbackauthorattachment;
+
     /** @var array */
     protected $fields = array('id', 'submissionid', 'weight', 'timecreated',
-        'timemodified', 'grade', 'gradinggrade', 'gradinggradeover');
+        'timemodified', 'grade', 'gradinggrade', 'gradinggradeover', 'feedbackauthor',
+        'feedbackauthorformat', 'feedbackauthorattachment');
+
+    /**
+     * Format the overall feedback text content
+     *
+     * False is returned if the overall feedback feature is disabled. Null is returned
+     * if the overall feedback content has not been found. Otherwise, string with
+     * formatted feedback text is returned.
+     *
+     * @return string|bool|null
+     */
+    public function get_overall_feedback_content() {
+
+        if ($this->workshop->overallfeedbackmode == 0) {
+            return false;
+        }
+
+        if (trim($this->feedbackauthor) === '') {
+            return null;
+        }
+
+        $content = format_text($this->feedbackauthor, $this->feedbackauthorformat,
+            array('overflowdiv' => true, 'context' => $this->workshop->context));
+        $content = file_rewrite_pluginfile_urls($content, 'pluginfile.php', $this->workshop->context->id,
+            'mod_workshop', 'overallfeedback_content', $this->id);
+
+        return $content;
+    }
+
+    /**
+     * Prepares the list of overall feedback attachments
+     *
+     * Returns false if overall feedback attachments are not allowed. Otherwise returns
+     * list of attachments (may be empty).
+     *
+     * @return bool|array of stdClass
+     */
+    public function get_overall_feedback_attachments() {
+
+        if ($this->workshop->overallfeedbackmode == 0) {
+            return false;
+        }
+
+        if ($this->workshop->overallfeedbackfiles == 0) {
+            return false;
+        }
+
+        if (empty($this->feedbackauthorattachment)) {
+            return array();
+        }
+
+        $attachments = array();
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($this->workshop->context->id, 'mod_workshop', 'overallfeedback_attachment', $this->id);
+        foreach ($files as $file) {
+            if ($file->is_directory()) {
+                continue;
+            }
+            $filepath = $file->get_filepath();
+            $filename = $file->get_filename();
+            $fileurl = moodle_url::make_pluginfile_url($this->workshop->context->id, 'mod_workshop',
+                'overallfeedback_attachment', $this->id, $filepath, $filename, true);
+            $previewurl = new moodle_url(moodle_url::make_pluginfile_url($this->workshop->context->id, 'mod_workshop',
+                'overallfeedback_attachment', $this->id, $filepath, $filename, false), array('preview' => 'bigthumb'));
+            $attachments[] = (object)array(
+                'filepath' => $filepath,
+                'filename' => $filename,
+                'fileurl' => $fileurl,
+                'previewurl' => $previewurl,
+                'mimetype' => $file->get_mimetype(),
+
+            );
+        }
+
+        return $attachments;
+    }
 }
 
 
index 4875559..db728d1 100644 (file)
@@ -164,6 +164,30 @@ class mod_workshop_mod_form extends moodleform_mod {
         $mform->addElement('editor', 'instructreviewerseditor', $label, null,
                             workshop::instruction_editors_options($this->context));
 
+        $mform->addElement('select', 'overallfeedbackmode', get_string('overallfeedbackmode', 'mod_workshop'), array(
+            0 => get_string('overallfeedbackmode_0', 'mod_workshop'),
+            1 => get_string('overallfeedbackmode_1', 'mod_workshop'),
+            2 => get_string('overallfeedbackmode_2', 'mod_workshop')));
+        $mform->addHelpButton('overallfeedbackmode', 'overallfeedbackmode', 'mod_workshop');
+        $mform->setDefault('overallfeedbackmode', 1);
+        $mform->setAdvanced('overallfeedbackmode');
+
+        $options = array();
+        for ($i = 7; $i >= 0; $i--) {
+            $options[$i] = $i;
+        }
+        $mform->addElement('select', 'overallfeedbackfiles', get_string('overallfeedbackfiles', 'workshop'), $options);
+        $mform->setDefault('overallfeedbackfiles', 0);
+        $mform->setAdvanced('overallfeedbackfiles');
+        $mform->disabledIf('overallfeedbackfiles', 'overallfeedbackmode', 'eq', 0);
+
+        $options = get_max_upload_sizes($CFG->maxbytes, $this->course->maxbytes);
+        $mform->addElement('select', 'overallfeedbackmaxbytes', get_string('overallfeedbackmaxbytes', 'workshop'), $options);
+        $mform->setAdvanced('overallfeedbackmaxbytes');
+        $mform->setDefault('overallfeedbackmaxbytes', $workshopconfig->maxbytes);
+        $mform->disabledIf('overallfeedbackmaxbytes', 'overallfeedbackmode', 'eq', 0);
+        $mform->disabledIf('overallfeedbackmaxbytes', 'overallfeedbackfiles', 'eq', 0);
+
         $label = get_string('examplesmode', 'workshop');
         $options = workshop::available_example_modes_list();
         $mform->addElement('select', 'examplesmode', $label, $options);
index fd67d95..74a69cc 100644 (file)
@@ -673,6 +673,10 @@ class mod_workshop_renderer extends plugin_renderer_base {
                     get_string('assessmentform', 'workshop'), '', false, true);
             $o .= $this->output->container(self::moodleform($assessment->form), 'assessment-form');
             $o .= print_collapsible_region_end(true);
+
+            if (!$assessment->form->is_editable()) {
+                $o .= $this->overall_feedback($assessment);
+            }
         }
 
         $o .= $this->output->container_end(); // main wrapper
@@ -700,6 +704,67 @@ class mod_workshop_renderer extends plugin_renderer_base {
         return $this->render_workshop_assessment($assessment);
     }
 
+    /**
+     * Renders the overall feedback for the author of the submission
+     *
+     * @param workshop_assessment $assessment
+     * @return string HTML
+     */
+    protected function overall_feedback(workshop_assessment $assessment) {
+
+        $content = $assessment->get_overall_feedback_content();
+
+        if ($content === false) {
+            return '';
+        }
+
+        $o = '';
+
+        if (!is_null($content)) {
+            $o .= $this->output->container($content, 'content');
+        }
+
+        $attachments = $assessment->get_overall_feedback_attachments();
+
+        if (!empty($attachments)) {
+            $o .= $this->output->container_start('attachments');
+            $images = '';
+            $files = '';
+            foreach ($attachments as $attachment) {
+                $icon = $this->output->pix_icon(file_file_icon($attachment), get_mimetype_description($attachment),
+                    'moodle', array('class' => 'icon'));
+                $link = html_writer::link($attachment->fileurl, $icon.' '.substr($attachment->filepath.$attachment->filename, 1));
+                if (file_mimetype_in_typegroup($attachment->mimetype, 'web_image')) {
+                    $preview = html_writer::empty_tag('img', array('src' => $attachment->previewurl, 'alt' => '', 'class' => 'preview'));
+                    $preview = html_writer::tag('a', $preview, array('href' => $attachment->fileurl));
+                    $images .= $this->output->container($preview);
+                } else {
+                    $files .= html_writer::tag('li', $link, array('class' => $attachment->mimetype));
+                }
+            }
+            if ($images) {
+                $images = $this->output->container($images, 'images');
+            }
+
+            if ($files) {
+                $files = html_writer::tag('ul', $files, array('class' => 'files'));
+            }
+
+            $o .= $images.$files;
+            $o .= $this->output->container_end();
+        }
+
+        if ($o === '') {
+            return '';
+        }
+
+        $o = $this->output->box($o, 'overallfeedback');
+        $o = print_collapsible_region($o, 'overall-feedback-wrapper', uniqid('workshop-overall-feedback'),
+            get_string('overallfeedback', 'workshop'), '', false, true);
+
+        return $o;
+    }
+
     /**
      * Renders a perpage selector for workshop listings
      *
index d69dc11..3cbd1c4 100644 (file)
     display: inline;
 }
 
-.path-mod-workshop .assessment-full .assessment-form-wrapper {
+.path-mod-workshop .assessment-full .assessment-form-wrapper,
+.path-mod-workshop .assessment-full .overall-feedback-wrapper {
     margin-top: 0.5em;
     padding: 0px 1em;
 }
     background-color: #ffd3d9;
 }
 
+/**
+ * Overall feedback
+ */
+.path-mod-workshop .assessment-full .overallfeedback .content,
+.path-mod-workshop .assessment-full .overallfeedback .attachments {
+    padding: 5px 10px;
+}
+
+.path-mod-workshop .assessment-full .overallfeedback .attachments .files img.icon {
+    margin-right: 5px;
+}
+
+.path-mod-workshop .assessment-full .overallfeedback .attachments .images div {
+    display: inline-block;
+    margin: 5px;
+    padding: 5px;
+    border: 1px solid #ddd;
+}
+
 /**
  * Assessment form
  */
index c3983fe..2412b7d 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$module->version   = 2013013100;        // the current module version (YYYYMMDDXX)
+$module->version   = 2013032501;        // the current module version (YYYYMMDDXX)
 $module->requires  = 2013012500;        // requires this Moodle version
 $module->component = 'mod_workshop';    // full name of the plugin (used for diagnostics)
 $module->cron      = 60;                // give as a chance every minute
index 3a902b7..570bf57 100644 (file)
@@ -729,6 +729,10 @@ class qformat_xml extends qformat_default {
                 array('#', 'attachments', 0, '#'), 0);
         $qo->graderinfo = $this->import_text_with_files($question,
                 array('#', 'graderinfo', 0), '', $this->get_format($qo->questiontextformat));
+        $qo->responsetemplate['text'] = $this->getpath($question,
+                array('#', 'responsetemplate', 0, '#', 'text', 0, '#'), '', true);
+        $qo->responsetemplate['format'] = $this->trans_format($this->getpath($question,
+                array('#', 'responsetemplate', 0, '@', 'format'), $this->get_format($qo->questiontextformat)));
 
         return $qo;
     }
@@ -1261,6 +1265,10 @@ class qformat_xml extends qformat_default {
                 $expout .= $this->write_files($fs->get_area_files($contextid, 'qtype_essay',
                         'graderinfo', $question->id));
                 $expout .= "    </graderinfo>\n";
+                $expout .= "    <responsetemplate " .
+                        $this->format($question->options->responsetemplateformat) . ">\n";
+                $expout .= $this->writetext($question->options->responsetemplate, 3);
+                $expout .= "    </responsetemplate>\n";
                 break;
 
             case 'calculated':
index fd409c2..78bfcf3 100644 (file)
@@ -360,6 +360,8 @@ END;
         $expectedq->attachments = 0;
         $expectedq->graderinfo['text'] = '';
         $expectedq->graderinfo['format'] = FORMAT_MOODLE;
+        $expectedq->responsetemplate['text'] = '';
+        $expectedq->responsetemplate['format'] = FORMAT_MOODLE;
 
         $this->assert(new question_check_specified_fields_expectation($expectedq), $q);
     }
@@ -384,6 +386,9 @@ END;
     <graderinfo format="html">
         <text><![CDATA[<p>Grade <b>generously</b>!</p>]]></text>
     </graderinfo>
+    <responsetemplate format="html">
+        <text><![CDATA[<p>Here is something <b>really</b> interesting.</p>]]></text>
+    </responsetemplate>
   </question>';
         $xmldata = xmlize($xml);
 
@@ -404,6 +409,8 @@ END;
         $expectedq->attachments = -1;
         $expectedq->graderinfo['text'] = '<p>Grade <b>generously</b>!</p>';
         $expectedq->graderinfo['format'] = FORMAT_HTML;
+        $expectedq->responsetemplate['text'] = '<p>Here is something <b>really</b> interesting.</p>';
+        $expectedq->responsetemplate['format'] = FORMAT_HTML;
 
         $this->assert(new question_check_specified_fields_expectation($expectedq), $q);
     }
@@ -430,7 +437,8 @@ END;
         $qdata->options->attachments = -1;
         $qdata->options->graderinfo = '<p>Grade <b>generously</b>!</p>';
         $qdata->options->graderinfoformat = FORMAT_HTML;
-
+        $qdata->options->responsetemplate = '<p>Here is something <b>really</b> interesting.</p>';
+        $qdata->options->responsetemplateformat = FORMAT_HTML;
         $exporter = new qformat_xml();
         $xml = $exporter->writequestion($qdata);
 
@@ -454,6 +462,9 @@ END;
     <graderinfo format="html">
       <text><![CDATA[<p>Grade <b>generously</b>!</p>]]></text>
     </graderinfo>
+    <responsetemplate format="html">
+      <text><![CDATA[<p>Here is something <b>really</b> interesting.</p>]]></text>
+    </responsetemplate>
   </question>
 ';
 
index 483ef0a..b2399e7 100644 (file)
@@ -42,12 +42,14 @@ class moodle1_qtype_essay_handler extends moodle1_qtype_handler {
     public function process_question(array $data, array $raw) {
         // data added on the upgrade step 2011031000
         $this->write_xml('essay', array(
-            'id'                 => $this->converter->get_nextid(),
-            'responseformat'     => 'editor',
-            'responsefieldlines' => 15,
-            'attachments'        => 0,
-            'graderinfo'         => '',
-            'graderinfoformat'   => FORMAT_MOODLE
+            'id'                     => $this->converter->get_nextid(),
+            'responseformat'         => 'editor',
+            'responsefieldlines'     => 15,
+            'attachments'            => 0,
+            'graderinfo'             => '',
+            'graderinfoformat'       => FORMAT_MOODLE,
+            'responsetemplate'       => '',
+            'responsetemplateformat' => FORMAT_MOODLE
         ), array('/essay/id'));
     }
 }
index e1a80c0..4da324b 100644 (file)
@@ -50,7 +50,8 @@ class backup_qtype_essay_plugin extends backup_qtype_plugin {
         // Now create the qtype own structures
         $essay = new backup_nested_element('essay', array('id'), array(
                 'responseformat', 'responsefieldlines', 'attachments',
-                'graderinfo', 'graderinfoformat'));
+                'graderinfo', 'graderinfoformat', 'responsetemplate',
+                'responsetemplateformat'));
 
         // Now the own qtype tree
         $pluginwrapper->add_child($essay);
index 06f3a46..2a401b8 100644 (file)
@@ -52,6 +52,13 @@ class restore_qtype_essay_plugin extends restore_qtype_plugin {
         $data = (object)$data;
         $oldid = $data->id;
 
+        if (!isset($data->responsetemplate)) {
+            $data->responsetemplate = '';
+        }
+        if (!isset($data->responsetemplateformat)) {
+            $data->responsetemplateformat = FORMAT_HTML;
+        }
+
         // Detect if the question is created or mapped
         $questioncreated = $this->get_mappingid('question_created',
                 $this->get_old_parentid('question')) ? true : false;
@@ -100,6 +107,8 @@ class restore_qtype_essay_plugin extends restore_qtype_plugin {
             $defaultoptions->attachments = 0;
             $defaultoptions->graderinfo = '';
             $defaultoptions->graderinfoformat = FORMAT_HTML;
+            $defaultoptions->responsetemplate = '';
+            $defaultoptions->responsetemplateformat = FORMAT_HTML;
             $DB->insert_record('qtype_essay_options', $defaultoptions);
         }
     }
index 5465af0..c311a8f 100644 (file)
@@ -13,6 +13,8 @@
         <FIELD NAME="attachments" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Whether, and how many, attachments a student is allowed to include with their response. -1 means unlimited."/>
         <FIELD NAME="graderinfo" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Information shown to people with permission to manually grade the question, when they are grading."/>
         <FIELD NAME="graderinfoformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The text format for graderinfo."/>
+        <FIELD NAME="responsetemplate" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="The template to pre-populate student's response field during attempt."/>
+        <FIELD NAME="responsetemplateformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The text format for responsetemplate."/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index 6f3a66f..f56535b 100644 (file)
@@ -117,6 +117,28 @@ function xmldb_qtype_essay_upgrade($oldversion) {
         upgrade_plugin_savepoint(true, 2013011800, 'qtype', 'essay');
     }
 
+    if ($oldversion < 2013021700) {
+        // Create new fields responsetemplate and responsetemplateformat in qtyep_essay_options table.
+        $table = new xmldb_table('qtype_essay_options');
+        $field = new xmldb_field('responsetemplate', XMLDB_TYPE_TEXT, null, null,
+                    null, null, null, 'graderinfoformat');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        $field = new xmldb_field('responsetemplateformat', XMLDB_TYPE_INTEGER, '4',
+                null, XMLDB_NOTNULL, null, '0', 'responsetemplate');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        $DB->execute("UPDATE {qtype_essay_options} SET responsetemplate = '',
+                responsetemplateformat = " . FORMAT_HTML . " WHERE responsetemplate IS NULL");
+
+        // Essay savepoint reached.
+        upgrade_plugin_savepoint(true, 2013021700, 'qtype', 'essay');
+    }
+
     return true;
 }
 
index 2ac0b70..2fbddad 100644 (file)
@@ -50,6 +50,10 @@ class qtype_essay_edit_form extends question_edit_form {
                 get_string('allowattachments', 'qtype_essay'), $qtype->attachment_options());
         $mform->setDefault('attachments', 0);
 
+        $mform->addElement('editor', 'responsetemplate', get_string('responsetemplate', 'qtype_essay'),
+                array('rows' => 10),  array_merge($this->editoroptions, array('maxfiles' => 0)));
+        $mform->addHelpButton('responsetemplate', 'responsetemplate', 'qtype_essay');
+
         $mform->addElement('editor', 'graderinfo', get_string('graderinfo', 'qtype_essay'),
                 array('rows' => 10), $this->editoroptions);
     }
@@ -79,6 +83,11 @@ class qtype_essay_edit_form extends question_edit_form {
         $question->graderinfo['format'] = $question->options->graderinfoformat;
         $question->graderinfo['itemid'] = $draftid;
 
+        $question->responsetemplate = array(
+            'text' => $question->options->responsetemplate,
+            'format' => $question->options->responsetemplateformat,
+        );
+
         return $question;
     }
 
index 2e8d524..068c4d1 100644 (file)
@@ -38,3 +38,5 @@ $string['pluginnameediting'] = 'Editing an Essay question';
 $string['pluginnamesummary'] = 'Allows a response of a few sentences or paragraphs. This must then be graded manually.';
 $string['responsefieldlines'] = 'Input box size';
 $string['responseformat'] = 'Response format';
+$string['responsetemplate'] = 'Response template';
+$string['responsetemplate_help'] = 'Any text entered here will be displayed in the response input box when a new attempt at the question starts.';
index 7fe4155..2979e01 100644 (file)
@@ -39,6 +39,8 @@ class qtype_essay_question extends question_with_responses {
     public $attachments;
     public $graderinfo;
     public $graderinfoformat;
+    public $responsetemplate;
+    public $responsetemplateformat;
 
     public function make_behaviour(question_attempt $qa, $preferredbehaviour) {
         question_engine::load_behaviour_class('manualgraded');
@@ -86,8 +88,17 @@ class qtype_essay_question extends question_with_responses {
     }
 
     public function is_same_response(array $prevresponse, array $newresponse) {
-        return question_utils::arrays_same_at_key_missing_is_blank(
-                $prevresponse, $newresponse, 'answer') && ($this->attachments == 0 ||
+        if (array_key_exists('answer', $prevresponse) && $prevresponse['answer'] !== $this->responsetemplate) {
+            $value1 = $prevresponse['answer'];
+        } else {
+            $value1 = '';
+        }
+        if (array_key_exists('answer', $newresponse) && $newresponse['answer'] !== $this->responsetemplate) {
+            $value2 = $newresponse['answer'];
+        } else {
+            $value2 = '';
+        }
+        return $value1 === $value2 && ($this->attachments == 0 ||
                 question_utils::arrays_same_at_key_missing_is_blank(
                 $prevresponse, $newresponse, 'attachments'));
     }
index 10a1346..7ab2df6 100644 (file)
@@ -68,6 +68,8 @@ class qtype_essay extends question_type {
         $options->graderinfo = $this->import_or_save_files($formdata->graderinfo,
                 $context, 'qtype_essay', 'graderinfo', $formdata->id);
         $options->graderinfoformat = $formdata->graderinfo['format'];
+        $options->responsetemplate = $formdata->responsetemplate['text'];
+        $options->responsetemplateformat = $formdata->responsetemplate['format'];
         $DB->update_record('qtype_essay_options', $options);
     }
 
@@ -78,6 +80,8 @@ class qtype_essay extends question_type {
         $question->attachments = $questiondata->options->attachments;
         $question->graderinfo = $questiondata->options->graderinfo;
         $question->graderinfoformat = $questiondata->options->graderinfoformat;
+        $question->responsetemplate = $questiondata->options->responsetemplate;
+        $question->responsetemplateformat = $questiondata->options->responsetemplateformat;
     }
 
     public function delete_question($questionid, $contextid) {
index 1905e2e..4ddc8bb 100644 (file)
@@ -42,6 +42,12 @@ class qtype_essay_renderer extends qtype_renderer {
 
         // Answer field.
         $step = $qa->get_last_step_with_qt_var('answer');
+
+        if (!$step->has_qt_var('answer') && empty($options->readonly)) {
+            // Question has never been answered, fill it with response template.
+            $step = new question_attempt_step(array('answer'=>$question->responsetemplate));
+        }
+
         if (empty($options->readonly)) {
             $answer = $responseoutput->response_area_input('answer', $qa,
                     $step, $question->responsefieldlines, $options->context);
index e7484aa..02e7d74 100644 (file)
@@ -34,7 +34,7 @@ defined('MOODLE_INTERNAL') || die();
  */
 class qtype_essay_test_helper extends question_test_helper {
     public function get_test_questions() {
-        return array('editor', 'editorfilepicker', 'plain', 'monospaced');
+        return array('editor', 'editorfilepicker', 'plain', 'monospaced', 'responsetemplate');
     }
 
     /**
@@ -97,4 +97,11 @@ class qtype_essay_test_helper extends question_test_helper {
         $q->responseformat = 'monospaced';
         return $q;
     }
+
+    public function make_essay_question_responsetemplate() {
+        $q = $this->initialise_essay_question();
+        $q->responsetemplate = 'Once upon a time';
+        $q->responsetemplateformat = FORMAT_HTML;
+        return $q;
+    }
 }
index 6381a6d..d137419 100644 (file)
@@ -49,4 +49,92 @@ class qtype_essay_question_test extends advanced_testcase {
         $this->assertEquals($longstring,
                 $essay->summarise_response(array('answer' => $longstring)));
     }
+
+    public function test_is_same_response() {
+        $essay = test_question_maker::make_an_essay_question();
+
+        $essay->responsetemplate = '';
+
+        $essay->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertTrue($essay->is_same_response(
+                array(),
+                array('answer' => '')));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => '')));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => ''),
+                array()));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => 'Hello'),
+                array()));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => 'Hello'),
+                array('answer' => '')));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => 0),
+                array('answer' => '')));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => 0)));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => '0'),
+                array('answer' => '')));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => '0')));
+    }
+
+    public function test_is_same_response_with_template() {
+        $essay = test_question_maker::make_an_essay_question();
+
+        $essay->responsetemplate = 'Once upon a time';
+
+        $essay->start_attempt(new question_attempt_step(), 1);
+
+        $this->assertTrue($essay->is_same_response(
+                array(),
+                array('answer' => 'Once upon a time')));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => 'Once upon a time')));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => 'Once upon a time'),
+                array('answer' => '')));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => ''),
+                array()));
+
+        $this->assertTrue($essay->is_same_response(
+                array('answer' => 'Once upon a time'),
+                array()));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => 0),
+                array('answer' => '')));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => 0)));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => '0'),
+                array('answer' => '')));
+
+        $this->assertFalse($essay->is_same_response(
+                array('answer' => ''),
+                array('answer' => '0')));
+    }
 }
index 301edd3..39ad956 100644 (file)
@@ -52,7 +52,7 @@ class qtype_essay_walkthrough_test extends qbehaviour_walkthrough_test_base {
 
     public function test_deferred_feedback_html_editor() {
 
-        // Create a matching question.
+        // Create an essay question.
         $q = test_question_maker::make_question('essay', 'editor');
         $this->start_attempt_at_question($q, 'deferredfeedback', 1);
 
@@ -104,7 +104,7 @@ class qtype_essay_walkthrough_test extends qbehaviour_walkthrough_test_base {
 
     public function test_deferred_feedback_plain_text() {
 
-        // Create a matching question.
+        // Create an essay question.
         $q = test_question_maker::make_question('essay', 'plain');
         $this->start_attempt_at_question($q, 'deferredfeedback', 1);
 
@@ -153,4 +153,55 @@ class qtype_essay_walkthrough_test extends qbehaviour_walkthrough_test_base {
                 $this->get_contains_question_text_expectation($q),
                 $this->get_contains_general_feedback_expectation($q));
     }
+
+    public function test_responsetemplate() {
+
+        // Create an essay question.
+        $q = test_question_maker::make_question('essay', 'responsetemplate');
+        $this->start_attempt_at_question($q, 'deferredfeedback', 1);
+
+        $prefix = $this->quba->get_field_prefix($this->slot);
+        $fieldname = $prefix . 'answer';
+
+        // Check the initial state.
+        $this->check_current_state(question_state::$todo);
+        $this->check_current_mark(null);
+        $this->render();
+        $this->check_contains_textarea('answer', 'Once upon a time');
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($q),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->check_step_count(1);
+
+        // Save.
+        $this->quba->process_all_actions(null, array(
+            'slots'                    => $this->slot,
+            $fieldname                 => 'Once upon a time there was a little green frog.',
+            $fieldname . 'format'      => FORMAT_HTML,
+            $prefix . ':sequencecheck' => '1',
+        ));
+
+        // Verify.
+        $this->check_current_state(question_state::$complete);
+        $this->check_current_mark(null);
+        $this->check_step_count(2);
+        $this->render();
+        $this->check_contains_textarea('answer', 'Once upon a time there was a little green frog.');
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($q),
+                $this->get_does_not_contain_feedback_expectation());
+        $this->check_step_count(2);
+
+        // Finish the attempt.
+        $this->quba->finish_all_questions();
+
+        // Verify.
+        $this->check_current_state(question_state::$needsgrading);
+        $this->check_current_mark(null);
+        $this->render();
+        $this->assertRegExp('/' . preg_quote(s('Once upon a time there was a little green frog.'), '/') . '/', $this->currentoutput);
+        $this->check_current_output(
+                $this->get_contains_question_text_expectation($q),
+                $this->get_contains_general_feedback_expectation($q));
+    }
 }
index 67a0d8e..4a64475 100644 (file)
@@ -26,7 +26,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'qtype_essay';
-$plugin->version   = 2013011800;
+$plugin->version   = 2013021700;
 
 $plugin->requires  = 2012112900;
 
index 636fa2e..31c7d28 100644 (file)
@@ -159,7 +159,11 @@ $THEME->javascripts = array(
     'headercollapse',
 );
 
-if (strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE 8') || strpos($_SERVER['HTTP_USER_AGENT'], 'MSIE 7')) {
+$useragent = '';
+if (!empty($_SERVER['HTTP_USER_AGENT'])) {
+    $useragent = $_SERVER['HTTP_USER_AGENT'];
+}
+if (strpos($useragent, 'MSIE 8') || strpos($useragent, 'MSIE 7')) {
     $THEME->javascripts[] = 'html5shiv';
 }
 
index cd6ac87..04f8199 100644 (file)
@@ -29,7 +29,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$version  = 2013040201.00;              // YYYYMMDD      = weekly release date of this DEV branch
+$version  = 2013040300.00;              // YYYYMMDD      = weekly release date of this DEV branch
                                         //         RR    = release increments - 00 in DEV branches
                                         //           .XX = incremental changes