Merge branch 'MDL-55191_master' of git://github.com/dmonllao/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Tue, 20 Sep 2016 03:41:03 +0000 (11:41 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Tue, 20 Sep 2016 03:41:03 +0000 (11:41 +0800)
265 files changed:
.eslintignore
.stylelintignore
Gruntfile.js
admin/cli/svgtool.php [moved from theme/base/cli/svgtool.php with 91% similarity]
admin/settings/location.php
admin/tool/behat/tests/manager_util_test.php
admin/tool/monitor/tests/eventobservers_test.php
backup/util/loggers/tests/logger_test.php
badges/criteria/award_criteria_manual.php
badges/index.php
badges/tests/behat/award_badge.feature
badges/view.php
blocks/glossary_random/block_glossary_random.php
blocks/lp/lang/en/block_lp.php
blocks/lp/templates/summary.mustache
blocks/recent_activity/tests/behat/structural_changes.feature
blocks/rss_client/classes/output/item.php
calendar/export.php
calendar/lib.php
calendar/managesubscriptions.php
calendar/preferences.php [deleted file]
calendar/tests/behat/calendar_lookahead.feature
calendar/tests/calendartype_test.php
calendar/upgrade.txt
calendar/view.php
course/lib.php
course/tests/courselib_test.php
course/tests/externallib_test.php
enrol/instances.php
enrol/locallib.php
enrol/tests/course_enrolment_manager_test.php [new file with mode: 0644]
files/tests/externallib_test.php
index.php
install/lang/fa/install.php
iplookup/index.php
iplookup/lib.php
iplookup/tests/geoip_test.php
iplookup/tests/geoplugin_test.php
lang/en/admin.php
lang/en/role.php
lib/amd/build/custom_interaction_events.min.js [new file with mode: 0644]
lib/amd/build/key_codes.min.js [new file with mode: 0644]
lib/amd/build/modal.min.js [new file with mode: 0644]
lib/amd/build/modal_backdrop.min.js [new file with mode: 0644]
lib/amd/build/modal_events.min.js [new file with mode: 0644]
lib/amd/build/modal_factory.min.js [new file with mode: 0644]
lib/amd/build/modal_save_cancel.min.js [new file with mode: 0644]
lib/amd/src/custom_interaction_events.js [new file with mode: 0644]
lib/amd/src/key_codes.js [moved from theme/base/version.php with 56% similarity]
lib/amd/src/modal.js [new file with mode: 0644]
lib/amd/src/modal_backdrop.js [new file with mode: 0644]
lib/amd/src/modal_events.js [moved from theme/canvas/version.php with 62% similarity]
lib/amd/src/modal_factory.js [new file with mode: 0644]
lib/amd/src/modal_save_cancel.js [new file with mode: 0644]
lib/behat/classes/behat_config_util.php
lib/behat/classes/util.php
lib/classes/component.php
lib/classes/event/search_indexed.php
lib/classes/plugin_manager.php
lib/db/access.php
lib/db/install.xml
lib/db/upgrade.php
lib/dml/moodle_database.php
lib/dml/mssql_native_moodle_database.php
lib/dml/mysqli_native_moodle_database.php
lib/dml/sqlsrv_native_moodle_database.php
lib/dml/tests/dml_test.php
lib/form/dateselector.php
lib/form/datetimeselector.php
lib/form/duration.php
lib/form/group.php
lib/form/modgrade.php
lib/form/tests/dateselector_test.php
lib/form/tests/datetimeselector_test.php
lib/form/tests/duration_test.php
lib/maxmind/GeoIp2/Compat/JsonSerializable.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Database/Reader.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/AddressNotFoundException.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/AuthenticationException.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/GeoIp2Exception.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/HttpException.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/InvalidRequestException.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Exception/OutOfQueriesException.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/AbstractModel.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/AnonymousIp.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/City.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/ConnectionType.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/Country.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/Domain.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/Enterprise.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/Insights.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Model/Isp.php [new file with mode: 0644]
lib/maxmind/GeoIp2/ProviderInterface.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/AbstractPlaceRecord.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/AbstractRecord.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/City.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Continent.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Country.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Location.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/MaxMind.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Postal.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/RepresentedCountry.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Subdivision.php [new file with mode: 0644]
lib/maxmind/GeoIp2/Record/Traits.php [new file with mode: 0644]
lib/maxmind/GeoIp2/WebService/Client.php [new file with mode: 0644]
lib/maxmind/MaxMind/Db/Reader.php [new file with mode: 0644]
lib/maxmind/MaxMind/Db/Reader/Decoder.php [new file with mode: 0644]
lib/maxmind/MaxMind/Db/Reader/InvalidDatabaseException.php [new file with mode: 0644]
lib/maxmind/MaxMind/Db/Reader/Metadata.php [new file with mode: 0644]
lib/maxmind/MaxMind/Db/Reader/Util.php [new file with mode: 0644]
lib/maxmind/README_moodle.txt [new file with mode: 0644]
lib/moodlelib.php
lib/navigationlib.php
lib/outputlib.php
lib/pear/HTML/QuickForm.php
lib/pear/Net/GeoIP.php [deleted file]
lib/pear/Net/GeoIP/DMA.php [deleted file]
lib/pear/Net/GeoIP/Location.php [deleted file]
lib/pear/README_MOODLE.txt
lib/phpunit/classes/advanced_testcase.php
lib/phpunit/classes/hint_resultprinter.php
lib/phpunit/tests/advanced_test.php
lib/templates/loading.mustache [moved from theme/base/templates/core/notification_success.mustache with 65% similarity]
lib/templates/modal.mustache [new file with mode: 0644]
lib/templates/modal_backdrop.mustache [moved from theme/base/templates/core/notification_error.mustache with 62% similarity]
lib/templates/modal_save_cancel.mustache [new file with mode: 0644]
lib/testing/lib.php
lib/tests/coursecatlib_test.php
lib/tests/moodlelib_test.php
lib/tests/progress_display_test.php
lib/tests/setuplib_test.php
lib/tests/user_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
login/forgot_password.php
login/index.php
login/signup_form.php
login/token.php
mod/assign/gradingtable.php
mod/assign/locallib.php
mod/assign/tests/base_test.php
mod/assign/tests/behat/edit_previous_feedback.feature
mod/assign/tests/lib_test.php
mod/assign/tests/locallib_participants_test.php [new file with mode: 0644]
mod/assign/tests/locallib_test.php
mod/book/lib.php
mod/forum/lib.php
mod/forum/post.php
mod/lesson/lang/en/lesson.php
mod/lesson/lesson.php
mod/lesson/locallib.php
mod/lesson/pix/e/copy.png [new file with mode: 0644]
mod/lesson/pix/e/copy.svg [moved from theme/base/pix/fp/view_list_active.svg with 50% similarity]
mod/lesson/renderer.php
mod/lesson/tests/behat/duplicate_lesson_page.feature [new file with mode: 0644]
mod/lesson/tests/locallib_test.php [new file with mode: 0644]
mod/scorm/settings.php
npm-shrinkwrap.json
package.json
question/engine/datalib.php
question/type/calculated/questiontype.php
report/competency/lang/en/report_competency.php
report/competency/templates/user_competency_summary.mustache
report/participation/index.php
search/engine/solr/classes/document.php
search/engine/solr/classes/engine.php
search/engine/solr/tests/engine_test.php
search/tests/generator/lib.php
theme/base/config.php [deleted file]
theme/base/lang/en/theme_base.php [deleted file]
theme/base/layout/embedded.php [deleted file]
theme/base/layout/frontpage.php [deleted file]
theme/base/layout/general.php [deleted file]
theme/base/layout/report.php [deleted file]
theme/base/pix/favicon.ico [deleted file]
theme/base/pix/fp/add_file.png [deleted file]
theme/base/pix/fp/add_file.svg [deleted file]
theme/base/pix/fp/alias.png [deleted file]
theme/base/pix/fp/alias_sm.png [deleted file]
theme/base/pix/fp/check.png [deleted file]
theme/base/pix/fp/create_folder.png [deleted file]
theme/base/pix/fp/create_folder.svg [deleted file]
theme/base/pix/fp/cross.png [deleted file]
theme/base/pix/fp/dnd_arrow.gif [deleted file]
theme/base/pix/fp/download_all.png [deleted file]
theme/base/pix/fp/download_all.svg [deleted file]
theme/base/pix/fp/help.png [deleted file]
theme/base/pix/fp/help.svg [deleted file]
theme/base/pix/fp/link.png [deleted file]
theme/base/pix/fp/link_sm.png [deleted file]
theme/base/pix/fp/logout.png [deleted file]
theme/base/pix/fp/logout.svg [deleted file]
theme/base/pix/fp/path_folder.png [deleted file]
theme/base/pix/fp/path_folder_rtl.png [deleted file]
theme/base/pix/fp/refresh.png [deleted file]
theme/base/pix/fp/refresh.svg [deleted file]
theme/base/pix/fp/search.png [deleted file]
theme/base/pix/fp/search.svg [deleted file]
theme/base/pix/fp/setting.png [deleted file]
theme/base/pix/fp/setting.svg [deleted file]
theme/base/pix/fp/view_icon_active.png [deleted file]
theme/base/pix/fp/view_icon_active.svg [deleted file]
theme/base/pix/fp/view_list_active.png [deleted file]
theme/base/pix/fp/view_tree_active.png [deleted file]
theme/base/pix/fp/view_tree_active.svg [deleted file]
theme/base/pix/horizontal-menu-submenu-indicator.png [deleted file]
theme/base/pix/progress.gif [deleted file]
theme/base/pix/screenshot.png [deleted file]
theme/base/pix/sprite.png [deleted file]
theme/base/pix/vertical-menu-submenu-indicator.png [deleted file]
theme/base/pix/yui2-treeview-sprite-rtl.gif [deleted file]
theme/base/style/admin.css [deleted file]
theme/base/style/autocomplete.css [deleted file]
theme/base/style/blocks.css [deleted file]
theme/base/style/calendar.css [deleted file]
theme/base/style/core.css [deleted file]
theme/base/style/course.css [deleted file]
theme/base/style/dock.css [deleted file]
theme/base/style/editor.css [deleted file]
theme/base/style/filemanager.css [deleted file]
theme/base/style/grade.css [deleted file]
theme/base/style/message.css [deleted file]
theme/base/style/pagelayout.css [deleted file]
theme/base/style/question.css [deleted file]
theme/base/style/search.css [deleted file]
theme/base/style/tabs.css [deleted file]
theme/base/style/templates.css [deleted file]
theme/base/style/user.css [deleted file]
theme/base/templates/core/notification_info.mustache [deleted file]
theme/base/templates/core/notification_warning.mustache [deleted file]
theme/bootstrapbase/layout/columns2.php
theme/bootstrapbase/layout/columns3.php
theme/bootstrapbase/layout/secure.php
theme/bootstrapbase/less/moodle.less
theme/bootstrapbase/less/moodle/modal.less [new file with mode: 0644]
theme/bootstrapbase/style/moodle.css
theme/canvas/config.php [deleted file]
theme/canvas/lang/en/theme_canvas.php [deleted file]
theme/canvas/layout/embedded.php [deleted file]
theme/canvas/layout/frontpage.php [deleted file]
theme/canvas/layout/general.php [deleted file]
theme/canvas/layout/report.php [deleted file]
theme/canvas/style/admin.css [deleted file]
theme/canvas/style/blocks.css [deleted file]
theme/canvas/style/core.css [deleted file]
theme/canvas/style/course.css [deleted file]
theme/canvas/style/editor.css [deleted file]
theme/canvas/style/mods.css [deleted file]
theme/canvas/style/pagelayout.css [deleted file]
theme/canvas/style/popups.css [deleted file]
theme/canvas/style/tables.css [deleted file]
theme/canvas/style/tabs.css [deleted file]
theme/canvas/style/text.css [deleted file]
theme/clean/layout/columns2.php
theme/clean/layout/columns3.php
theme/clean/layout/secure.php
theme/upgrade.txt
user/calendar.php [new file with mode: 0644]
user/classes/form/calendar_form.php [moved from calendar/preferences_form.php with 52% similarity]
user/editadvanced_form.php
user/editlib.php
user/profile/definelib.php
user/profile/index.php
version.php
webservice/lib.php

index 576c8c4..e11f4c5 100644 (file)
@@ -17,7 +17,6 @@ lib/bennu/
 lib/evalmath/
 lib/lessphp/
 lib/phpexcel/
-lib/pear/Net/
 lib/google/
 lib/htmlpurifier/
 lib/jabber/
@@ -49,6 +48,8 @@ lib/amd/src/mustache.js
 lib/graphlib.php
 lib/spout/
 lib/amd/src/chartjs-lazy.js
+lib/maxmind/GeoIp2/
+lib/maxmind/MaxMind/
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/bootstrapbase/less/bootstrap/
index c73d41a..ceeb1a0 100644 (file)
@@ -16,7 +16,6 @@ lib/bennu/
 lib/evalmath/
 lib/lessphp/
 lib/phpexcel/
-lib/pear/Net/
 lib/google/
 lib/htmlpurifier/
 lib/jabber/
@@ -48,6 +47,8 @@ lib/amd/src/mustache.js
 lib/graphlib.php
 lib/spout/
 lib/amd/src/chartjs-lazy.js
+lib/maxmind/GeoIp2/
+lib/maxmind/MaxMind/
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/bootstrapbase/less/bootstrap/
index 5e547eb..1be39db 100644 (file)
@@ -30,7 +30,15 @@ module.exports = function(grunt) {
         cwd = process.env.PWD || process.cwd(),
         async = require('async'),
         DOMParser = require('xmldom').DOMParser,
-        xpath = require('xpath');
+        xpath = require('xpath'),
+        semver = require('semver');
+
+    // Verify the node version is new enough.
+    var expected = semver.validRange(grunt.file.readJSON('package.json').engines.node);
+    var actual = semver.valid(process.version);
+    if (!semver.satisfies(actual, expected)) {
+        grunt.fail.fatal('Node version too old. Require ' + expected + ', version installed: ' + actual);
+    }
 
     // Windows users can't run grunt in a subdirectory, so allow them to set
     // the root by passing --root=path/to/dir.
similarity index 91%
rename from theme/base/cli/svgtool.php
rename to admin/cli/svgtool.php
index 47d294e..929a7c9 100644 (file)
@@ -17,7 +17,7 @@
 /**
  * This script implements some useful svg manipulation tricks.
  *
- * @package    theme_base
+ * @package    core_admin
  * @subpackage cli
  * @copyright  2012 Petr Skoda {@link http://skodak.org}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -25,7 +25,7 @@
 
 define('CLI_SCRIPT', true);
 
-require(__DIR__.'/../../../config.php');
+require(__DIR__.'/../../config.php');
 require_once($CFG->libdir.'/clilib.php');
 
 // Now get cli options.
@@ -45,10 +45,10 @@ if (!file_exists($path)) {
 }
 
 if ($options['ie9fix']) {
-    theme_base_recurse_svgs($path, '', 'theme_base_svgtool_ie9fix', $blacklist);
+    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_ie9fix', $blacklist);
 
 } else if ($options['noaspectratio']) {
-    theme_base_recurse_svgs($path, '', 'theme_base_svgtool_noaspectratio', $blacklist);
+    core_admin_recurse_svgs($path, '', 'core_admin_svgtool_noaspectratio', $blacklist);
 
 } else {
     $help =
@@ -78,7 +78,7 @@ exit(0);
  *
  * @param string $file
  */
-function theme_base_svgtool_ie9fix($file) {
+function core_admin_svgtool_ie9fix($file) {
     global $CFG;
 
     if (strpos($file, $CFG->dirroot.DIRECTORY_SEPARATOR) === 0) {
@@ -115,7 +115,7 @@ function theme_base_svgtool_ie9fix($file) {
  *
  * @param string $file
  */
-function theme_base_svgtool_noaspectratio($file) {
+function core_admin_svgtool_noaspectratio($file) {
     global $CFG;
 
     if (strpos($file, $CFG->dirroot.DIRECTORY_SEPARATOR) === 0) {
@@ -155,7 +155,7 @@ function theme_base_svgtool_noaspectratio($file) {
  * @param string $filecallback
  * @param array $blacklist
  */
-function theme_base_recurse_svgs($base, $sub, $filecallback, $blacklist) {
+function core_admin_recurse_svgs($base, $sub, $filecallback, $blacklist) {
     if (is_dir("$base/$sub")) {
         $items = new DirectoryIterator("$base/$sub");
         foreach ($items as $item) {
@@ -163,7 +163,7 @@ function theme_base_recurse_svgs($base, $sub, $filecallback, $blacklist) {
                 continue;
             }
             $file = $item->getFilename();
-            theme_base_recurse_svgs("$base/$sub", $file, $filecallback, $blacklist);
+            core_admin_recurse_svgs("$base/$sub", $file, $filecallback, $blacklist);
         }
         unset($item);
         unset($items);
index 1868f33..504cbb9 100644 (file)
@@ -10,7 +10,8 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page
     $temp->add(new admin_setting_configtext('defaultcity', new lang_string('defaultcity', 'admin'), new lang_string('defaultcity_help', 'admin'), ''));
 
     $temp->add(new admin_setting_heading('iplookup', new lang_string('iplookup', 'admin'), new lang_string('iplookupinfo', 'admin')));
-    $temp->add(new admin_setting_configfile('geoipfile', new lang_string('geoipfile', 'admin'), new lang_string('configgeoipfile', 'admin', $CFG->dataroot.'/geoip/'), $CFG->dataroot.'/geoip/GeoLiteCity.dat'));
+    $temp->add(new admin_setting_configfile('geoip2file', new lang_string('geoipfile', 'admin'),
+        new lang_string('configgeoipfile', 'admin', $CFG->dataroot.'/geoip/'), $CFG->dataroot.'/geoip/GeoLite2-City.mmdb'));
     $temp->add(new admin_setting_configtext('googlemapkey3', new lang_string('googlemapkey3', 'admin'), new lang_string('googlemapkey3_help', 'admin'), '', PARAM_RAW, 60));
 
     $temp->add(new admin_setting_configtext('allcountrycodes', new lang_string('allcountrycodes', 'admin'), new lang_string('configallcountrycodes', 'admin'), '', '/^(?:\w+(?:,\w+)*)?$/'));
index 2f59806..9556cee 100644 (file)
@@ -365,12 +365,7 @@ class tool_behat_manager_util_testcase extends advanced_testcase {
 
         $behatconfigutil = $this->behatconfigutil;
         // Fix expected directory path for OS.
-        $cleanfeaturepath = str_replace('\\', DIRECTORY_SEPARATOR, $cleanfeaturepath);
-        $cleanfeaturepath = str_replace('/', DIRECTORY_SEPARATOR, $cleanfeaturepath);
-
-        if (testing_is_cygwin()) {
-            $featurepath = str_replace('\\', '/', $cleanfeaturepath);
-        }
+        $cleanfeaturepath = testing_cli_fix_directory_separator($cleanfeaturepath);
 
         list($retkey, $retcleanfeaturepath) = $behatconfigutil->get_clean_feature_key_and_path($featurepath);
 
index 9239e16..a1cb384 100644 (file)
@@ -384,7 +384,7 @@ class tool_monitor_eventobservers_testcase extends advanced_testcase {
             // Now let us trigger 7 instances of the event.
             $event = \mod_book\event\course_module_instance_list_viewed::create_from_course($course);
             $event->trigger();
-            sleep(1); // Add a second delay, to prevent time collisions.
+            $this->waitForSecond(); // Add a second delay, to prevent time collisions.
         }
         $this->run_adhock_tasks();
         $messages = $messagesink->get_messages();
index 70a65f9..f81d7a4 100644 (file)
@@ -95,7 +95,8 @@ class backup_logger_testcase extends basic_testcase {
         $lo3 = new mock_base_logger3(backup::LOG_ERROR);
         $lo1->set_next($lo2);
         $lo2->set_next($lo3);
-        $this->assertFalse($lo1->process('test', backup::LOG_ERROR));
+        $msg = 13;
+        $this->assertFalse($lo1->process($msg, backup::LOG_ERROR));
 
         // Test checksum correct
         $lo1 = new mock_base_logger1(backup::LOG_ERROR);
index fd0ad2e..51a7959 100644 (file)
@@ -207,12 +207,15 @@ class award_criteria_manual extends award_criteria {
             return array($join, $where, $params);
         } else {
             foreach ($this->params as $param) {
-                $join .= " LEFT JOIN {badge_manual_award} bma{$param['role']} ON
-                          bma{$param['role']}.recipientid = u.id AND
-                          bma{$param['role']}.issuerrole = :issuerrole{$param['role']} ";
-                $where .= " AND bma{$param['role']}.issuerrole IS NOT NULL ";
+                $roledata[] = " bma.issuerrole = :issuerrole{$param['role']} ";
                 $params["issuerrole{$param['role']}"] = $param['role'];
             }
+            if (!empty($roledata)) {
+                $extraon = implode(' AND ', $roledata);
+                $join = " JOIN {badge_manual_award} bma ON bma.recipientid = u.id
+                          AND bma.badgeid = :badgeid{$this->badgeid} AND ({$extraon})";
+                $params["badgeid{$this->badgeid}"] = $this->badgeid;
+            }
             return array($join, $where, $params);
         }
     }
index 732d9d5..77260f0 100644 (file)
@@ -160,7 +160,7 @@ if ($type == BADGE_TYPE_SITE) {
 }
 echo $OUTPUT->box('', 'notifyproblem hide', 'check_connection');
 
-$totalcount = count(badges_get_badges($type, $courseid, '', '' , '', ''));
+$totalcount = count(badges_get_badges($type, $courseid, '', '' , 0, 0));
 $records = badges_get_badges($type, $courseid, $sortby, $sorthow, $page, BADGE_PERPAGE);
 
 if ($totalcount) {
index bbc28ae..508fd42 100644 (file)
@@ -227,3 +227,86 @@ Feature: Award badges
     And I log in as "student1"
     And I follow "Profile" in the user menu
     Then I should see "Course Badge"
+
+  @javascript
+  Scenario: All of the selected roles can award badges
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+      | student1 | Student | 1 | student1@example.com |
+      | student2 | Student | 2 | student2@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+      | student2 | C1 | student |
+    And I log in as "teacher1"
+    And I follow "Course 1"
+    # Create course badge 1.
+    And I navigate to "Add a new badge" node in "Course administration > Badges"
+    And I follow "Add a new badge"
+    And I set the following fields to these values:
+      | Name | Course Badge 1 |
+      | Description | Course badge description |
+      | issuername | Tester of course badge |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    And I set the field "type" to "Manual issue by role"
+    And I expand all fieldsets
+    # Set to ANY of the roles awards badge.
+    And I set the field "Teacher" to "1"
+    And I set the field "Any of the selected roles awards the badge" to "1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    And I follow "Recipients (0)"
+    And I press "Award badge"
+    # Award course badge 1 to student 1.
+    And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)"
+    When I press "Award badge"
+    And I follow "Course Badge 1"
+    And I follow "Recipients (1)"
+    Then I should see "Recipients (1)"
+    # Add course badge 2.
+    And I navigate to "Add a new badge" node in "Course administration > Badges"
+    And I follow "Add a new badge"
+    And I set the following fields to these values:
+      | Name | Course Badge 2 |
+      | Description | Course badge description |
+      | issuername | Tester of course badge |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    And I set the field "type" to "Manual issue by role"
+    And I expand all fieldsets
+    # Set to ALL of the selected roles award badge.
+    And I set the field "Teacher" to "1"
+    And I set the field "All of the selected roles award the badge" to "1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    And I follow "Recipients (0)"
+    And I press "Award badge"
+    # Award course badge 2 to student 2.
+    And I set the field "potentialrecipients[]" to "Student 2 (student2@example.com)"
+    When I press "Award badge"
+    And I follow "Course Badge 2"
+    And I follow "Recipients (1)"
+    Then I should see "Recipients (1)"
+    And I log out
+    And I trigger cron
+    # Student 1 should have just course badge 1.
+    And I log in as "student1"
+    And I follow "Profile" in the user menu
+    When I follow "Course 1"
+    Then I should see "Course Badge 1"
+    And I should not see "Course Badge 2"
+    And I log out
+    # Student 2 should have just course badge 2.
+    And I log in as "student2"
+    And I follow "Profile" in the user menu
+    When I follow "Course 1"
+    Then I should see "Course Badge 2"
+    Then I should not see "Course Badge 1"
index 5516bfd..704af1f 100644 (file)
@@ -85,7 +85,7 @@ $output = $PAGE->get_renderer('core', 'badges');
 echo $output->header();
 echo $OUTPUT->heading($title);
 
-$totalcount = count(badges_get_badges($type, $courseid, '', '', '', '', $USER->id));
+$totalcount = count(badges_get_badges($type, $courseid, '', '', 0, 0, $USER->id));
 $records = badges_get_badges($type, $courseid, $sortby, $sorthow, $page, BADGE_PERPAGE, $USER->id);
 
 if ($totalcount) {
index bf9f6a7..4ef7515 100644 (file)
@@ -86,7 +86,7 @@ class block_glossary_random extends block_base {
             switch ($this->config->type) {
 
                 case BGR_RANDOMLY:
-                    $i = rand(1,$numberofentries);
+                    $i = ($numberofentries > 1) ? rand(1, $numberofentries) : 1;
                     $limitfrom = $i-1;
                     break;
 
index 8470df8..0c0ba9a 100644 (file)
@@ -28,6 +28,8 @@ $string['competenciestoreview'] = 'Competencies to review';
 $string['lp:addinstance'] = 'Add a new learning plans block';
 $string['lp:myaddinstance'] = 'Add a new learning plans block to Dashboard';
 $string['lp:view'] = 'View learning plans block';
+$string['myplans'] = 'My plans';
+$string['noactiveplans'] = 'No active plans at the moment.';
 $string['planstoreview'] = 'Plans to review';
 $string['pluginname'] = 'Learning plans';
 $string['viewmore'] = 'View more...';
index 8ad8aa4..dc0943f 100644 (file)
@@ -37,7 +37,7 @@
 }}
 <div>
     {{#hasplans}}
-        <h3>My plans</h3>
+        <h3>{{#str}}myplans, block_lp{{/str}}</h3>
         <div class="sub-content">
             {{#hasactiveplans}}
                 <ul>
@@ -50,7 +50,7 @@
                 </ul>
             {{/hasactiveplans}}
             {{^hasactiveplans}}
-                <p>No active plans at the moment. <a href="{{plansurl}}">{{#str}}viewotherplans, block_lp{{/str}}</a></p>
+                <p>{{#str}}noactiveplans, block_lp{{/str}} <a href="{{plansurl}}">{{#str}}viewotherplans, block_lp{{/str}}</a></p>
             {{/hasactiveplans}}
         </div>
     {{/hasplans}}
@@ -70,7 +70,7 @@
         </div>
     {{/hascompstoreview}}
     {{#hasplanstoreview}}
-        <h3>Plans to review</h3>
+        <h3>{{#str}}planstoreview, block_lp{{/str}}</h3>
         <div class="sub-content">
             <ul>
                 {{#planstoreview}}
index a43dbb7..9f6d8da 100644 (file)
@@ -156,7 +156,8 @@ Feature: View structural changes in recent activity block
     And I should see "Added Forum" in the "Recent activity" "block"
     And I should see "ForumNew" in the "Recent activity" "block"
     And I log out
-    # Update forum as a teacher
+    # Update forum as a teacher after a second to ensure we have a new timestamp for recent activity.
+    And I wait "1" seconds
     And I log in as "teacher1"
     And I follow "Course 1"
     And I follow "ForumNew"
index 71a71dc..e988304 100644 (file)
@@ -127,7 +127,7 @@ class item implements \renderable, \templatable {
         $title = $this->title;
         if (!$title) {
             $title = strip_tags($this->description);
-            $title = core_text::substr($title, 0, 20) . '...';
+            $title = \core_text::substr($title, 0, 20) . '...';
         }
 
         // Allow the renderer to format the title and description.
index 7de7d5c..979c892 100644 (file)
@@ -114,7 +114,6 @@ $PAGE->navbar->add($pagetitle);
 $PAGE->set_title($course->shortname.': '.get_string('calendar', 'calendar').': '.$pagetitle);
 $PAGE->set_heading($course->fullname);
 $PAGE->set_pagelayout('standard');
-$PAGE->set_button(calendar_preferences_button($course));
 
 $renderer = $PAGE->get_renderer('core_calendar');
 $calendar->add_sidecalendar_blocks($renderer);
index c675c65..3c2aa92 100644 (file)
@@ -1658,6 +1658,8 @@ function calendar_get_default_courses() {
  * Display calendar preference button
  *
  * @param stdClass $course course object
+ * @deprecated since Moodle 3.2
+ * @todo MDL-55875 This will be deleted in Moodle 3.6.
  * @return string return preference button in html
  */
 function calendar_preferences_button(stdClass $course) {
@@ -1667,8 +1669,9 @@ function calendar_preferences_button(stdClass $course) {
     if (!isloggedin() || isguestuser()) {
         return '';
     }
+    debugging('This should no longer be used, the calendar preferences are now linked to the user preferences page');
 
-    return $OUTPUT->single_button(new moodle_url('/calendar/preferences.php', array('course' => $course->id)), get_string("preferences", "calendar"));
+    return $OUTPUT->single_button(new moodle_url('/user/calendar.php'), get_string("preferences", "calendar"));
 }
 
 /**
index a83b90f..3e4e0e9 100644 (file)
@@ -110,7 +110,6 @@ $subscriptions = $DB->get_records_sql($sql, $params);
 // Print title and header.
 $PAGE->set_title("$course->shortname: ".get_string('calendar', 'calendar').": ".get_string('subscriptions', 'calendar'));
 $PAGE->set_heading($course->fullname);
-$PAGE->set_button(calendar_preferences_button($course));
 
 $renderer = $PAGE->get_renderer('core_calendar');
 
diff --git a/calendar/preferences.php b/calendar/preferences.php
deleted file mode 100644 (file)
index aeb502d..0000000
+++ /dev/null
@@ -1,84 +0,0 @@
-<?php
-
-// preferences.php - user prefs for calendar
-
-require_once('../config.php');
-require_once($CFG->dirroot.'/calendar/lib.php');
-require_once($CFG->dirroot.'/calendar/preferences_form.php');
-
-$courseid = required_param('course', PARAM_INT);
-$course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
-
-$PAGE->set_url(new moodle_url('/calendar/preferences.php', array('course' => $courseid)));
-$PAGE->set_pagelayout('standard');
-
-require_login($course);
-
-if ($courseid == SITEID) {
-    $viewurl = new moodle_url('/calendar/view.php', array('view' => 'month'));
-} else {
-    $viewurl = new moodle_url('/calendar/view.php', array('view' => 'month', 'course' => $courseid));
-}
-navigation_node::override_active_url($viewurl);
-
-$defaultlookahead = CALENDAR_DEFAULT_UPCOMING_LOOKAHEAD;
-if (isset($CFG->calendar_lookahead)) {
-    $defaultlookahead = intval($CFG->calendar_lookahead);
-}
-$defaultmaxevents = CALENDAR_DEFAULT_UPCOMING_MAXEVENTS;
-if (isset($CFG->calendar_maxevents)) {
-    $defaultmaxevents = intval($CFG->calendar_maxevents);
-}
-
-$prefs = new stdClass;
-$prefs->timeformat = get_user_preferences('calendar_timeformat', '');
-$prefs->startwday  = calendar_get_starting_weekday();
-$prefs->maxevents  = get_user_preferences('calendar_maxevents', $defaultmaxevents);
-$prefs->lookahead  = get_user_preferences('calendar_lookahead', $defaultlookahead);
-$prefs->persistflt = get_user_preferences('calendar_persistflt', 0);
-
-$form = new calendar_preferences_form($PAGE->url);
-$form->set_data($prefs);
-
-if ($form->is_cancelled()) {
-    redirect($viewurl);
-} else if ($form->is_submitted() && $form->is_validated() && confirm_sesskey()) {
-    $data = $form->get_data();
-    if ($data->timeformat != CALENDAR_TF_12 && $data->timeformat != CALENDAR_TF_24) {
-        $data->timeformat = '';
-    }
-    set_user_preference('calendar_timeformat', $data->timeformat);
-
-    $data->startwday = intval($data->startwday);
-    if ($data->startwday < 0 || $data->startwday > 6) {
-        $data->startwday = abs($data->startwday % 7);
-    }
-    set_user_preference('calendar_startwday', $data->startwday);
-
-    if (intval($data->maxevents) >= 1) {
-        set_user_preference('calendar_maxevents', $data->maxevents);
-    }
-
-    if (intval($data->lookahead) >= 1) {
-        set_user_preference('calendar_lookahead', $data->lookahead);
-    }
-
-    set_user_preference('calendar_persistflt', intval($data->persistflt));
-    redirect($viewurl, get_string('changessaved'), 1);
-    exit;
-}
-
-$strcalendar = get_string('calendar', 'calendar');
-$strpreferences = get_string('calendarpreferences', 'calendar');
-
-$PAGE->navbar->add($strpreferences);
-$PAGE->set_pagelayout('admin');
-$PAGE->set_title("$course->shortname: $strcalendar: $strpreferences");
-$PAGE->set_heading($course->fullname);
-
-echo $OUTPUT->header();
-echo $OUTPUT->heading($strpreferences);
-echo $OUTPUT->box_start('generalbox boxaligncenter');
-$form->display();
-echo $OUTPUT->box_end();
-echo $OUTPUT->footer();
\ No newline at end of file
index e95c018..449574f 100644 (file)
@@ -28,11 +28,13 @@ Feature: Limit displayed upcoming events
       | Event title       | Two months away event |
     When I follow "C1"
     Then I should not see "Two months away event"
-    And I follow "Go to calendar"
-    And I click on "Preferences" "button"
+    And I am on site homepage
+    And I follow "Preferences" in the user menu
+    And I follow "Calendar preferences"
     And I set the following fields to these values:
       | Upcoming events look-ahead | 3 months |
     And I press "Save changes"
     And I wait to be redirected
-    And I follow "C1"
+    And I am on site homepage
+    And I follow "Course 1"
     And I should see "Two months away event"
index c124477..a8ccc85 100644 (file)
@@ -50,6 +50,8 @@ require_once($CFG->dirroot . '/user/profile/index_field_form.php');
  * @since Moodle 2.6
  */
 class core_calendar_type_testcase extends advanced_testcase {
+    /** @var MoodleQuickForm Keeps reference of dummy form object */
+    private $mform;
 
     /**
      * The test user.
@@ -63,6 +65,10 @@ class core_calendar_type_testcase extends advanced_testcase {
         // The user we are going to test this on.
         $this->user = self::getDataGenerator()->create_user();
         self::setUser($this->user);
+
+        // Get form data.
+        $form = new temp_form_calendartype();
+        $this->mform = $form->getform();
     }
 
     /**
@@ -216,15 +222,17 @@ class core_calendar_type_testcase extends advanced_testcase {
     private function convert_dateselector_to_unixtime_test($element, $type, $date) {
         $this->set_calendar_type($type);
 
+        static $counter = 0;
+        $counter++;
+
         if ($element == 'dateselector') {
-            $el = new MoodleQuickForm_date_selector('dateselector', null, array('timezone' => 0.0, 'step' => 1));
+            $el = $this->mform->addElement('date_selector', 'dateselector' . $counter, null, array('timezone' => 0.0, 'step' => 1));
         } else {
-            $el = new MoodleQuickForm_date_time_selector('dateselector', null, array('timezone' => 0.0, 'step' => 1));
+            $el = $this->mform->addElement('date_time_selector', 'dateselector' . $counter, null, array('timezone' => 0.0, 'step' => 1, 'optional' => false));
         }
-        $el->_createElements();
-        $submitvalues = array('dateselector' => $date);
+        $submitvalues = array('dateselector' . $counter => $date);
 
-        $this->assertSame($el->exportValue($submitvalues), array('dateselector' => $date['timestamp']));
+        $this->assertSame($el->exportValue($submitvalues), array('dateselector' . $counter => $date['timestamp']));
     }
 
     /**
@@ -300,3 +308,25 @@ class core_calendar_type_testcase extends advanced_testcase {
         \core\session\manager::set_user($this->user);
     }
 }
+
+/**
+ * Form object to be used in test case.
+ */
+class temp_form_calendartype extends moodleform {
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        // No definition required.
+    }
+    /**
+     * Returns form reference
+     * @return MoodleQuickForm
+     */
+    public function getform() {
+        $mform = $this->_form;
+        // Set submitted flag, to simulate submission.
+        $mform->_flagSubmitted = true;
+        return $mform;
+    }
+}
index 55d272a..4a37e2c 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /calendar/* ,
 information provided here is intended especially for developers.
 
+=== 3.2 ===
+* calendar_preferences_button() is now depreciated.  Calendar preferences have been moved to the user preferences page.
+
 === 2.9 ===
 default values changes in code:
 * core_calendar_external::get_calendar_events_parameters() 'timeend' default option changed; now, by default,
index e37e3ac..fe8c3e9 100644 (file)
 //                                                                         //
 /////////////////////////////////////////////////////////////////////////////
 
-//  Display the calendar page.
+/**
+ * Display the calendar page.
+ * @copyright 2003 Jon Papaioannou
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @package core_calendar
+ */
 
 require_once('../config.php');
 require_once($CFG->dirroot.'/course/lib.php');
@@ -117,7 +122,6 @@ switch($view) {
 $PAGE->set_pagelayout('standard');
 $PAGE->set_title("$course->shortname: $strcalendar: $pagetitle");
 $PAGE->set_heading($COURSE->fullname);
-$PAGE->set_button(calendar_preferences_button($course));
 
 $renderer = $PAGE->get_renderer('core_calendar');
 $calendar->add_sidecalendar_blocks($renderer, true, $view);
index 0ed11fc..631500d 100644 (file)
@@ -2776,6 +2776,11 @@ function update_course($data, $editoroptions = NULL) {
 
     $data->timemodified = time();
 
+    // Prevent changes on front page course.
+    if ($data->id == SITEID) {
+        throw new moodle_exception('invalidcourse', 'error');
+    }
+
     $oldcourse = course_get_format($data->id)->get_course();
     $context   = context_course::instance($oldcourse->id);
 
index f1a1b8a..ce3105f 100644 (file)
@@ -3124,4 +3124,17 @@ class core_course_courselib_testcase extends advanced_testcase {
         $adminoptions = course_get_user_administration_options($course, $context);
         $this->assertFalse($adminoptions->badges);
     }
+
+    /**
+     * Test test_update_course_frontpage_category.
+     */
+    public function test_update_course_frontpage_category() {
+        // Fetch front page course.
+        $course = get_course(SITEID);
+        // Test update information on front page course.
+        $course->category = 99;
+        $this->expectException('moodle_exception');
+        $this->expectExceptionMessage(get_string('invalidcourse', 'error'));
+        update_course($course);
+    }
 }
index b7fbe2f..2a49a47 100644 (file)
@@ -67,7 +67,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $category2->name = 'Root Test Category 2';
         $category2->idnumber = 'rootcattest2';
         $category2->desc = 'Description for root test category 1';
-        $category2->theme = 'base';
+        $category2->theme = 'bootstrapbase';
         $categories = array(
             array('name' => $category1->name, 'parent' => 0),
             array('name' => $category2->name, 'parent' => 0, 'idnumber' => $category2->idnumber,
@@ -395,7 +395,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $course2['enablecompletion'] = 1;
         $course2['completionnotify'] = 1;
         $course2['lang'] = 'en';
-        $course2['forcetheme'] = 'base';
+        $course2['forcetheme'] = 'bootstrapbase';
         $course3['fullname'] = 'Test course 3';
         $course3['shortname'] = 'Testcourse3';
         $course3['categoryid'] = $category->id;
@@ -1061,7 +1061,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $course2['defaultgroupingid'] = 0;
         $course2['enablecompletion'] = 1;
         $course2['lang'] = 'en';
-        $course2['forcetheme'] = 'base';
+        $course2['forcetheme'] = 'bootstrapbase';
         $courses = array($course1, $course2);
 
         $updatedcoursewarnings = core_course_external::update_courses($courses);
index 52b5142..546da9d 100644 (file)
@@ -225,8 +225,6 @@ foreach ($instances as $instance) {
     $edit = array();
 
     if ($canconfig) {
-        // up/down link
-        $updown = '';
         if ($updowncount > 1) {
             $aurl = new moodle_url($url, array('action'=>'up', 'instance'=>$instance->id));
             $updown[] = $OUTPUT->action_icon($aurl, new pix_icon('t/up', $strup, 'core', array('class' => 'iconsmall')));
index ea3d8e8..2d918da 100644 (file)
@@ -171,13 +171,12 @@ class course_enrolment_manager {
             $sqltotal = "SELECT COUNT(DISTINCT u.id)
                            FROM {user} u
                            JOIN {user_enrolments} ue ON (ue.userid = u.id  AND ue.enrolid $instancessql)
-                           JOIN {enrol} e ON (e.id = ue.enrolid)
-                      LEFT JOIN {groups_members} gm ON u.id = gm.userid AND gm.groupid IN (
-                               SELECT g.id
-                                 FROM {groups} g
-                                WHERE g.courseid = e.courseid
-                              )
-                          WHERE $filtersql";
+                           JOIN {enrol} e ON (e.id = ue.enrolid)";
+            if ($this->groupfilter) {
+                $sqltotal .= " LEFT JOIN ({groups_members} gm JOIN {groups} g ON (g.id = gm.groupid))
+                                         ON (u.id = gm.userid AND g.courseid = e.courseid)";
+            }
+            $sqltotal .= "WHERE $filtersql";
             $this->totalusers = (int)$DB->count_records_sql($sqltotal, $params);
         }
         return $this->totalusers;
@@ -245,13 +244,12 @@ class course_enrolment_manager {
                       FROM {user} u
                       JOIN {user_enrolments} ue ON (ue.userid = u.id  AND ue.enrolid $instancessql)
                       JOIN {enrol} e ON (e.id = ue.enrolid)
-                 LEFT JOIN {user_lastaccess} ul ON (ul.courseid = e.courseid AND ul.userid = u.id)
-                 LEFT JOIN {groups_members} gm ON u.id = gm.userid AND gm.groupid IN (
-                               SELECT g.id
-                                 FROM {groups} g
-                                WHERE g.courseid = e.courseid
-                           )
-                     WHERE $filtersql
+                 LEFT JOIN {user_lastaccess} ul ON (ul.courseid = e.courseid AND ul.userid = u.id)";
+            if ($this->groupfilter) {
+                $sql .= " LEFT JOIN ({groups_members} gm JOIN {groups} g ON (g.id = gm.groupid))
+                                    ON (u.id = gm.userid AND g.courseid = e.courseid)";
+            }
+            $sql .= "WHERE $filtersql
                   ORDER BY $sort $direction";
             $this->users[$key] = $DB->get_records_sql($sql, $params, $page*$perpage, $perpage);
         }
diff --git a/enrol/tests/course_enrolment_manager_test.php b/enrol/tests/course_enrolment_manager_test.php
new file mode 100644 (file)
index 0000000..8f56a44
--- /dev/null
@@ -0,0 +1,242 @@
+<?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/>.
+
+/**
+ * Test course_enrolment_manager parts.
+ *
+ * @package    core_enrol
+ * @category   test
+ * @copyright  2016 Ruslan Kabalin, Lancaster University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Test course_enrolment_manager parts.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2016 Ruslan Kabalin, Lancaster University
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_course_enrolment_manager_testcase extends advanced_testcase {
+    /**
+     * The course context used in tests.
+     * @var stdClass
+     */
+    private $course = null;
+    /**
+     * List of users used in tests.
+     * @var array
+     */
+    private $users = array();
+    /**
+     * List of groups used in tests.
+     * @var array
+     */
+    private $groups = array();
+
+    /**
+     * Tests set up
+     */
+    protected function setUp() {
+        global $CFG;
+        require_once($CFG->dirroot . '/enrol/locallib.php');
+        $this->setAdminUser();
+
+        $users = array();
+        $groups = array();
+        // Create the course and the users.
+        $course = $this->getDataGenerator()->create_course();
+        $users['user0'] = $this->getDataGenerator()->create_user(
+                array('username' => 'user0', 'firstname' => 'user0')); // A user without group.
+        $users['user1'] = $this->getDataGenerator()->create_user(
+                array('username' => 'user1', 'firstname' => 'user1')); // User for group 1.
+        $users['user21'] = $this->getDataGenerator()->create_user(
+                array('username' => 'user21', 'firstname' => 'user21')); // Two users for group 2.
+        $users['user22'] = $this->getDataGenerator()->create_user(
+                array('username' => 'user22', 'firstname' => 'user22'));
+        $users['userall'] = $this->getDataGenerator()->create_user(
+                array('username' => 'userall', 'firstname' => 'userall')); // A user in all groups.
+        $users['usertch'] = $this->getDataGenerator()->create_user(
+                array('username' => 'usertch', 'firstname' => 'usertch')); // A user with teacher role.
+
+        // Enrol the users in the course.
+        $this->getDataGenerator()->enrol_user($users['user0']->id, $course->id, 'student'); // Student.
+        $this->getDataGenerator()->enrol_user($users['user1']->id, $course->id, 'student'); // Student.
+        $this->getDataGenerator()->enrol_user($users['user21']->id, $course->id, 'student'); // Student.
+        $this->getDataGenerator()->enrol_user($users['user22']->id, $course->id, 'student', 'manual', 0, 0, ENROL_USER_SUSPENDED); // Suspended student.
+        $this->getDataGenerator()->enrol_user($users['userall']->id, $course->id, 'student'); // Student.
+        $this->getDataGenerator()->enrol_user($users['usertch']->id, $course->id, 'editingteacher'); // Teacher.
+
+        // Create 2 groups.
+        $groups['group1'] = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $groups['group2'] = $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+
+        // Add the users to the groups.
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $groups['group1']->id, 'userid' => $users['user1']->id));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $groups['group2']->id, 'userid' => $users['user21']->id));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $groups['group2']->id, 'userid' => $users['user22']->id));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $groups['group1']->id, 'userid' => $users['userall']->id));
+        $this->getDataGenerator()->create_group_member(
+                array('groupid' => $groups['group2']->id, 'userid' => $users['userall']->id));
+
+        // Make setup data accessible from test methods.
+        $this->course = $course;
+        $this->users = $users;
+        $this->groups = $groups;
+    }
+
+    /**
+     * Verify get_total_users() returned number of users expected in every situation.
+     */
+    public function test_get_total_users() {
+        global $PAGE;
+
+        $this->resetAfterTest();
+
+        // All users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(6, $totalusers, 'All users must be returned when no filtering is applied.');
+
+        // Student role filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 5);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(5, $totalusers, 'Only students must be returned when student role filtering is applied.');
+
+        // Teacher role filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 3);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(1, $totalusers, 'Only teacher must be returned when teacher role filtering is applied.');
+
+        // Search user filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, 'userall');
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(1, $totalusers, 'Only searchable user must be returned when search filtering is applied.');
+
+        // Group 1 filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', $this->groups['group1']->id);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(2, $totalusers, 'Only group members must be returned when group filtering is applied.');
+
+        // Group 2 filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', $this->groups['group2']->id);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(3, $totalusers, 'Only group members must be returned when group filtering is applied.');
+
+        // 'No groups' filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', -1);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(2, $totalusers, 'Only non-group members must be returned when \'no groups\' filtering is applied.');
+
+        // Active users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', 0, ENROL_USER_ACTIVE);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(5, $totalusers, 'Only active users must be returned when active users filtering is applied.');
+
+        // Suspended users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', 0, ENROL_USER_SUSPENDED);
+        $totalusers = $manager->get_total_users();
+        $this->assertEquals(1, $totalusers, 'Only suspended users must be returned when suspended users filtering is applied.');
+    }
+
+    /**
+     * Verify get_users() returned number of users expected in every situation.
+     */
+    public function test_get_users() {
+        global $PAGE;
+
+        $this->resetAfterTest();
+
+        // All users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->get_users('id');
+        $this->assertCount(6, $users,  'All users must be returned when no filtering is applied.');
+        $this->assertArrayHasKey($this->users['user0']->id, $users);
+        $this->assertArrayHasKey($this->users['user1']->id, $users);
+        $this->assertArrayHasKey($this->users['user21']->id, $users);
+        $this->assertArrayHasKey($this->users['user22']->id, $users);
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+        $this->assertArrayHasKey($this->users['usertch']->id, $users);
+
+        // Student role filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 5);
+        $users = $manager->get_users('id');
+        $this->assertCount(5, $users, 'Only students must be returned when student role filtering is applied.');
+        $this->assertArrayHasKey($this->users['user0']->id, $users);
+        $this->assertArrayHasKey($this->users['user1']->id, $users);
+        $this->assertArrayHasKey($this->users['user21']->id, $users);
+        $this->assertArrayHasKey($this->users['user22']->id, $users);
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+
+        // Teacher role filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 3);
+        $users = $manager->get_users('id');
+        $this->assertCount(1, $users, 'Only teacher must be returned when teacher role filtering is applied.');
+        $this->assertArrayHasKey($this->users['usertch']->id, $users);
+
+        // Search user filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, 'userall');
+        $users = $manager->get_users('id');
+        $this->assertCount(1, $users, 'Only searchable user must be returned when search filtering is applied.');
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+
+        // Group 1 filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', $this->groups['group1']->id);
+        $users = $manager->get_users('id');
+        $this->assertCount(2, $users, 'Only group members must be returned when group filtering is applied.');
+        $this->assertArrayHasKey($this->users['user1']->id, $users);
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+
+        // Group 2 filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', $this->groups['group2']->id);
+        $users = $manager->get_users('id');
+        $this->assertCount(3, $users, 'Only group members must be returned when group filtering is applied.');
+        $this->assertArrayHasKey($this->users['user21']->id, $users);
+        $this->assertArrayHasKey($this->users['user22']->id, $users);
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+
+        // 'No groups' filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', -1);
+        $users = $manager->get_users('id');
+        $this->assertCount(2, $users, 'Only non-group members must be returned when \'no groups\' filtering is applied.');
+        $this->assertArrayHasKey($this->users['user0']->id, $users);
+        $this->assertArrayHasKey($this->users['usertch']->id, $users);
+
+        // Active users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', 0, ENROL_USER_ACTIVE);
+        $users = $manager->get_users('id');
+        $this->assertCount(5, $users, 'Only active users must be returned when active users filtering is applied.');
+        $this->assertArrayHasKey($this->users['user0']->id, $users);
+        $this->assertArrayHasKey($this->users['user1']->id, $users);
+        $this->assertArrayHasKey($this->users['user21']->id, $users);
+        $this->assertArrayHasKey($this->users['userall']->id, $users);
+        $this->assertArrayHasKey($this->users['usertch']->id, $users);
+
+        // Suspended users filtering.
+        $manager = new course_enrolment_manager($PAGE, $this->course, null, 0, '', 0, ENROL_USER_SUSPENDED);
+        $users = $manager->get_users('id');
+        $this->assertCount(1, $users, 'Only suspended users must be returned when suspended users filtering is applied.');
+        $this->assertArrayHasKey($this->users['user22']->id, $users);
+    }
+}
index 4f19777..76cf28b 100644 (file)
@@ -145,7 +145,8 @@ class core_files_externallib_testcase extends advanced_testcase {
         $instanceid = null;
 
         // Make sure the file is created.
-        $fileinfo = @core_files_external::upload($contextid, $component, $filearea, $itemid, $filepath, $filename, $filecontent);
+        $fileinfo = core_files_external::upload($contextid, $component, $filearea, $itemid, $filepath, $filename, $filecontent,
+            'user', $USER->id);
         $fileinfo = external_api::clean_returnvalue(core_files_external::upload_returns(), $fileinfo);
         $browser = get_file_browser();
         $file = $browser->get_file_info($context, $component, $filearea, $itemid, $filepath, $filename);
index b500007..7167e9a 100644 (file)
--- a/index.php
+++ b/index.php
@@ -52,13 +52,15 @@ if ($CFG->forcelogin) {
     user_accesstime_log();
 }
 
-$hassiteconfig = has_capability('moodle/site:config', context_system::instance());
+$hasmaintenanceaccess = has_capability('moodle/site:maintenanceaccess', context_system::instance());
 
 // If the site is currently under maintenance, then print a message.
-if (!empty($CFG->maintenance_enabled) and !$hassiteconfig) {
+if (!empty($CFG->maintenance_enabled) and !$hasmaintenanceaccess) {
     print_maintenance_message();
 }
 
+$hassiteconfig = has_capability('moodle/site:config', context_system::instance());
+
 if ($hassiteconfig && moodle_needs_upgrading()) {
     redirect($CFG->wwwroot .'/'. $CFG->admin .'/index.php');
 }
index 622e967..38d796f 100644 (file)
@@ -33,7 +33,7 @@ defined('MOODLE_INTERNAL') || die();
 $string['admindirname'] = 'دایرکتوری مدیر';
 $string['availablelangs'] = 'بسته‌های زبانی موجود';
 $string['chooselanguagehead'] = 'انتخاب زبان';
-$string['chooselanguagesub'] = 'لطفاً زبانی را به جهت استفاده در حین نصب انتخاب نمایید. زبانی که در این صفحه انتخاب می‌کنید به عنوان زبان پیش‌فرض سایت نیز مورد استفاده قرار خواهد گرفت. البته می‌توانید بعداً آن را تغییر دهید.';
+$string['chooselanguagesub'] = 'لطفاً زبانی را به جهت استفاده در حین نصب انتخاب نمایید. زبانی که در این صفحه انتخاب می‌کنید به عنوان زبان پیش‌فرض سایت نیز مورد استفاده قرار خواهد گرفت. البته می‌توانید بعداً آن را تغییر دهید.<br />ترجمهٔ فارسی این نسخه با همکاری <a href="http://foodle.org" target="_blank">گروه فودل</a> آماده شده است.';
 $string['databasehost'] = 'میزبان پایگاه داده';
 $string['databasename'] = 'نام پایگاه داده';
 $string['dataroot'] = 'دایرکتوری داده';
index 07981ed..c285ee7 100644 (file)
@@ -33,7 +33,7 @@ if (isguestuser()) {
     throw new require_login_exception('Guests are not allowed here.');
 }
 
-$ip   = optional_param('ip', getremoteaddr(), PARAM_HOST);
+$ip   = optional_param('ip', getremoteaddr(), PARAM_RAW);
 $user = optional_param('user', 0, PARAM_INT);
 
 if (isset($CFG->iplookup)) {
@@ -48,15 +48,11 @@ $PAGE->set_context(context_system::instance());
 $info = array($ip);
 $note = array();
 
-if (!preg_match('/(^\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/', $ip, $match)) {
+if (cleanremoteaddr($ip) === false) {
     print_error('invalidipformat', 'error');
 }
 
-if ($match[1] > 255 or $match[2] > 255 or $match[3] > 255 or $match[4] > 255) {
-    print_error('invalidipformat', 'error');
-}
-
-if ($match[1] == '127' or $match[1] == '10' or ($match[1] == '172' and $match[2] >= '16' and $match[2] <= '31') or ($match[1] == '192' and $match[2] == '168')) {
+if (!ip_is_public($ip)) {
     print_error('iplookupprivate', 'error');
 }
 
index da07be6..4afeb3f 100644 (file)
@@ -36,39 +36,30 @@ function iplookup_find_location($ip) {
 
     $info = array('city'=>null, 'country'=>null, 'longitude'=>null, 'latitude'=>null, 'error'=>null, 'note'=>'',  'title'=>array());
 
-    if (!empty($CFG->geoipfile) and file_exists($CFG->geoipfile)) {
-        require_once('Net/GeoIP.php');
+    if (!empty($CFG->geoip2file) and file_exists($CFG->geoip2file)) {
+        $reader = new GeoIp2\Database\Reader($CFG->geoip2file);
+        $record = $reader->city($ip);
 
-        $geoip = Net_GeoIP::getInstance($CFG->geoipfile, Net_GeoIP::STANDARD);
-        $location = $geoip->lookupLocation($ip);
-        $geoip->close();
-
-        if (empty($location)) {
+        if (empty($record)) {
             $info['error'] = get_string('iplookupfailed', 'error', $ip);
             return $info;
         }
-        if (!empty($location->city)) {
-            $info['city'] = core_text::convert($location->city, 'iso-8859-1', 'utf-8');
-            $info['title'][] = $info['city'];
-        }
 
-        if (!empty($location->countryCode)) {
-            $countries = get_string_manager()->get_list_of_countries(true);
-            if (isset($countries[$location->countryCode])) {
-                // prefer our localized country names
-                $info['country'] = $countries[$location->countryCode];
-            } else {
-                $info['country'] = $location->countryName;
-            }
-            $info['title'][] = $info['country'];
-
-        } else if (!empty($location->countryName)) {
-            $info['country'] = $location->countryName;
-            $info['title'][] = $info['country'];
+        $info['city'] = core_text::convert($record->city->name, 'iso-8859-1', 'utf-8');
+        $info['title'][] = $info['city'];
+
+        $countrycode = $record->country->isoCode;
+        $countries = get_string_manager()->get_list_of_countries(true);
+        if (isset($countries[$countrycode])) {
+            // Prefer our localized country names.
+            $info['country'] = $countries[$countrycode];
+        } else {
+            $info['country'] = $record->country->names['en'];
         }
+        $info['title'][] = $info['country'];
 
-        $info['longitude'] = $location->longitude;
-        $info['latitude']  = $location->latitude;
+        $info['longitude'] = $record->location->longitude;
+        $info['latitude']  = $record->location->latitude;
         $info['note'] = get_string('iplookupmaxmindnote', 'admin');
 
         return $info;
@@ -76,6 +67,12 @@ function iplookup_find_location($ip) {
     } else {
         require_once($CFG->libdir.'/filelib.php');
 
+        if (strpos($ip, ':') !== false) {
+            // IPv6 is not supported by geoplugin.net.
+            $info['error'] = get_string('invalidipformat', 'error');
+            return $info;
+        }
+
         $ipdata = download_file_content('http://www.geoplugin.net/json.gp?ip='.$ip);
         if ($ipdata) {
             $ipdata = preg_replace('/^geoPlugin\((.*)\)\s*$/s', '$1', $ipdata);
index 3c21394..2143bc0 100644 (file)
@@ -31,20 +31,20 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_iplookup_geoip_testcase extends advanced_testcase {
 
-    public function test_geoip() {
+    public function setUp() {
         global $CFG;
         require_once("$CFG->libdir/filelib.php");
         require_once("$CFG->dirroot/iplookup/lib.php");
 
         if (!PHPUNIT_LONGTEST) {
             // this may take a long time
-            return;
+            $this->markTestSkipped('PHPUNIT_LONGTEST is not defined');
         }
 
         $this->resetAfterTest();
 
         // let's store the file somewhere
-        $gzfile = "$CFG->dataroot/phpunit/geoip/GeoLiteCity.dat.gz";
+        $gzfile = "$CFG->dataroot/phpunit/geoip/GeoLite2-City.mmdb.gz";
         check_dir_exists(dirname($gzfile));
         if (file_exists($gzfile) and (filemtime($gzfile) < time() - 60*60*24*30)) {
             // delete file if older than 1 month
@@ -52,35 +52,60 @@ class core_iplookup_geoip_testcase extends advanced_testcase {
         }
 
         if (!file_exists($gzfile)) {
-            download_file_content('http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz', null, null, false, 300, 20, false, $gzfile);
+            download_file_content('http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz',
+                null, null, false, 300, 20, false, $gzfile);
         }
 
         $this->assertTrue(file_exists($gzfile));
 
-        $zd = gzopen($gzfile, "r");
-        $contents = gzread($zd, 50000000);
-        gzclose($zd);
+        $geoipfile = str_replace('.gz', '', $gzfile);
 
-        $geoipfile = "$CFG->dataroot/geoip/GeoLiteCity.dat";
-        check_dir_exists(dirname($geoipfile));
-        $fp = fopen($geoipfile, 'w');
-        fwrite($fp, $contents);
-        fclose($fp);
+        // Open our files (in binary mode).
+        $file = gzopen($gzfile, 'rb');
+        $geoipfilebuf = fopen($geoipfile, 'wb');
+
+        // Keep repeating until the end of the input file.
+        while (!gzeof($file)) {
+            // Read buffer-size bytes.
+            // Both fwrite and gzread and binary-safe.
+            fwrite($geoipfilebuf, gzread($file, 4096));
+        }
+
+        // Files are done, close files.
+        fclose($geoipfilebuf);
+        gzclose($file);
 
         $this->assertTrue(file_exists($geoipfile));
 
-        $CFG->geoipfile = $geoipfile;
+        $CFG->geoip2file = $geoipfile;
+    }
+
+    public function test_ipv4() {
+
+        $result = iplookup_find_location('131.111.150.25');
+
+        $this->assertEquals('array', gettype($result));
+        $this->assertEquals('Cambridge', $result['city']);
+        $this->assertEquals(0.1249, $result['longitude'], '', 0.001);
+        $this->assertEquals(52.191000000000003, $result['latitude'], '', 0.001);
+        $this->assertNull($result['error']);
+        $this->assertEquals('array', gettype($result['title']));
+        $this->assertEquals('Cambridge', $result['title'][0]);
+        $this->assertEquals('United Kingdom', $result['title'][1]);
+    }
+
+    public function test_ipv6() {
 
-        $result = iplookup_find_location('147.230.16.1');
+        $result = iplookup_find_location('2a01:8900:2:3:8c6c:c0db:3d33:9ce6');
 
         $this->assertEquals('array', gettype($result));
-        $this->assertEquals('Liberec', $result['city']);
-        $this->assertEquals(15.0653, $result['longitude'], '', 0.001);
-        $this->assertEquals(50.7639, $result['latitude'], '', 0.001);
+        $this->assertEquals('Lancaster', $result['city']);
+        $this->assertEquals(-2.79970, $result['longitude'], '', 0.001);
+        $this->assertEquals(54.04650, $result['latitude'], '', 0.001);
         $this->assertNull($result['error']);
         $this->assertEquals('array', gettype($result['title']));
-        $this->assertEquals('Liberec', $result['title'][0]);
-        $this->assertEquals('Czech Republic', $result['title'][1]);
+        $this->assertEquals('Lancaster', $result['title'][0]);
+        $this->assertEquals('United Kingdom', $result['title'][1]);
     }
 }
 
index bf54697..8c94273 100644 (file)
@@ -31,30 +31,39 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_iplookup_geoplugin_testcase extends advanced_testcase {
 
-    public function test_geoip() {
+    public function setUp() {
         global $CFG;
         require_once("$CFG->libdir/filelib.php");
         require_once("$CFG->dirroot/iplookup/lib.php");
 
         if (!PHPUNIT_LONGTEST) {
             // we do not want to DDOS their server, right?
-            return;
+            $this->markTestSkipped('PHPUNIT_LONGTEST is not defined');
         }
 
         $this->resetAfterTest();
 
         $CFG->geoipfile = '';
+    }
 
-        $result = iplookup_find_location('147.230.16.1');
+    public function test_geoip_ipv4() {
+        $result = iplookup_find_location('131.111.150.25');
 
         $this->assertEquals('array', gettype($result));
-        $this->assertEquals('Liberec', $result['city']);
-        $this->assertEquals(15.0653, $result['longitude'], '', 0.001);
-        $this->assertEquals(50.7639, $result['latitude'], '', 0.001);
+        $this->assertEquals('Cambridge', $result['city']);
+        $this->assertEquals(0.1167, $result['longitude'], '', 0.001);
+        $this->assertEquals(52.200000000000003, $result['latitude'], '', 0.001);
         $this->assertNull($result['error']);
         $this->assertEquals('array', gettype($result['title']));
-        $this->assertEquals('Liberec', $result['title'][0]);
-        $this->assertEquals('Czech Republic', $result['title'][1]);
+        $this->assertEquals('Cambridge', $result['title'][0]);
+        $this->assertEquals('United Kingdom', $result['title'][1]);
+    }
+
+    public function test_geoip_ipv6() {
+        $result = iplookup_find_location('2a01:8900:2:3:8c6c:c0db:3d33:9ce6');
+
+        $this->assertNotNull($result['error']);
+        $this->assertEquals($result['error'], get_string('invalidipformat', 'error'));
     }
 }
 
index 52f555b..e3fd072 100644 (file)
@@ -230,7 +230,7 @@ $string['configfrontpageloggedin'] = 'The items selected above will be displayed
 $string['configfullnamedisplay'] = 'This defines how names are shown when they are displayed in full. The default value, "language", leaves it to the string "fullnamedisplay" in the current language pack to decide. Some languages have different name display conventions.
 
 For most mono-lingual sites the most efficient setting is "firstname lastname", but you may choose to hide surnames altogether. Placeholders that can be used are: firstname, lastname, firstnamephonetic, lastnamephonetic, middlename, and alternatename.';
-$string['configgeoipfile'] = 'Location of GeoIP City binary data file. This file is not part of Moodle distribution and must be obtained separately from <a href="http://www.maxmind.com/">MaxMind</a>. You can either buy a commercial version or use the free version. Simply download <a href="http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz" >http://geolite.maxmind.com/download/geoip/database/GeoLiteCity.dat.gz</a> and extract it into "{$a}" directory on your server.';
+$string['configgeoipfile'] = 'Location of GeoLite2 City binary data file. This file is not part of Moodle distribution and must be obtained separately from <a href="http://www.maxmind.com/">MaxMind</a>. You can either buy a commercial version or use the free version. Simply download <a href="http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz" >http://geolite.maxmind.com/download/geoip/database/GeoLite2-City.mmdb.gz</a> and extract it into "{$a}" directory on your server.';
 $string['configgetremoteaddrconf'] = 'If your server is behind a reverse proxy, you can use this setting to specify which HTTP headers can be trusted to contain the remote IP address. The headers are read in order, using the first one that is available.';
 $string['configgradebookroles'] = 'This setting allows you to control who appears on the gradebook.  Users need to have at least one of these roles in a course to be shown in the gradebook for that course.';
 $string['configgradeexport'] = 'Choose which gradebook export formats are your primary methods for exporting grades.  Chosen plugins will then set and use a "last exported" field for every grade.  For example, this might result in exported records being identified as being "new" or "updated".  If you are not sure about this then leave everything unchecked.';
@@ -540,7 +540,7 @@ $string['fullnamedisplay'] = 'Full name format';
 $string['fullnamedisplayprivate'] = 'Full name format - private';
 $string['gdrequired'] = 'The GD extension is now required by Moodle for image conversion.';
 $string['generalsettings'] = 'General settings';
-$string['geoipfile'] = 'GeoIP city data file';
+$string['geoipfile'] = 'GeoLite2 City MaxMind DB';
 $string['getremoteaddrconf'] = 'Logged IP address source';
 $string['globalsearch'] = 'Global search';
 $string['globalsearchmanage'] = 'Manage global search';
@@ -598,9 +598,9 @@ $string['ipblockersyntax'] = 'Put every entry on one line. Valid entries are eit
 $string['iplookup'] = 'IP address lookup';
 $string['iplookupgeoplugin'] = '<a href="http://www.geoplugin.com">geoPlugin</a> service is currently being used to look up geographical information. For more accurate results we recommend installing a local copy of the MaxMind GeoLite database.';
 $string['iplookupinfo'] = 'By default Moodle uses the free online NetGeo (The Internet Geographic Database) server to lookup location of IP addresses, unfortunately this database is not maintained anymore and may return <em>wildly incorrect</em> data.
-It is recommended to install local copy of free GeoLite City database from MaxMind.<br />
+It is recommended to install local copy of free GeoLite2 City database from MaxMind.<br />
 IP address location is displayed on simple map or using Google Maps. Please note that you need to have a Google account and apply for free Google Maps API key to enable interactive maps.';
-$string['iplookupmaxmindnote'] = 'This product includes GeoLite data created by MaxMind, available from <a href="http://www.maxmind.com/">http://www.maxmind.com/</a>.';
+$string['iplookupmaxmindnote'] = 'This product includes GeoLite2 data created by MaxMind, available from <a href="http://www.maxmind.com">http://www.maxmind.com</a>.';
 $string['keeptagnamecase'] = 'Keep tag name casing';
 $string['lang'] = 'Default language';
 $string['langcache'] = 'Cache language menu';
index a5b182d..b7ede5d 100644 (file)
@@ -392,6 +392,7 @@ $string['site:doanything'] = 'Allowed to do everything';
 $string['site:doclinks'] = 'Show links to offsite docs';
 $string['site:forcelanguage'] = 'Override course language';
 $string['site:import'] = 'Import other courses into a course';
+$string['site:maintenanceaccess'] = 'Allowed access when maintenance mode is enabled.';
 $string['site:manageblocks'] = 'Manage blocks on a page';
 $string['site:mnetloginfromremote'] = 'Login from a remote application via MNet';
 $string['site:mnetlogintoremote'] = 'Roam to a remote application via MNet';
diff --git a/lib/amd/build/custom_interaction_events.min.js b/lib/amd/build/custom_interaction_events.min.js
new file mode 100644 (file)
index 0000000..8ce561d
Binary files /dev/null and b/lib/amd/build/custom_interaction_events.min.js differ
diff --git a/lib/amd/build/key_codes.min.js b/lib/amd/build/key_codes.min.js
new file mode 100644 (file)
index 0000000..04bace5
Binary files /dev/null and b/lib/amd/build/key_codes.min.js differ
diff --git a/lib/amd/build/modal.min.js b/lib/amd/build/modal.min.js
new file mode 100644 (file)
index 0000000..98fd724
Binary files /dev/null and b/lib/amd/build/modal.min.js differ
diff --git a/lib/amd/build/modal_backdrop.min.js b/lib/amd/build/modal_backdrop.min.js
new file mode 100644 (file)
index 0000000..7984ca1
Binary files /dev/null and b/lib/amd/build/modal_backdrop.min.js differ
diff --git a/lib/amd/build/modal_events.min.js b/lib/amd/build/modal_events.min.js
new file mode 100644 (file)
index 0000000..ee5cdce
Binary files /dev/null and b/lib/amd/build/modal_events.min.js differ
diff --git a/lib/amd/build/modal_factory.min.js b/lib/amd/build/modal_factory.min.js
new file mode 100644 (file)
index 0000000..0191451
Binary files /dev/null and b/lib/amd/build/modal_factory.min.js differ
diff --git a/lib/amd/build/modal_save_cancel.min.js b/lib/amd/build/modal_save_cancel.min.js
new file mode 100644 (file)
index 0000000..0bdfe30
Binary files /dev/null and b/lib/amd/build/modal_save_cancel.min.js differ
diff --git a/lib/amd/src/custom_interaction_events.js b/lib/amd/src/custom_interaction_events.js
new file mode 100644 (file)
index 0000000..8666b23
--- /dev/null
@@ -0,0 +1,381 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This module provides a wrapper to encapsulate a lot of the common combinations of
+ * user interaction we use in Moodle.
+ *
+ * @module     core/custom_interaction_events
+ * @class      custom_interaction_events
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.2
+ */
+define(['jquery', 'core/key_codes'], function($, keyCodes) {
+    // The list of events provided by this module. Namespaced to avoid clashes.
+    var events = {
+        activate: 'cie:activate',
+        keyboardActivate: 'cie:keyboardactivate',
+        escape: 'cie:escape',
+        down: 'cie:down',
+        up: 'cie:up',
+        home: 'cie:home',
+        end: 'cie:end',
+        next: 'cie:next',
+        previous: 'cie:previous',
+        asterix: 'cie:asterix',
+        scrollTop: 'cie:scrollTop',
+        scrollBottom: 'cie:scrollBottom',
+        ctrlPageUp: 'cie:ctrlPageUp',
+        ctrlPageDown: 'cie:ctrlPageDown',
+        enter: 'cie:enter',
+    };
+
+    /**
+     * Check if the caller has asked for the given event type to be
+     * registered.
+     *
+     * @method shouldAddEvent
+     * @private
+     * @param {string} eventType name of the event (see events above)
+     * @param {array} include the list of events to be added
+     * @return {bool} true if the event should be added, false otherwise.
+     */
+    var shouldAddEvent = function(eventType, include) {
+        include = include || [];
+
+        if (include.length && include.indexOf(eventType) !== -1) {
+            return true;
+        }
+
+        return false;
+    };
+
+    /**
+     * Check if any of the modifier keys have been pressed on the event.
+     *
+     * @method isModifierPressed
+     * @private
+     * @param {event} e jQuery event
+     * @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
+     */
+    var isModifierPressed = function(e) {
+        return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
+    };
+
+    /**
+     * Register a keyboard event that ignores modifier keys.
+     *
+     * @method addKeyboardEvent
+     * @private
+     * @param {object} element A jQuery object of the element to bind events to
+     * @param {string} event The custom interaction event name
+     * @param {int} keyCode The key code.
+     */
+    var addKeyboardEvent = function(element, event, keyCode) {
+        element.off('keydown.' + event).on('keydown.' + event, function(e) {
+            if (!isModifierPressed(e)) {
+                if (e.keyCode == keyCode) {
+                    $(e.target).trigger(event, [{originalEvent: e}]);
+                }
+            }
+        });
+    };
+
+    /**
+     * Trigger the activate event on the given element if it is clicked or the enter
+     * or space key are pressed without a modifier key.
+     *
+     * @method addActivateListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addActivateListener = function(element) {
+        element.off('click.cie.activate').on('click.cie.activate', function(e) {
+            $(e.target).trigger(events.activate, [{originalEvent: e}]);
+        });
+        element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
+            if (!isModifierPressed(e)) {
+                if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
+                    $(e.target).trigger(events.activate, [{originalEvent: e}]);
+                }
+            }
+        });
+    };
+
+    /**
+     * Trigger the keyboard activate event on the given element if the enter
+     * or space key are pressed without a modifier key.
+     *
+     * @method addKeyboardActivateListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addKeyboardActivateListener = function(element) {
+        element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
+            if (!isModifierPressed(e)) {
+                if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
+                    $(e.target).trigger(events.keyboardActivate, [{originalEvent: e}]);
+                }
+            }
+        });
+    };
+
+    /**
+     * Trigger the escape event on the given element if the escape key is pressed
+     * without a modifier key.
+     *
+     * @method addEscapeListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addEscapeListener = function(element) {
+        addKeyboardEvent(element, events.escape, keyCodes.escape);
+    };
+
+    /**
+     * Trigger the down event on the given element if the down arrow key is pressed
+     * without a modifier key.
+     *
+     * @method addDownListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addDownListener = function(element) {
+        addKeyboardEvent(element, events.down, keyCodes.arrowDown);
+    };
+
+    /**
+     * Trigger the up event on the given element if the up arrow key is pressed
+     * without a modifier key.
+     *
+     * @method addUpListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addUpListener = function(element) {
+        addKeyboardEvent(element, events.up, keyCodes.arrowUp);
+    };
+
+    /**
+     * Trigger the home event on the given element if the home key is pressed
+     * without a modifier key.
+     *
+     * @method addHomeListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addHomeListener = function(element) {
+        addKeyboardEvent(element, events.home, keyCodes.home);
+    };
+
+    /**
+     * Trigger the end event on the given element if the end key is pressed
+     * without a modifier key.
+     *
+     * @method addEndListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addEndListener = function(element) {
+        addKeyboardEvent(element, events.end, keyCodes.end);
+    };
+
+    /**
+     * Trigger the next event on the given element if the right arrow key is pressed
+     * without a modifier key in LTR mode or left arrow key in RTL mode.
+     *
+     * @method addNextListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addNextListener = function(element) {
+        // Left and right are flipped in RTL mode.
+        var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
+
+        addKeyboardEvent(element, events.next, keyCode);
+    };
+
+    /**
+     * Trigger the previous event on the given element if the left arrow key is pressed
+     * without a modifier key in LTR mode or right arrow key in RTL mode.
+     *
+     * @method addPreviousListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addPreviousListener = function(element) {
+        // Left and right are flipped in RTL mode.
+        var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
+
+        addKeyboardEvent(element, events.previous, keyCode);
+    };
+
+    /**
+     * Trigger the asterix event on the given element if the asterix key is pressed
+     * without a modifier key.
+     *
+     * @method addAsterixListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addAsterixListener = function(element) {
+        addKeyboardEvent(element, events.asterix, keyCodes.asterix);
+    };
+
+
+    /**
+     * Trigger the scrollTop event on the given element if the user scrolls to
+     * the top of the given element.
+     *
+     * @method addScrollTopListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addScrollTopListener = function(element) {
+        element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function() {
+            var scrollTop = element.scrollTop();
+            if (scrollTop === 0) {
+                element.trigger(events.scrollTop);
+            }
+        });
+    };
+
+    /**
+     * Trigger the scrollBottom event on the given element if the user scrolls to
+     * the bottom of the given element.
+     *
+     * @method addScrollBottomListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addScrollBottomListener = function(element) {
+        element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function() {
+            var scrollTop = element.scrollTop();
+            var innerHeight = element.innerHeight();
+            var scrollHeight = element[0].scrollHeight;
+
+            if (scrollTop + innerHeight >= scrollHeight) {
+                element.trigger(events.scrollBottom);
+            }
+        });
+    };
+
+    /**
+     * Trigger the ctrlPageUp event on the given element if the user presses the
+     * control and page up key.
+     *
+     * @method addCtrlPageUpListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addCtrlPageUpListener = function(element) {
+        element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
+            if (e.ctrlKey) {
+                if (e.keyCode == keyCodes.pageUp) {
+                    $(e.target).trigger(events.ctrlPageUp, [{originalEvent: e}]);
+                }
+            }
+        });
+    };
+
+    /**
+     * Trigger the ctrlPageDown event on the given element if the user presses the
+     * control and page down key.
+     *
+     * @method addCtrlPageDownListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addCtrlPageDownListener = function(element) {
+        element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
+            if (e.ctrlKey) {
+                if (e.keyCode == keyCodes.pageDown) {
+                    $(e.target).trigger(events.ctrlPageDown, [{originalEvent: e}]);
+                }
+            }
+        });
+    };
+
+    /**
+     * Trigger the enter event on the given element if the enter key is pressed
+     * without a modifier key.
+     *
+     * @method addEnterListener
+     * @private
+     * @param {object} element jQuery object to add event listeners to
+     */
+    var addEnterListener = function(element) {
+        addKeyboardEvent(element, events.enter, keyCodes.enter);
+    };
+
+    /**
+     * Get the list of events and their handlers.
+     *
+     * @method getHandlers
+     * @private
+     * @return {object} object key of event names and value of handler functions
+     */
+    var getHandlers = function() {
+        var handlers = {};
+
+        handlers[events.activate] = addActivateListener;
+        handlers[events.keyboardActivate] = addKeyboardActivateListener;
+        handlers[events.escape] = addEscapeListener;
+        handlers[events.down] = addDownListener;
+        handlers[events.up] = addUpListener;
+        handlers[events.home] = addHomeListener;
+        handlers[events.end] = addEndListener;
+        handlers[events.next] = addNextListener;
+        handlers[events.previous] = addPreviousListener;
+        handlers[events.asterix] = addAsterixListener;
+        handlers[events.scrollTop] = addScrollTopListener;
+        handlers[events.scrollBottom] = addScrollBottomListener;
+        handlers[events.ctrlPageUp] = addCtrlPageUpListener;
+        handlers[events.ctrlPageDown] = addCtrlPageDownListener;
+        handlers[events.enter] = addEnterListener;
+
+        return handlers;
+    };
+
+    /**
+     * Add all of the listeners on the given element for the requested events.
+     *
+     * @method define
+     * @public
+     * @param {object} element the DOM element to register event listeners on
+     * @param {array} include the array of events to be triggered
+     */
+    var define = function(element, include) {
+        element = $(element);
+        include = include || [];
+
+        if (!element.length || !include.length) {
+            return;
+        }
+
+        $.each(getHandlers(), function(eventType, handler) {
+            if (shouldAddEvent(eventType, include)) {
+                handler(element);
+            }
+        });
+    };
+
+    return /** @module core/custom_interaction_events */ {
+        define: define,
+        events: events,
+    };
+});
similarity index 56%
rename from theme/base/version.php
rename to lib/amd/src/key_codes.js
index 80b5247..b64529b 100644 (file)
@@ -1,4 +1,3 @@
-<?php
 // This file is part of Moodle - http://moodle.org/
 //
 // Moodle is free software: you can redistribute it and/or modify
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Theme version info
+ * A list of human readable names for the keycodes.
  *
- * @package    theme_base
- * @copyright  2011 Petr Skoda  {@link http://skodak.org}
+ * @module     core/key_codes
+ * @class      key_codes
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.2
  */
+define(function() {
 
-defined('MOODLE_INTERNAL') || die;
-
-$plugin->version   = 2016052300; // The current module version (Date: YYYYMMDDXX).
-$plugin->requires  = 2016051900; // Requires this Moodle version.
-$plugin->component = 'theme_base'; // Full name of the plugin (used for diagnostics).
+    return /** @alias module:core/key_codes */ {
+        'tab': 9,
+        'enter': 13,
+        'escape': 27,
+        'space': 32,
+        'end': 35,
+        'home': 36,
+        'arrowLeft': 37,
+        'arrowUp': 38,
+        'arrowRight': 39,
+        'arrowDown': 40,
+        '8': 56,
+        'asterix': 106,
+        'pageUp': 33,
+        'pageDown': 34,
+    };
+});
diff --git a/lib/amd/src/modal.js b/lib/amd/src/modal.js
new file mode 100644 (file)
index 0000000..2421359
--- /dev/null
@@ -0,0 +1,591 @@
+// 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/>.
+
+/**
+ * Contain the logic for modals.
+ *
+ * @module     core/modal
+ * @class      modal
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
+        'core/custom_interaction_events', 'core/modal_backdrop', 'core/modal_events'],
+     function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, ModalEvents) {
+
+    var SELECTORS = {
+        CONTAINER: '[data-region="modal-container"]',
+        MODAL: '[data-region="modal"]',
+        HEADER: '[data-region="header"]',
+        TITLE: '[data-region="title"]',
+        BODY: '[data-region="body"]',
+        FOOTER: '[data-region="footer"]',
+        HIDE: '[data-action="hide"]',
+        DIALOG: '[role=dialog]',
+        MENU_BAR: '[role=menubar]',
+        HAS_Z_INDEX: '.moodle-has-zindex',
+        CAN_RECEIVE_FOCUS: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
+    };
+
+    var TEMPLATES = {
+        LOADING: 'core/loading',
+        BACKDROP: 'core/modal_backdrop',
+    };
+
+    /**
+     * Module singleton for the backdrop to be reused by all Modal instances.
+     */
+    var backdropPromise;
+
+    /**
+     * Constructor for the Modal.
+     *
+     * @param {object} root The root jQuery element for the modal
+     */
+    var Modal = function(root) {
+        this.root = $(root);
+        this.modal = this.root.find(SELECTORS.MODAL);
+        this.header = this.modal.find(SELECTORS.HEADER);
+        this.title = this.header.find(SELECTORS.TITLE);
+        this.body = this.modal.find(SELECTORS.BODY);
+        this.footer = this.modal.find(SELECTORS.FOOTER);
+        this.hiddenSiblings = [];
+        this.isAttached = false;
+        this.bodyJS = null;
+        this.footerJS = null;
+
+        if (!this.root.is(SELECTORS.CONTAINER)) {
+            Notification.exception({message: 'Element is not a modal container'});
+        }
+
+        if (!this.modal.length) {
+            Notification.exception({message: 'Container does not contain a modal'});
+        }
+
+        if (!this.header.length) {
+            Notification.exception({message: 'Modal is missing a header region'});
+        }
+
+        if (!this.title.length) {
+            Notification.exception({message: 'Modal header is missing a title region'});
+        }
+
+        if (!this.body.length) {
+            Notification.exception({message: 'Modal is missing a body region'});
+        }
+
+        if (!this.footer.length) {
+            Notification.exception({message: 'Modal is missing a footer region'});
+        }
+
+        this.registerEventListeners();
+    };
+
+    /**
+     * Add the modal to the page, if it hasn't already been added. This includes running any
+     * javascript that has been cached until now.
+     *
+     * @method attachToDOM
+     */
+    Modal.prototype.attachToDOM = function() {
+        if (this.isAttached) {
+            return;
+        }
+
+        $('body').append(this.root);
+
+        // If we'd cached any JS then we can run it how that the modal is
+        // attached to the DOM.
+        if (this.bodyJS) {
+            Templates.runTemplateJS(this.bodyJS);
+            this.bodyJS = null;
+        }
+
+        if (this.footerJS) {
+            Templates.runTemplateJS(this.footerJS);
+            this.footerJS = null;
+        }
+
+        this.isAttached = true;
+    };
+
+    /**
+     * Count the number of other visible modals (not including this one).
+     *
+     * @method countOtherVisibleModals
+     * @return {int}
+     */
+    Modal.prototype.countOtherVisibleModals = function() {
+        var count = 0;
+        $('body').find(SELECTORS.CONTAINER).each(function(index, element) {
+            element = $(element);
+
+            // If we haven't found ourself and the element is visible.
+            if (!this.root.is(element) && element.hasClass('show')) {
+                count++;
+            }
+        }.bind(this));
+
+        return count;
+    };
+
+    /**
+     * Get the modal backdrop.
+     *
+     * @method getBackdrop
+     * @return {object} jQuery promise
+     */
+    Modal.prototype.getBackdrop = function() {
+        if (!backdropPromise) {
+            backdropPromise = Templates.render(TEMPLATES.BACKDROP, {})
+                .then(function(html) {
+                    var element = $(html);
+
+                    return new ModalBackdrop(element);
+                })
+                .fail(Notification.exception);
+        }
+
+        return backdropPromise;
+    };
+
+    /**
+     * Get the root element of this modal.
+     *
+     * @method getRoot
+     * @return {object} jQuery object
+     */
+    Modal.prototype.getRoot = function() {
+        return this.root;
+    };
+
+    /**
+     * Get the modal element of this modal.
+     *
+     * @method getModal
+     * @return {object} jQuery object
+     */
+    Modal.prototype.getModal = function() {
+        return this.modal;
+    };
+
+    /**
+     * Get the modal title element.
+     *
+     * @method getTitle
+     * @return {object} jQuery object
+     */
+    Modal.prototype.getTitle = function() {
+        return this.title;
+    };
+
+    /**
+     * Get the modal body element.
+     *
+     * @method getBody
+     * @return {object} jQuery object
+     */
+    Modal.prototype.getBody = function() {
+        return this.body;
+    };
+
+    /**
+     * Get the modal footer element.
+     *
+     * @method getFooter
+     * @return {object} jQuery object
+     */
+    Modal.prototype.getFooter = function() {
+        return this.footer;
+    };
+
+    /**
+     * Set the modal title element.
+     *
+     * @method setTitle
+     * @param {string} value The title string
+     */
+    Modal.prototype.setTitle = function(value) {
+        var title = this.getTitle();
+        title.html(value);
+    };
+
+    /**
+     * Set the modal body element.
+     *
+     * This method is overloaded to take either a string
+     * value for the body or a jQuery promise that is resolved with HTML and Javascript
+     * most commonly from a Templates.render call.
+     *
+     * @method setBody
+     * @param {(string|object)} value The body string or jQuery promise
+     */
+    Modal.prototype.setBody = function(value) {
+        var body = this.getBody();
+
+        if (typeof value === 'string') {
+            // Just set the value if it's a string.
+            body.html(value);
+        } else {
+            // Otherwise we assume it's a promise to be resolved with
+            // html and javascript.
+            Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
+                body.html(html);
+
+                value.done(function(html, js) {
+                    body.html(html);
+
+                    if (this.isAttached) {
+                        // If we're in the DOM then run the JS immediately.
+                        Templates.runTemplateJS(js);
+                    } else {
+                        // Otherwise cache it to be run when we're attached.
+                        this.bodyJS = js;
+                    }
+                }.bind(this));
+            }.bind(this));
+        }
+    };
+
+    /**
+     * Set the modal footer element.
+     *
+     * This method is overloaded to take either a string
+     * value for the body or a jQuery promise that is resolved with HTML and Javascript
+     * most commonly from a Templates.render call.
+     *
+     * @method setFooter
+     * @param {(string|object)} value The footer string or jQuery promise
+     */
+    Modal.prototype.setFooter = function(value) {
+        var footer = this.getFooter();
+
+        if (typeof value === 'string') {
+            // Just set the value if it's a string.
+            footer.html(value);
+        } else {
+            // Otherwise we assume it's a promise to be resolved with
+            // html and javascript.
+            Templates.render(TEMPLATES.LOADING, {}).done(function(html) {
+                footer.html(html);
+
+                value.done(function(html, js) {
+                    footer.html(html);
+
+                    if (this.isAttached) {
+                        // If we're in the DOM then run the JS immediately.
+                        Templates.runTemplateJS(js);
+                    } else {
+                        // Otherwise cache it to be run when we're attached.
+                        this.footerJS = js;
+                    }
+                }.bind(this));
+            }.bind(this));
+        }
+    };
+
+    /**
+     * Mark the modal as a large modal.
+     *
+     * @method setLarge
+     */
+    Modal.prototype.setLarge = function() {
+        if (this.isLarge()) {
+            return;
+        }
+
+        this.getRoot().addClass('large');
+    };
+
+    /**
+     * Check if the modal is a large modal.
+     *
+     * @method isLarge
+     * @return {bool}
+     */
+    Modal.prototype.isLarge = function() {
+        return this.getRoot().hasClass('large');
+    };
+
+    /**
+     * Mark the modal as a small modal.
+     *
+     * @method setSmall
+     */
+    Modal.prototype.setSmall = function() {
+        if (this.isSmall()) {
+            return;
+        }
+
+        this.getRoot().removeClass('large');
+    };
+
+    /**
+     * Check if the modal is a small modal.
+     *
+     * @method isSmall
+     * @return {bool}
+     */
+    Modal.prototype.isSmall = function() {
+        return !this.getRoot().hasClass('large');
+    };
+
+    /**
+     * Determine the highest z-index value currently on the page.
+     *
+     * @method calculateZIndex
+     * @return {int}
+     */
+    Modal.prototype.calculateZIndex = function() {
+        var items = $(SELECTORS.DIALOG + ', ' + SELECTORS.MENU_BAR + ', ' + SELECTORS.HAS_Z_INDEX);
+        var zIndex = parseInt(this.root.css('z-index'));
+
+        items.each(function(index, item) {
+            item = $(item);
+            // Note that webkit browsers won't return the z-index value from the CSS stylesheet
+            // if the element doesn't have a position specified. Instead it'll return "auto".
+            var itemZIndex = item.css('z-index') ? parseInt(item.css('z-index')) : 0;
+
+            if (itemZIndex > zIndex) {
+                zIndex = itemZIndex;
+            }
+        });
+
+        return zIndex;
+    };
+
+    /**
+     * Check if this modal is visible.
+     *
+     * @method isVisible
+     * @return {bool}
+     */
+    Modal.prototype.isVisible = function() {
+        return this.root.hasClass('show');
+    };
+
+    /**
+     * Check if this modal has focus.
+     *
+     * @method hasFocus
+     * @return {bool}
+     */
+    Modal.prototype.hasFocus = function() {
+        var target = $(document.activeElement);
+        return this.root.is(target) || this.root.has(target).length;
+    };
+
+    /**
+     * Check if this modal has CSS transitions applied.
+     *
+     * @method hasTransitions
+     * @return {bool}
+     */
+    Modal.prototype.hasTransitions = function() {
+        return this.getRoot().hasClass('fade');
+    };
+
+    /**
+     * Display this modal. The modal will be attached to the DOM if it hasn't
+     * already been.
+     *
+     * @method show
+     */
+    Modal.prototype.show = function() {
+        if (this.isVisible()) {
+            return;
+        }
+
+        if (!this.isAttached) {
+            this.attachToDOM();
+        }
+
+        this.getBackdrop().done(function(backdrop) {
+            var currentIndex = this.calculateZIndex();
+            var newIndex = currentIndex + 2;
+            var newBackdropIndex = newIndex - 1;
+            this.root.css('z-index', newIndex);
+            backdrop.setZIndex(newBackdropIndex);
+            backdrop.show();
+
+            this.root.removeClass('hide').addClass('show');
+            this.accessibilityShow();
+            this.getTitle().focus();
+            $('body').addClass('modal-open');
+            this.root.trigger(ModalEvents.shown, this);
+        }.bind(this));
+    };
+
+    /**
+     * Hide this modal.
+     *
+     * @method hide
+     */
+    Modal.prototype.hide = function() {
+        if (!this.isVisible()) {
+            return;
+        }
+
+        this.getBackdrop().done(function(backdrop) {
+            if (!this.countOtherVisibleModals()) {
+                // Hide the backdrop if we're the last open modal.
+                backdrop.hide();
+                $('body').removeClass('modal-open');
+            }
+
+            var currentIndex = parseInt(this.root.css('z-index'));
+            this.root.css('z-index', '');
+            backdrop.setZIndex(currentIndex - 3);
+
+            this.accessibilityHide();
+
+            if (this.hasTransitions()) {
+                // Wait for CSS transitions to complete before hiding the element.
+                this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
+                    this.getRoot().removeClass('show').addClass('hide');
+                }.bind(this));
+            } else {
+                this.getRoot().removeClass('show').addClass('hide');
+            }
+
+            this.root.trigger(ModalEvents.hidden, this);
+        }.bind(this));
+    };
+
+    /**
+     * Remove this modal from the DOM.
+     *
+     * @method destroy
+     */
+    Modal.prototype.destroy = function() {
+        this.root.remove();
+        this.root.trigger(ModalEvents.destroyed, this);
+    };
+
+    /**
+     * Sets the appropriate aria attributes on this dialogue and the other
+     * elements in the DOM to ensure that screen readers are able to navigate
+     * the dialogue popup correctly.
+     *
+     * @method accessibilityShow
+     */
+    Modal.prototype.accessibilityShow = function() {
+        // We need to get a list containing each sibling element and the shallowest
+        // non-ancestral nodes in the DOM. We can shortcut this a little by leveraging
+        // the fact that this dialogue is always appended to the document body therefore
+        // it's siblings are the shallowest non-ancestral nodes. If that changes then
+        // this code should also be updated.
+        $('body').children().each(function(index, child) {
+            // Skip the current modal.
+            if (!this.root.is(child)) {
+                child = $(child);
+                var hidden = child.attr('aria-hidden');
+                // If they are already hidden we can ignore them.
+                if (hidden !== 'true') {
+                    // Save their current state.
+                    child.data('previous-aria-hidden', hidden);
+                    this.hiddenSiblings.push(child);
+
+                    // Hide this node from screen readers.
+                    child.attr('aria-hidden', 'true');
+                }
+            }
+        }.bind(this));
+
+        // Make us visible to screen readers.
+        this.root.attr('aria-hidden', 'false');
+    };
+
+    /**
+     * Restores the aria visibility on the DOM elements changed when displaying
+     * the dialogue popup and makes the dialogue aria hidden to allow screen
+     * readers to navigate the main page correctly when the dialogue is closed.
+     *
+     * @method accessibilityHide
+     */
+    Modal.prototype.accessibilityHide = function() {
+        this.root.attr('aria-hidden', 'true');
+
+        // Restore the sibling nodes back to their original values.
+        $.each(this.hiddenSiblings, function(index, sibling) {
+            sibling = $(sibling);
+            var previousValue = sibling.data('previous-aria-hidden');
+            // If the element didn't previously have an aria-hidden attribute
+            // then we can just remove the one we set.
+            if (typeof previousValue == 'undefined') {
+                sibling.removeAttr('aria-hidden');
+            } else {
+                // Otherwise set it back to the old value (which will be false).
+                sibling.attr('aria-hidden', previousValue);
+            }
+        });
+
+        // Clear the cache. No longer need to store these.
+        this.hiddenSiblings = [];
+    };
+
+    /**
+     * Handle the tab event to lock focus within this modal.
+     *
+     * @method handleTabLock
+     * @param {event} e The tab key jQuery event
+     */
+    Modal.prototype.handleTabLock = function(e) {
+        if (!this.hasFocus()) {
+            return;
+        }
+
+        var target = $(document.activeElement);
+        var focusableElements = this.modal.find(SELECTORS.CAN_RECEIVE_FOCUS);
+        var firstFocusable = focusableElements.first();
+        var lastFocusable = focusableElements.last();
+
+        if (target.is(firstFocusable) && e.shiftKey) {
+            lastFocusable.focus();
+            e.preventDefault();
+        } else if (target.is(lastFocusable) && !e.shiftKey) {
+            firstFocusable.focus();
+            e.preventDefault();
+        }
+    };
+
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    Modal.prototype.registerEventListeners = function() {
+        this.getRoot().on('keydown', function(e) {
+            if (!this.isVisible()) {
+                return;
+            }
+
+            if (e.keyCode == KeyCodes.tab) {
+                this.handleTabLock(e);
+            } else if (e.keyCode == KeyCodes.escape) {
+                this.hide();
+            }
+        }.bind(this));
+
+        CustomEvents.define(this.getModal(), [CustomEvents.events.activate]);
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.HIDE, function(e, data) {
+            this.hide();
+            data.originalEvent.preventDefault();
+        }.bind(this));
+    };
+
+    return Modal;
+});
diff --git a/lib/amd/src/modal_backdrop.js b/lib/amd/src/modal_backdrop.js
new file mode 100644 (file)
index 0000000..75a7b26
--- /dev/null
@@ -0,0 +1,148 @@
+// 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/>.
+
+/**
+ * Contain the logic for modal backdrops.
+ *
+ * @module     core/modal_backdrop
+ * @class      modal_backdrop
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/templates', 'core/notification'],
+     function($, Templates, Notification) {
+
+    var SELECTORS = {
+        ROOT: '[data-region="modal-backdrop"]',
+    };
+
+    /**
+     * Constructor for ModalBackdrop.
+     *
+     * @param {object} root The root element for the modal backdrop
+     */
+    var ModalBackdrop = function(root) {
+        this.root = $(root);
+        this.isAttached = false;
+
+        if (!this.root.is(SELECTORS.ROOT)) {
+            Notification.exception({message: 'Element is not a modal backdrop'});
+        }
+    };
+
+    /**
+     * Get the root element of this modal backdrop.
+     *
+     * @method getRoot
+     * @return {object} jQuery object
+     */
+    ModalBackdrop.prototype.getRoot = function() {
+        return this.root;
+    };
+
+    /**
+     * Add the modal backdrop to the page, if it hasn't already been added.
+     *
+     * @method attachToDOM
+     */
+    ModalBackdrop.prototype.attachToDOM = function() {
+        if (this.isAttached) {
+            return;
+        }
+
+        $('body').append(this.root);
+        this.isAttached = true;
+    };
+
+    /**
+     * Set the z-index value for this backdrop.
+     *
+     * @method setZIndex
+     * @param {int} value The z-index value
+     */
+    ModalBackdrop.prototype.setZIndex = function(value) {
+        this.root.css('z-index', value);
+    };
+
+    /**
+     * Check if this backdrop is visible.
+     *
+     * @method isVisible
+     * @return {bool}
+     */
+    ModalBackdrop.prototype.isVisible = function() {
+        return this.root.hasClass('show');
+    };
+
+    /**
+     * Check if this backdrop has CSS transitions applied.
+     *
+     * @method hasTransitions
+     * @return {bool}
+     */
+    ModalBackdrop.prototype.hasTransitions = function() {
+        return this.getRoot().hasClass('fade');
+    };
+
+    /**
+     * Display this backdrop. The backdrop will be attached to the DOM if it hasn't
+     * already been.
+     *
+     * @method show
+     */
+    ModalBackdrop.prototype.show = function() {
+        if (this.isVisible()) {
+            return;
+        }
+
+        if (!this.isAttached) {
+            this.attachToDOM();
+        }
+
+        this.root.removeClass('hide').addClass('show');
+    };
+
+    /**
+     * Hide this backdrop.
+     *
+     * @method hide
+     */
+    ModalBackdrop.prototype.hide = function() {
+        if (!this.isVisible()) {
+            return;
+        }
+
+        if (this.hasTransitions()) {
+            // Wait for CSS transitions to complete before hiding the element.
+            this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', function() {
+                this.getRoot().removeClass('show').addClass('hide');
+            }.bind(this));
+        } else {
+            this.getRoot().removeClass('show').addClass('hide');
+        }
+    };
+
+    /**
+     * Remove this backdrop from the DOM.
+     *
+     * @method destroy
+     */
+    ModalBackdrop.prototype.destroy = function() {
+        this.root.remove();
+    };
+
+    return ModalBackdrop;
+});
similarity index 62%
rename from theme/canvas/version.php
rename to lib/amd/src/modal_events.js
index 50c7955..81fc683 100644 (file)
@@ -1,4 +1,3 @@
-<?php
 // This file is part of Moodle - http://moodle.org/
 //
 // Moodle is free software: you can redistribute it and/or modify
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Theme version info
+ * Contain the events a modal can fire.
  *
- * @package    theme_canvas
- * @copyright  2010 Patrick Malley
+ * @module     core/modal_events
+ * @class      modal_events
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-
-defined('MOODLE_INTERNAL') || die;
-
-$plugin->version   = 2016052300; // The current module version (Date: YYYYMMDDXX)
-$plugin->requires  = 2016051900; // Requires this Moodle version
-$plugin->component = 'theme_canvas'; // Full name of the plugin (used for diagnostics)
-$plugin->dependencies = array(
-    'theme_base'  => 2016051900,
-);
+define([], function() {
+    return {
+        // Default events.
+        shown: 'modal:shown',
+        hidden: 'modal:hidden',
+        destroyed: 'modal:destroyed',
+        // ModalSaveCancel events.
+        save: 'modal-save-cancel:save',
+        cancel: 'modal-save-cancel:cancel',
+    };
+});
diff --git a/lib/amd/src/modal_factory.js b/lib/amd/src/modal_factory.js
new file mode 100644 (file)
index 0000000..7932c86
--- /dev/null
@@ -0,0 +1,150 @@
+// 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/>.
+
+/**
+ * Create a modal.
+ *
+ * @module     core/modal_factory
+ * @class      modal_factory
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/modal_events', 'core/modal', 'core/modal_save_cancel', 'core/templates',
+    'core/notification', 'core/custom_interaction_events'],
+    function($, ModalEvents, Modal, ModalSaveCancel, Templates, Notification, CustomEvents) {
+
+    // The templates for each type of modal.
+    var TEMPLATES = {
+        DEFAULT: 'core/modal',
+        SAVE_CANCEL: 'core/modal_save_cancel',
+    };
+
+    // The JS classes for each type of modal.
+    var CLASSES = {
+        DEFAULT: Modal,
+        SAVE_CANCEL: ModalSaveCancel,
+    };
+
+    // The available types of modals.
+    var TYPES = {
+        DEFAULT: 'DEFAULT',
+        SAVE_CANCEL: 'SAVE_CANCEL',
+    };
+
+    /**
+     * Set up the events required to show the modal and return focus when the modal
+     * is closed.
+     *
+     * @method setUpTrigger
+     * @param {object} modal The modal instance
+     * @param {object} triggerElement The jQuery element to open the modal
+     */
+    var setUpTrigger = function(modal, triggerElement) {
+        if (typeof triggerElement != 'undefined') {
+            CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
+            triggerElement.on(CustomEvents.events.activate, function() {
+                modal.show();
+            });
+
+            modal.getRoot().on(ModalEvents.hidden, function() {
+                triggerElement.focus();
+            });
+        }
+    };
+
+    /**
+     * Create the correct instance of a modal based on the givem type. Sets up
+     * the trigger between the modal and the trigger element.
+     *
+     * @method createFromElement
+     * @param {string} type A modal type (see TYPES)
+     * @param {object} modalElement The modal HTML jQuery object
+     * @param {object} triggerElement The trigger HTML jQuery object
+     * @return {object} Modal instance
+     */
+    var createFromElement = function(type, modalElement, triggerElement) {
+        modalElement = $(modalElement);
+        var className = CLASSES[type];
+        var modal = new className(modalElement);
+        setUpTrigger(modal, triggerElement);
+
+        return modal;
+    };
+
+    /**
+     * Create the correct modal instance for the given type, including loading
+     * the correct template and setting up the trigger relationship with the
+     * trigger element.
+     *
+     * @method createFromType
+     * @param {string} type A modal type (see TYPES)
+     * @param {object} triggerElement The trigger HTML jQuery object
+     * @return {promise} Resolved with a Modal instance
+     */
+    var createFromType = function(type, triggerElement) {
+        var templateName = TEMPLATES[type];
+
+        return Templates.render(templateName, {})
+            .then(function(html) {
+                var modalElement = $(html);
+                return createFromElement(type, modalElement, triggerElement);
+            })
+            .fail(Notification.exception);
+    };
+
+    /**
+     * Create a Modal instance.
+     *
+     * @method create
+     * @param {object} modalConfig The configuration to create the modal instance
+     * @param {object} triggerElement The trigger HTML jQuery object
+     * @return {promise} Resolved with a Modal instance
+     */
+    var create = function(modalConfig, triggerElement) {
+        var type = modalConfig.type || TYPES.DEFAULT;
+        var isLarge = modalConfig.large ? true : false;
+
+        if (!TYPES[type]) {
+            type = TYPES.DEFAULT;
+        }
+
+        return createFromType(type, triggerElement)
+            .then(function(modal) {
+                if (typeof modalConfig.title != 'undefined') {
+                    modal.setTitle(modalConfig.title);
+                }
+
+                if (typeof modalConfig.body != 'undefined') {
+                    modal.setBody(modalConfig.body);
+                }
+
+                if (typeof modalConfig.footer != 'undefined') {
+                    modal.setFooter(modalConfig.footer);
+                }
+
+                if (isLarge) {
+                    modal.setLarge();
+                }
+
+                return modal;
+            });
+    };
+
+    return {
+        create: create,
+        types: TYPES,
+    };
+});
diff --git a/lib/amd/src/modal_save_cancel.js b/lib/amd/src/modal_save_cancel.js
new file mode 100644 (file)
index 0000000..6aa133c
--- /dev/null
@@ -0,0 +1,92 @@
+// 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/>.
+
+/**
+ * Contain the logic for the save/cancel modal.
+ *
+ * @module     core/modal_save_cancel
+ * @class      modal_save_cancel
+ * @package    core
+ * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
+        function($, Notification, CustomEvents, Modal, ModalEvents) {
+
+    var SELECTORS = {
+        SAVE_BUTTON: '[data-action="save"]',
+        CANCEL_BUTTON: '[data-action="cancel"]',
+    };
+
+    /**
+     * Constructor for the Modal.
+     *
+     * @param {object} root The root jQuery element for the modal
+     */
+    var ModalSaveCancel = function(root) {
+        Modal.call(this, root);
+
+        if (!this.getFooter().find(SELECTORS.SAVE_BUTTON).length) {
+            Notification.exception({message: 'No save button found'});
+        }
+
+        if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+            Notification.exception({message: 'No cancel button found'});
+        }
+    };
+
+    ModalSaveCancel.prototype = Object.create(Modal.prototype);
+    ModalSaveCancel.prototype.constructor = ModalSaveCancel;
+
+    /**
+     * Override parent implementation to prevent changing the footer content.
+     */
+    ModalSaveCancel.prototype.setFooter = function() {
+        Notification.exception({message: 'Can not change the footer of a save cancel modal'});
+        return;
+    };
+
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    ModalSaveCancel.prototype.registerEventListeners = function() {
+        // Apply parent event listeners.
+        Modal.prototype.registerEventListeners.call(this);
+
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
+            var saveEvent = $.Event(ModalEvents.save);
+            this.getRoot().trigger(saveEvent, this);
+
+            if (!saveEvent.isDefaultPrevented()) {
+                this.hide();
+                data.originalEvent.preventDefault();
+            }
+        }.bind(this));
+
+        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
+            var cancelEvent = $.Event(ModalEvents.cancel);
+            this.getRoot().trigger(cancelEvent, this);
+
+            if (!cancelEvent.isDefaultPrevented()) {
+                this.hide();
+                data.originalEvent.preventDefault();
+            }
+        }.bind(this));
+    };
+
+    return ModalSaveCancel;
+});
index 1dcc33c..ba3bced 100644 (file)
@@ -223,17 +223,13 @@ class behat_config_util {
         global $CFG;
 
         // Fix directory path.
-        $featurepath = str_replace('\\', DIRECTORY_SEPARATOR, $featurepath);
-        $featurepath = str_replace('/', DIRECTORY_SEPARATOR, $featurepath);
-
-        if (testing_is_cygwin()) {
-            $featurepath = str_replace('\\', '/', $featurepath);
-        }
+        $featurepath = testing_cli_fix_directory_separator($featurepath);
+        $dirroot = testing_cli_fix_directory_separator($CFG->dirroot . DIRECTORY_SEPARATOR);
 
         $key = basename($featurepath, '.feature');
 
         // Get relative path.
-        $featuredirname = str_replace($CFG->dirroot . DIRECTORY_SEPARATOR , '', $featurepath);
+        $featuredirname = str_replace($dirroot , '', $featurepath);
         // Get 5 levels of feature path to ensure we have a unique key.
         for ($i = 0; $i < 5; $i++) {
             if (($featuredirname = dirname($featuredirname)) && $featuredirname !== '.') {
@@ -624,6 +620,11 @@ class behat_config_util {
      */
     public function profile_guided_allocate($features, $nbuckets, $instance) {
 
+        // No profile guided allocation is required in phpunit.
+        if (defined('PHPUNIT_TEST')) {
+            return false;
+        }
+
         $behattimingfile = defined('BEHAT_FEATURE_TIMING_FILE') &&
         @filesize(BEHAT_FEATURE_TIMING_FILE) ? BEHAT_FEATURE_TIMING_FILE : false;
 
index ff70009..c0c0363 100644 (file)
@@ -345,5 +345,9 @@ class behat_util extends testing_util {
 
         // Inform data generator.
         self::get_data_generator()->reset();
+
+        // Initialise $CFG with default values. This is needed for behat cli process, so we don't have modified
+        // $CFG values from the old run. @see set_config.
+        initialise_cfg();
     }
 }
index c49ed69..6544ab4 100644 (file)
@@ -73,6 +73,8 @@ class core_component {
     );
     /** @var array associative array of PRS-4 namespaces and corresponding paths. */
     protected static $psr4namespaces = array(
+        'MaxMind' => 'lib/maxmind/MaxMind',
+        'GeoIp2' => 'lib/maxmind/GeoIP2',
     );
 
     /**
index c2c71ae..5f89d64 100644 (file)
@@ -72,6 +72,6 @@ class search_indexed extends base {
      * @return \moodle_url
      */
     public function get_url() {
-        return new \moodle_url('/search/admin.php');
+        return new \moodle_url('/admin/searchareas.php');
     }
 }
index 559a99a..53e8ec1 100644 (file)
@@ -1664,9 +1664,9 @@ class core_plugin_manager {
             'repository' => array('alfresco'),
             'tinymce' => array('dragmath'),
             'tool' => array('bloglevelupgrade', 'qeupgradehelper', 'timezoneimport'),
-            'theme' => array('mymobile', 'afterburner', 'anomaly', 'arialist', 'binarius', 'boxxie', 'brick', 'formal_white',
-                'formfactor', 'fusion', 'leatherbound', 'magazine', 'nimble', 'nonzero', 'overlay', 'serenity', 'sky_high',
-                'splash', 'standard', 'standardold'),
+            'theme' => array('afterburner', 'anomaly', 'arialist', 'base', 'binarius', 'boxxie', 'brick', 'canvas',
+                'formal_white', 'formfactor', 'fusion', 'leatherbound', 'magazine', 'mymobile', 'nimble', 'nonzero',
+                'overlay', 'serenity', 'sky_high', 'splash', 'standard', 'standardold'),
             'webservice' => array('amf'),
         );
 
@@ -1900,7 +1900,7 @@ class core_plugin_manager {
             ),
 
             'theme' => array(
-                'base', 'bootstrapbase', 'canvas', 'clean', 'more'
+                'bootstrapbase', 'clean', 'more'
             ),
 
             'tool' => array(
index 9136516..7d83661 100644 (file)
@@ -2292,5 +2292,11 @@ $capabilities = array(
             'manager' => CAP_ALLOW
         ),
     ),
+    'moodle/site:maintenanceaccess' => array(
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => array(
+        )
+    ),
 
 );
index 51265b0..c038172 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20160404" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20160804" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="attemptstepid" TYPE="foreign" FIELDS="attemptstepid" REFTABLE="question_attempt_steps" REFFIELDS="id"/>
       </KEYS>
-      <INDEXES>
-        <INDEX NAME="attemptstepid-name" UNIQUE="true" FIELDS="attemptstepid, name"/>
-      </INDEXES>
     </TABLE>
     <TABLE NAME="question_statistics" COMMENT="Statistics for individual questions used in an activity.">
       <FIELDS>
index c413aa6..469b6ab 100644 (file)
@@ -2177,5 +2177,66 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2016082200.00);
     }
 
+    if ($oldversion < 2016091900.00) {
+
+        // Removing the themes from core.
+        $themes = array('base', 'canvas');
+
+        foreach ($themes as $key => $theme) {
+            if (check_dir_exists($CFG->dirroot . '/theme/' . $theme, false)) {
+                // Ignore the themes that have been re-downloaded.
+                unset($themes[$key]);
+            }
+        }
+
+        if (!empty($themes)) {
+
+            list($insql, $inparams) = $DB->get_in_or_equal($themes, SQL_PARAMS_NAMED);
+
+            // Replace the theme usage.
+            $DB->set_field_select('course', 'theme', 'clean', "theme $insql", $inparams);
+            $DB->set_field_select('course_categories', 'theme', 'clean', "theme $insql", $inparams);
+            $DB->set_field_select('user', 'theme', 'clean', "theme $insql", $inparams);
+            $DB->set_field_select('mnet_host', 'theme', 'clean', "theme $insql", $inparams);
+
+            // Replace the theme configs.
+            if (in_array(get_config('core', 'theme'), $themes)) {
+                set_config('theme', 'clean');
+            }
+            if (in_array(get_config('core', 'thememobile'), $themes)) {
+                set_config('thememobile', 'clean');
+            }
+            if (in_array(get_config('core', 'themelegacy'), $themes)) {
+                set_config('themelegacy', 'clean');
+            }
+            if (in_array(get_config('core', 'themetablet'), $themes)) {
+                set_config('themetablet', 'clean');
+            }
+
+            // Hacky emulation of plugin uninstallation.
+            foreach ($themes as $theme) {
+                unset_all_config_for_plugin('theme_' . $theme);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2016091900.00);
+    }
+
+    if ($oldversion < 2016091900.02) {
+
+        // Define index attemptstepid-name (unique) to be dropped from question_attempt_step_data.
+        $table = new xmldb_table('question_attempt_step_data');
+        $index = new xmldb_index('attemptstepid-name', XMLDB_INDEX_UNIQUE, array('attemptstepid', 'name'));
+
+        // Conditionally launch drop index attemptstepid-name.
+        if ($dbman->index_exists($table, $index)) {
+            $dbman->drop_index($table, $index);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2016091900.02);
+    }
+
     return true;
 }
index e78005d..bf37ffd 100644 (file)
@@ -2050,6 +2050,33 @@ abstract class moodle_database {
         return $this->sql_order_by_text($fieldname, $numchars);
     }
 
+    /**
+     * Returns an equal (=) or not equal (<>) part of a query.
+     *
+     * Note the use of this method may lead to slower queries (full scans) so
+     * use it only when needed and against already reduced data sets.
+     *
+     * @since Moodle 3.2
+     *
+     * @param string $fieldname Usually the name of the table column.
+     * @param string $param Usually the bound query parameter (?, :named).
+     * @param bool $casesensitive Use case sensitive search when set to true (default).
+     * @param bool $accentsensitive Use accent sensitive search when set to true (default). (not all databases support accent insensitive)
+     * @param bool $notequal True means not equal (<>)
+     * @return string The SQL code fragment.
+     */
+    public function sql_equal($fieldname, $param, $casesensitive = true, $accentsensitive = true, $notequal = false) {
+        // Note that, by default, it's assumed that the correct sql equal operations are
+        // case sensitive. Only databases not observing this behavior must override the method.
+        // Also, accent sensitiveness only will be handled by databases supporting it.
+        $equalop = $notequal ? '<>' : '=';
+        if ($casesensitive) {
+            return "$fieldname $equalop $param";
+        } else {
+            return "LOWER($fieldname) $equalop LOWER($param)";
+        }
+    }
+
     /**
      * Returns 'LIKE' part of a query.
      *
index fd1721a..7215308 100644 (file)
@@ -1221,6 +1221,24 @@ class mssql_native_moodle_database extends moodle_database {
         return $this->collation;
     }
 
+    public function sql_equal($fieldname, $param, $casesensitive = true, $accentsensitive = true, $notequal = false) {
+        $equalop = $notequal ? '<>' : '=';
+        $collation = $this->get_collation();
+
+        if ($casesensitive) {
+            $collation = str_replace('_CI', '_CS', $collation);
+        } else {
+            $collation = str_replace('_CS', '_CI', $collation);
+        }
+        if ($accentsensitive) {
+            $collation = str_replace('_AI', '_AS', $collation);
+        } else {
+            $collation = str_replace('_AS', '_AI', $collation);
+        }
+
+        return "$fieldname COLLATE $collation $equalop $param";
+    }
+
     /**
      * Returns 'LIKE' part of a query.
      *
index 663bd6a..6048cdd 100644 (file)
@@ -1511,6 +1511,24 @@ class mysqli_native_moodle_database extends moodle_database {
         return ' CAST(' . $fieldname . ' AS DECIMAL(65,7)) ';
     }
 
+    public function sql_equal($fieldname, $param, $casesensitive = true, $accentsensitive = true, $notequal = false) {
+        $equalop = $notequal ? '<>' : '=';
+        if ($casesensitive) {
+            // Current MySQL versions do not support case sensitive and accent insensitive.
+            return "$fieldname COLLATE utf8_bin $equalop $param";
+        } else if ($accentsensitive) {
+            // Case insensitive and accent sensitive, we can force a binary comparison once all texts are using the same case.
+            return "LOWER($fieldname) COLLATE utf8_bin $equalop LOWER($param)";
+        } else {
+            // Case insensitive and accent insensitive. All collations are that way, but utf8_bin.
+            $collation = '';
+            if ($this->get_dbcollation() == 'utf8_bin') {
+                $collation = 'COLLATE utf8_unicode_ci';
+            }
+            return "$fieldname $collation $equalop $param";
+        }
+    }
+
     /**
      * Returns 'LIKE' part of a query.
      *
index e2d253f..5de4ff0 100644 (file)
@@ -1288,6 +1288,24 @@ class sqlsrv_native_moodle_database extends moodle_database {
         return $this->collation;
     }
 
+    public function sql_equal($fieldname, $param, $casesensitive = true, $accentsensitive = true, $notequal = false) {
+        $equalop = $notequal ? '<>' : '=';
+        $collation = $this->get_collation();
+
+        if ($casesensitive) {
+            $collation = str_replace('_CI', '_CS', $collation);
+        } else {
+            $collation = str_replace('_CS', '_CI', $collation);
+        }
+        if ($accentsensitive) {
+            $collation = str_replace('_AI', '_AS', $collation);
+        } else {
+            $collation = str_replace('_AS', '_AI', $collation);
+        }
+
+        return "$fieldname COLLATE $collation $equalop $param";
+    }
+
     /**
      * Returns 'LIKE' part of a query.
      *
index 7851e2e..5aaf56b 100644 (file)
@@ -3829,7 +3829,7 @@ class core_dml_testcase extends database_driver_testcase {
         }
     }
 
-    public function test_sql_binary_equal() {
+    public function test_sql_equal() {
         $DB = $this->tdb;
         $dbman = $DB->get_manager();
 
@@ -3838,20 +3838,51 @@ class core_dml_testcase extends database_driver_testcase {
 
         $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
         $table->add_field('name', XMLDB_TYPE_CHAR, '255', null, null, null, null);
+        $table->add_field('name2', XMLDB_TYPE_CHAR, '255', null, null, null, null);
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         $dbman->create_table($table);
 
-        $DB->insert_record($tablename, array('name'=>'aaa'));
-        $DB->insert_record($tablename, array('name'=>'aáa'));
-        $DB->insert_record($tablename, array('name'=>'aäa'));
-        $DB->insert_record($tablename, array('name'=>'bbb'));
-        $DB->insert_record($tablename, array('name'=>'BBB'));
+        $DB->insert_record($tablename, array('name' => 'one', 'name2' => 'one'));
+        $DB->insert_record($tablename, array('name' => 'ONE', 'name2' => 'ONE'));
+        $DB->insert_record($tablename, array('name' => 'two', 'name2' => 'TWO'));
+        $DB->insert_record($tablename, array('name' => 'öne', 'name2' => 'one'));
+        $DB->insert_record($tablename, array('name' => 'öne', 'name2' => 'ÖNE'));
 
-        $records = $DB->get_records_sql("SELECT * FROM {{$tablename}} WHERE name = ?", array('bbb'));
-        $this->assertEquals(1, count($records), 'SQL operator "=" is expected to be case sensitive');
+        // Case sensitive and accent sensitive (equal and not equal).
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', '?', true, true, false);
+        $records = $DB->get_records_sql($sql, array('one'));
+        $this->assertCount(1, $records);
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', ':name', true, true, true);
+        $records = $DB->get_records_sql($sql, array('name' => 'one'));
+        $this->assertCount(4, $records);
+        // And with column comparison instead of params.
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', 'name2', true, true, false);
+        $records = $DB->get_records_sql($sql);
+        $this->assertCount(2, $records);
 
-        $records = $DB->get_records_sql("SELECT * FROM {{$tablename}} WHERE name = ?", array('aaa'));
-        $this->assertEquals(1, count($records), 'SQL operator "=" is expected to be accent sensitive');
+        // Case insensitive and accent sensitive (equal and not equal).
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', '?', false, true, false);
+        $records = $DB->get_records_sql($sql, array('one'));
+        $this->assertCount(2, $records);
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', ':name', false, true, true);
+        $records = $DB->get_records_sql($sql, array('name' => 'one'));
+        $this->assertCount(3, $records);
+        // And with column comparison instead of params.
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', 'name2', false, true, false);
+        $records = $DB->get_records_sql($sql);
+        $this->assertCount(4, $records);
+
+        // TODO: Accent insensitive is not cross-db, only some drivers support it, so just verify the queries work.
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', '?', true, false);
+        $records = $DB->get_records_sql($sql, array('one'));
+        $this->assertGreaterThanOrEqual(1, count($records)); // At very least, there is 1 record with CS/AI "one".
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', '?', false, false);
+        $records = $DB->get_records_sql($sql, array('one'));
+        $this->assertGreaterThanOrEqual(2, count($records)); // At very least, there are 2 records with CI/AI "one".
+        // And with column comparison instead of params.
+        $sql = "SELECT * FROM {{$tablename}} WHERE " . $DB->sql_equal('name', 'name2', false, false);
+        $records = $DB->get_records_sql($sql);
+        $this->assertGreaterThanOrEqual(4, count($records)); // At very least, there are 4 records with CI/AI names matching.
     }
 
     public function test_sql_like() {
@@ -4146,6 +4177,9 @@ class core_dml_testcase extends database_driver_testcase {
             $this->fail("Expecting an exception, none occurred");
         } catch (moodle_exception $e) {
             $this->assertInstanceOf('coding_exception', $e);
+        } catch (Error $error) {
+            // PHP 7.1 throws Error even earlier.
+            $this->assertRegExp('/Too few arguments to function/', $error->getMessage());
         }
 
         // Cover the function using placeholders in all positions.
index 29957e3..0c8ed84 100644 (file)
@@ -130,18 +130,18 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
         $dateformat = $calendartype->get_date_order($this->_options['startyear'], $this->_options['stopyear']);
         foreach ($dateformat as $key => $value) {
             // E_STRICT creating elements without forms is nasty because it internally uses $this
-            $this->_elements[] = @MoodleQuickForm::createElement('select', $key, get_string($key, 'form'), $value, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', $key, get_string($key, 'form'), $value, $this->getAttributes(), true);
         }
         // The YUI2 calendar only supports the gregorian calendar type so only display the calendar image if this is being used.
         if ($calendartype->get_name() === 'gregorian') {
             $image = $OUTPUT->pix_icon('i/calendar', get_string('calendar', 'calendar'), 'moodle');
-            $this->_elements[] = @MoodleQuickForm::createElement('link', 'calendar',
+            $this->_elements[] = $this->createFormElement('link', 'calendar',
                     null, '#', $image,
                     array('class' => 'visibleifjs'));
         }
         // If optional we add a checkbox which the user can use to turn if on
         if ($this->_options['optional']) {
-            $this->_elements[] = @MoodleQuickForm::createElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
         }
         foreach ($this->_elements as $element){
             if (method_exists($element, 'setHiddenLabel')){
@@ -160,6 +160,7 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
      * @return bool
      */
     function onQuickFormEvent($event, $arg, &$caller) {
+        $this->setMoodleForm($caller);
         switch ($event) {
             case 'updateValue':
                 // Constant values override both default and submitted ones
index 8055f02..760deae 100644 (file)
@@ -139,25 +139,25 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
         $dateformat = $calendartype->get_date_order($this->_options['startyear'], $this->_options['stopyear']);
         foreach ($dateformat as $key => $date) {
             // E_STRICT creating elements without forms is nasty because it internally uses $this
-            $this->_elements[] = @MoodleQuickForm::createElement('select', $key, get_string($key, 'form'), $date, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', $key, get_string($key, 'form'), $date, $this->getAttributes(), true);
         }
         if (right_to_left()) {   // Switch order of elements for Right-to-Left
-            $this->_elements[] = @MoodleQuickForm::createElement('select', 'minute', get_string('minute', 'form'), $minutes, $this->getAttributes(), true);
-            $this->_elements[] = @MoodleQuickForm::createElement('select', 'hour', get_string('hour', 'form'), $hours, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', 'minute', get_string('minute', 'form'), $minutes, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', 'hour', get_string('hour', 'form'), $hours, $this->getAttributes(), true);
         } else {
-            $this->_elements[] = @MoodleQuickForm::createElement('select', 'hour', get_string('hour', 'form'), $hours, $this->getAttributes(), true);
-            $this->_elements[] = @MoodleQuickForm::createElement('select', 'minute', get_string('minute', 'form'), $minutes, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', 'hour', get_string('hour', 'form'), $hours, $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('select', 'minute', get_string('minute', 'form'), $minutes, $this->getAttributes(), true);
         }
         // The YUI2 calendar only supports the gregorian calendar type so only display the calendar image if this is being used.
         if ($calendartype->get_name() === 'gregorian') {
             $image = $OUTPUT->pix_icon('i/calendar', get_string('calendar', 'calendar'), 'moodle');
-            $this->_elements[] = @MoodleQuickForm::createElement('link', 'calendar',
+            $this->_elements[] = $this->createFormElement('link', 'calendar',
                     null, '#', $image,
                     array('class' => 'visibleifjs'));
         }
         // If optional we add a checkbox which the user can use to turn if on
         if ($this->_options['optional']) {
-            $this->_elements[] = @MoodleQuickForm::createElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
         }
         foreach ($this->_elements as $element){
             if (method_exists($element, 'setHiddenLabel')){
@@ -176,6 +176,7 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
      * @return bool
      */
     function onQuickFormEvent($event, $arg, &$caller) {
+        $this->setMoodleForm($caller);
         switch ($event) {
             case 'updateValue':
                 // Constant values override both default and submitted ones
index ab2d434..c921d14 100644 (file)
@@ -144,12 +144,12 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
         }
         $this->_elements = array();
         // E_STRICT creating elements without forms is nasty because it internally uses $this
-        $this->_elements[] = @MoodleQuickForm::createElement('text', 'number', get_string('time', 'form'), $attributes, true);
+        $this->_elements[] = $this->createFormElement('text', 'number', get_string('time', 'form'), $attributes, true);
         unset($attributes['size']);
-        $this->_elements[] = @MoodleQuickForm::createElement('select', 'timeunit', get_string('timeunit', 'form'), $this->get_units(), $attributes, true);
+        $this->_elements[] = $this->createFormElement('select', 'timeunit', get_string('timeunit', 'form'), $this->get_units(), $attributes, true);
         // If optional we add a checkbox which the user can use to turn if on
         if($this->_options['optional']) {
-            $this->_elements[] = @MoodleQuickForm::createElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
+            $this->_elements[] = $this->createFormElement('checkbox', 'enabled', null, get_string('enable'), $this->getAttributes(), true);
         }
         foreach ($this->_elements as $element){
             if (method_exists($element, 'setHiddenLabel')){
@@ -167,6 +167,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      * @return bool
      */
     function onQuickFormEvent($event, $arg, &$caller) {
+        $this->setMoodleForm($caller);
         switch ($event) {
             case 'updateValue':
                 // constant values override both default and submitted ones
index 753977f..6750d36 100644 (file)
@@ -41,6 +41,9 @@ class MoodleQuickForm_group extends HTML_QuickForm_group{
     /** @var string html for help button, if empty then no help */
     var $_helpbutton='';
 
+    /** @var MoodleQuickForm */
+    protected $_mform = null;
+
     /**
      * constructor
      *
@@ -107,4 +110,41 @@ class MoodleQuickForm_group extends HTML_QuickForm_group{
             }
         }
     }
+
+    /**
+     * Stores the form this element was added to
+     * This object is later used by {@link MoodleQuickForm_group::createElement()}
+     * @param null|MoodleQuickForm $mform
+     */
+    public function setMoodleForm($mform) {
+        if ($mform && $mform instanceof MoodleQuickForm) {
+            $this->_mform = $mform;
+        }
+    }
+
+    /**
+     * Called by HTML_QuickForm whenever form event is made on this element
+     *
+     * If this function is overridden and parent is not called the element must be responsible for
+     * storing the MoodleQuickForm object, see {@link MoodleQuickForm_group::setMoodleForm()}
+     *
+     * @param     string $event Name of event
+     * @param     mixed $arg event arguments
+     * @param     mixed $caller calling object
+     */
+    public function onQuickFormEvent($event, $arg, &$caller) {
+        $this->setMoodleForm($caller);
+        return parent::onQuickFormEvent($event, $arg, $caller);
+    }
+
+    /**
+     * Creates an element to add to the group
+     * Expects the same arguments as MoodleQuickForm::createElement()
+     */
+    public function createFormElement() {
+        if (!$this->_mform) {
+            throw new coding_exception('You can not call createFormElement() on the group element that was not yet added to a form.');
+        }
+        return call_user_func_array([$this->_mform, 'createElement'], func_get_args());
+    }
 }
index bced16a..1be7cc8 100644 (file)
@@ -147,7 +147,7 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
         // Grade scale select box.
         $scales = get_scales_menu($COURSE->id);
         $langscale = get_string('modgradetypescale', 'grades');
-        $this->scaleformelement = @MoodleQuickForm::createElement('select', 'modgrade_scale', $langscale,
+        $this->scaleformelement = $this->createFormElement('select', 'modgrade_scale', $langscale,
             $scales, $attributes);
         $this->scaleformelement->setHiddenLabel = false;
         $scaleformelementid = $this->generate_modgrade_subelement_id('modgrade_scale');
@@ -155,7 +155,7 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
 
         // Maximum grade textbox.
         $langmaxgrade = get_string('modgrademaxgrade', 'grades');
-        $this->maxgradeformelement = @MoodleQuickForm::createElement('text', 'modgrade_point', $langmaxgrade, array());
+        $this->maxgradeformelement = $this->createFormElement('text', 'modgrade_point', $langmaxgrade, array());
         $this->maxgradeformelement->setHiddenLabel = false;
         $maxgradeformelementid = $this->generate_modgrade_subelement_id('modgrade_point');
         $this->maxgradeformelement->updateAttributes(array('id' => $maxgradeformelementid));
@@ -167,7 +167,7 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
             'point' => get_string('modgradetypepoint', 'grades'),
         );
         $langtype = get_string('modgradetype', 'grades');
-        $this->gradetypeformelement = @MoodleQuickForm::createElement('select', 'modgrade_type', $langtype, $gradetype,
+        $this->gradetypeformelement = $this->createFormElement('select', 'modgrade_type', $langtype, $gradetype,
             $attributes, true);
         $this->gradetypeformelement->setHiddenLabel = false;
         $gradetypeformelementid = $this->generate_modgrade_subelement_id('modgrade_type');
@@ -184,7 +184,7 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
                 $choices[''] = get_string('choose');
                 $choices['no'] = get_string('no');
                 $choices['yes'] = get_string('yes');
-                $rescalegradesselect = @MoodleQuickForm::createElement('select',
+                $rescalegradesselect = $this->createFormElement('select',
                     'modgrade_rescalegrades',
                     $langrescalegrades,
                     $choices);
@@ -204,23 +204,23 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
             }
 
             $gradesexisthtml = '<div class=\'alert\'>' . $gradesexistmsg . '</div>';
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'gradesexistmsg', '', $gradesexisthtml);
+            $this->_elements[] = $this->createFormElement('static', 'gradesexistmsg', '', $gradesexisthtml);
         }
 
         // Grade type select box.
         $label = html_writer::tag('label', $this->gradetypeformelement->getLabel(),
             array('for' => $this->gradetypeformelement->getAttribute('id')));
-        $this->_elements[] = @MoodleQuickForm::createElement('static', 'gradetypelabel', '', '&nbsp;'.$label);
+        $this->_elements[] = $this->createFormElement('static', 'gradetypelabel', '', '&nbsp;'.$label);
         $this->_elements[] = $this->gradetypeformelement;
-        $this->_elements[] = @MoodleQuickForm::createElement('static', 'gradetypespacer', '', '<br />');
+        $this->_elements[] = $this->createFormElement('static', 'gradetypespacer', '', '<br />');
 
         // Only show the grade scale select box when applicable.
         if (!$this->isupdate || !$this->hasgrades || $this->currentgradetype == 'scale') {
             $label = html_writer::tag('label', $this->scaleformelement->getLabel(),
                 array('for' => $this->scaleformelement->getAttribute('id')));
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'scalelabel', '', $label);
+            $this->_elements[] = $this->createFormElement('static', 'scalelabel', '', $label);
             $this->_elements[] = $this->scaleformelement;
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'scalespacer', '', '<br />');
+            $this->_elements[] = $this->createFormElement('static', 'scalespacer', '', '<br />');
         }
 
         if ($this->isupdate && $this->hasgrades && $this->canrescale && $this->currentgradetype == 'point') {
@@ -228,18 +228,18 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
             $label = html_writer::tag('label', $rescalegradesselect->getLabel(),
                 array('for' => $rescalegradesselect->getAttribute('id')));
             $labelhelp = new help_icon('modgraderescalegrades', 'grades');
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'scalelabel', '', $label . $OUTPUT->render($labelhelp));
+            $this->_elements[] = $this->createFormElement('static', 'scalelabel', '', $label . $OUTPUT->render($labelhelp));
             $this->_elements[] = $rescalegradesselect;
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'scalespacer', '', '<br />');
+            $this->_elements[] = $this->createFormElement('static', 'scalespacer', '', '<br />');
         }
 
         // Only show the max points form element when applicable.
         if (!$this->isupdate || !$this->hasgrades || $this->currentgradetype == 'point') {
             $label = html_writer::tag('label', $this->maxgradeformelement->getLabel(),
                 array('for' => $this->maxgradeformelement->getAttribute('id')));
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'pointlabel', '', $label);
+            $this->_elements[] = $this->createFormElement('static', 'pointlabel', '', $label);
             $this->_elements[] = $this->maxgradeformelement;
-            $this->_elements[] = @MoodleQuickForm::createElement('static', 'pointspacer', '', '<br />');
+            $this->_elements[] = $this->createFormElement('static', 'pointspacer', '', '<br />');
         }
     }
 
@@ -340,6 +340,7 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
      * @return mixed
      */
     public function onQuickFormEvent($event, $arg, &$caller) {
+        $this->setMoodleForm($caller);
         switch ($event) {
             case 'createElement':
                 // The first argument is the name.
index f6e4e24..84727ed 100644 (file)
@@ -129,8 +129,8 @@ class core_form_dateselector_testcase extends advanced_testcase {
 
             // Create dateselector element with different timezones.
             $elparams = array('optional'=>false, 'timezone' => $vals['timezone']);
-            $el = new MoodleQuickForm_date_selector('dateselector', null, $elparams);
-            $el->_createElements();
+            $el = $this->mform->addElement('date_selector', 'dateselector', null, $elparams);
+            $this->assertTrue($el instanceof MoodleQuickForm_date_selector);
             $submitvalues = array('dateselector' => $vals);
 
             $this->assertSame(array('dateselector' => $vals['timestamp']), $el->exportValue($submitvalues),
@@ -153,8 +153,8 @@ class core_form_dateselector_testcase extends advanced_testcase {
 
             // Create dateselector element with different timezones.
             $elparams = array('optional'=>false, 'timezone' => $vals['timezone']);
-            $el = new MoodleQuickForm_date_selector('dateselector', null, $elparams);
-            $el->_createElements();
+            $el = $this->mform->addElement('date_selector', 'dateselector', null, $elparams);
+            $this->assertTrue($el instanceof MoodleQuickForm_date_selector);
             $expectedvalues = array(
                 'day' => array($vals['day']),
                 'month' => array($vals['month']),
index ba188ee..40b69c7 100644 (file)
@@ -141,8 +141,8 @@ class core_form_datetimeselector_testcase extends advanced_testcase {
 
             // Create dateselector element with different timezones.
             $elparams = array('optional'=>false, 'timezone' => $vals['timezone']);
-            $el = new MoodleQuickForm_date_time_selector('dateselector', null, $elparams);
-            $el->_createElements();
+            $el = $this->mform->addElement('date_time_selector', 'dateselector', null, $elparams);
+            $this->assertTrue($el instanceof MoodleQuickForm_date_time_selector);
             $submitvalues = array('dateselector' => $vals);
 
             $this->assertSame(array('dateselector' => $vals['timestamp']), $el->exportValue($submitvalues),
@@ -165,8 +165,8 @@ class core_form_datetimeselector_testcase extends advanced_testcase {
 
             // Create dateselector element with different timezones.
             $elparams = array('optional'=>false, 'timezone' => $vals['timezone']);
-            $el = new MoodleQuickForm_date_time_selector('dateselector', null, $elparams);
-            $el->_createElements();
+            $el = $this->mform->addElement('date_time_selector', 'dateselector', null, $elparams);
+            $this->assertTrue($el instanceof MoodleQuickForm_date_time_selector);
             $expectedvalues = array(
                 'day' => array($vals['day']),
                 'month' => array($vals['month']),
index 3b7f20f..35900d9 100644 (file)
@@ -41,6 +41,8 @@ require_once($CFG->libdir . '/form/duration.php');
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class core_form_duration_testcase extends basic_testcase {
+    /** @var MoodleQuickForm Keeps reference of dummy form object */
+    private $mform;
     /** @var MoodleQuickForm_duration Keeps reference of MoodleQuickForm_duration object */
     private $element;
 
@@ -49,7 +51,11 @@ class core_form_duration_testcase extends basic_testcase {
      */
     protected function setUp() {
         parent::setUp();
-        $this->element = new MoodleQuickForm_duration();
+
+        // Get form data.
+        $form = new temp_form_duration();
+        $this->mform = $form->getform();
+        $this->element = $this->mform->addElement('duration', 'duration');
     }
 
     /**
@@ -67,7 +73,7 @@ class core_form_duration_testcase extends basic_testcase {
      */
     public function test_constructor() {
         // Test trying to create with an invalid unit.
-        $this->element = new MoodleQuickForm_duration('testel', null, array('defaultunit' => 123));
+        $this->element = $this->mform->addElement('duration', 'testel', null, array('defaultunit' => 123, 'optional' => false));
     }
 
     /**
@@ -94,7 +100,7 @@ class core_form_duration_testcase extends basic_testcase {
         $this->assertEquals(array(1, 86400), $this->element->seconds_to_unit(86400));
         $this->assertEquals(array(25, 3600), $this->element->seconds_to_unit(90000));
 
-        $this->element = new MoodleQuickForm_duration('testel', null, array('defaultunit' => 86400));
+        $this->element = $this->mform->addElement('duration', 'testel', null, array('defaultunit' => 86400, 'optional' => false));
         $this->assertEquals(array(0, 86400), $this->element->seconds_to_unit(0)); // Zero minutes, for a nice default unit.
     }
 
@@ -102,8 +108,7 @@ class core_form_duration_testcase extends basic_testcase {
      * Testcase to check generated timestamp
      */
     public function test_exportValue() {
-        $el = new MoodleQuickForm_duration('testel');
-        $el->_createElements();
+        $el = $this->mform->addElement('duration', 'testel');
         $values = array('testel' => array('number' => 10, 'timeunit' => 1));
         $this->assertEquals(array('testel' => 10), $el->exportValue($values));
         $values = array('testel' => array('number' => 3, 'timeunit' => 60));
@@ -117,11 +122,32 @@ class core_form_duration_testcase extends basic_testcase {
         $values = array('testel' => array('number' => 0, 'timeunit' => 3600));
         $this->assertEquals(array('testel' => 0), $el->exportValue($values));
 
-        $el = new MoodleQuickForm_duration('testel', null, array('optional' => true));
-        $el->_createElements();
+        $el = $this->mform->addElement('duration', 'testel', null, array('optional' => true));
         $values = array('testel' => array('number' => 10, 'timeunit' => 1));
         $this->assertEquals(array('testel' => 0), $el->exportValue($values));
         $values = array('testel' => array('number' => 20, 'timeunit' => 1, 'enabled' => 1));
         $this->assertEquals(array('testel' => 20), $el->exportValue($values));
     }
 }
+
+/**
+ * Form object to be used in test case.
+ */
+class temp_form_duration extends moodleform {
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        // No definition required.
+    }
+    /**
+     * Returns form reference
+     * @return MoodleQuickForm
+     */
+    public function getform() {
+        $mform = $this->_form;
+        // Set submitted flag, to simulate submission.
+        $mform->_flagSubmitted = true;
+        return $mform;
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Compat/JsonSerializable.php b/lib/maxmind/GeoIp2/Compat/JsonSerializable.php
new file mode 100644 (file)
index 0000000..4846ce7
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace GeoIp2\Compat;
+
+// @codingStandardsIgnoreFile
+
+/**
+  * This interface exists to provide backwards compatibility with PHP 5.3
+  *
+  * This should _not_ be used by any third-party code.
+  *
+  * @ignore
+  */
+if (interface_exists('JsonSerializable')) {
+    interface JsonSerializable extends \JsonSerializable
+    {
+    }
+} else {
+    interface JsonSerializable
+    {
+        /**
+         * Returns data that can be serialized by json_encode
+         * @ignore
+         */
+        public function jsonSerialize();
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Database/Reader.php b/lib/maxmind/GeoIp2/Database/Reader.php
new file mode 100644 (file)
index 0000000..b19b5f1
--- /dev/null
@@ -0,0 +1,246 @@
+<?php
+
+namespace GeoIp2\Database;
+
+use GeoIp2\Exception\AddressNotFoundException;
+use GeoIp2\ProviderInterface;
+use MaxMind\Db\Reader as DbReader;
+
+/**
+ * Instances of this class provide a reader for the GeoIP2 database format.
+ * IP addresses can be looked up using the database specific methods.
+ *
+ * ## Usage ##
+ *
+ * The basic API for this class is the same for every database. First, you
+ * create a reader object, specifying a file name. You then call the method
+ * corresponding to the specific database, passing it the IP address you want
+ * to look up.
+ *
+ * If the request succeeds, the method call will return a model class for
+ * the method you called. This model in turn contains multiple record classes,
+ * each of which represents part of the data returned by the database. If
+ * the database does not contain the requested information, the attributes
+ * on the record class will have a `null` value.
+ *
+ * If the address is not in the database, an
+ * {@link \GeoIp2\Exception\AddressNotFoundException} exception will be
+ * thrown. If an invalid IP address is passed to one of the methods, a
+ * SPL {@link \InvalidArgumentException} will be thrown. If the database is
+ * corrupt or invalid, a {@link \MaxMind\Db\Reader\InvalidDatabaseException}
+ * will be thrown.
+ *
+ */
+class Reader implements ProviderInterface
+{
+    private $dbReader;
+    private $locales;
+
+    /**
+     * Constructor.
+     *
+     * @param string $filename The path to the GeoIP2 database file.
+     * @param array $locales  List of locale codes to use in name property
+     * from most preferred to least preferred.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *          is corrupt or invalid
+     */
+    public function __construct(
+        $filename,
+        $locales = array('en')
+    ) {
+        $this->dbReader = new DbReader($filename);
+        $this->locales = $locales;
+    }
+
+    /**
+     * This method returns a GeoIP2 City model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\City
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function city($ipAddress)
+    {
+        return $this->modelFor('City', 'City', $ipAddress);
+    }
+
+    /**
+     * This method returns a GeoIP2 Country model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\Country
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function country($ipAddress)
+    {
+        return $this->modelFor('Country', 'Country', $ipAddress);
+    }
+
+    /**
+     * This method returns a GeoIP2 Anonymous IP model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\AnonymousIp
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function anonymousIp($ipAddress)
+    {
+        return $this->flatModelFor(
+            'AnonymousIp',
+            'GeoIP2-Anonymous-IP',
+            $ipAddress
+        );
+    }
+
+    /**
+     * This method returns a GeoIP2 Connection Type model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\ConnectionType
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function connectionType($ipAddress)
+    {
+        return $this->flatModelFor(
+            'ConnectionType',
+            'GeoIP2-Connection-Type',
+            $ipAddress
+        );
+    }
+
+    /**
+     * This method returns a GeoIP2 Domain model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\Domain
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function domain($ipAddress)
+    {
+        return $this->flatModelFor(
+            'Domain',
+            'GeoIP2-Domain',
+            $ipAddress
+        );
+    }
+
+    /**
+     * This method returns a GeoIP2 Enterprise model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\Enterprise
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function enterprise($ipAddress)
+    {
+        return $this->modelFor('Enterprise', 'Enterprise', $ipAddress);
+    }
+
+    /**
+     * This method returns a GeoIP2 ISP model.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string.
+     *
+     * @return \GeoIp2\Model\Isp
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address is
+     *         not in the database.
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
+     *         is corrupt or invalid
+     */
+    public function isp($ipAddress)
+    {
+        return $this->flatModelFor(
+            'Isp',
+            'GeoIP2-ISP',
+            $ipAddress
+        );
+    }
+
+    private function modelFor($class, $type, $ipAddress)
+    {
+        $record = $this->getRecord($class, $type, $ipAddress);
+
+        $record['traits']['ip_address'] = $ipAddress;
+        $class = "GeoIp2\\Model\\" . $class;
+
+        return new $class($record, $this->locales);
+    }
+
+    private function flatModelFor($class, $type, $ipAddress)
+    {
+        $record = $this->getRecord($class, $type, $ipAddress);
+
+        $record['ip_address'] = $ipAddress;
+        $class = "GeoIp2\\Model\\" . $class;
+
+        return new $class($record);
+    }
+
+    private function getRecord($class, $type, $ipAddress)
+    {
+        if (strpos($this->metadata()->databaseType, $type) === false) {
+            $method = lcfirst($class);
+            throw new \BadMethodCallException(
+                "The $method method cannot be used to open a "
+                . $this->metadata()->databaseType . " database"
+            );
+        }
+        $record = $this->dbReader->get($ipAddress);
+        if ($record === null) {
+            throw new AddressNotFoundException(
+                "The address $ipAddress is not in the database."
+            );
+        }
+        return $record;
+    }
+
+    /**
+     * @throws \InvalidArgumentException if arguments are passed to the method.
+     * @throws \BadMethodCallException if the database has been closed.
+     * @return \MaxMind\Db\Reader\Metadata object for the database.
+     */
+    public function metadata()
+    {
+        return $this->dbReader->metadata();
+    }
+
+    /**
+     * Closes the GeoIP2 database and returns the resources to the system.
+     */
+    public function close()
+    {
+        $this->dbReader->close();
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Exception/AddressNotFoundException.php b/lib/maxmind/GeoIp2/Exception/AddressNotFoundException.php
new file mode 100644 (file)
index 0000000..d548338
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ * This class represents a generic error.
+ */
+class AddressNotFoundException extends GeoIp2Exception
+{
+}
diff --git a/lib/maxmind/GeoIp2/Exception/AuthenticationException.php b/lib/maxmind/GeoIp2/Exception/AuthenticationException.php
new file mode 100644 (file)
index 0000000..2a8b592
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ * This class represents a generic error.
+ */
+class AuthenticationException extends GeoIp2Exception
+{
+}
diff --git a/lib/maxmind/GeoIp2/Exception/GeoIp2Exception.php b/lib/maxmind/GeoIp2/Exception/GeoIp2Exception.php
new file mode 100644 (file)
index 0000000..7c4d745
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ * This class represents a generic error.
+ */
+class GeoIp2Exception extends \Exception
+{
+}
diff --git a/lib/maxmind/GeoIp2/Exception/HttpException.php b/lib/maxmind/GeoIp2/Exception/HttpException.php
new file mode 100644 (file)
index 0000000..931fd46
--- /dev/null
@@ -0,0 +1,25 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ *  This class represents an HTTP transport error.
+ */
+
+class HttpException extends GeoIp2Exception
+{
+    /**
+     * The URI queried
+     */
+    public $uri;
+
+    public function __construct(
+        $message,
+        $httpStatus,
+        $uri,
+        \Exception $previous = null
+    ) {
+        $this->uri = $uri;
+        parent::__construct($message, $httpStatus, $previous);
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Exception/InvalidRequestException.php b/lib/maxmind/GeoIp2/Exception/InvalidRequestException.php
new file mode 100644 (file)
index 0000000..6712d73
--- /dev/null
@@ -0,0 +1,26 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ * This class represents an error returned by MaxMind's GeoIP2
+ * web service.
+ */
+class InvalidRequestException extends HttpException
+{
+    /**
+     * The code returned by the MaxMind web service
+     */
+    public $error;
+
+    public function __construct(
+        $message,
+        $error,
+        $httpStatus,
+        $uri,
+        \Exception $previous = null
+    ) {
+        $this->error = $error;
+        parent::__construct($message, $httpStatus, $uri, $previous);
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Exception/OutOfQueriesException.php b/lib/maxmind/GeoIp2/Exception/OutOfQueriesException.php
new file mode 100644 (file)
index 0000000..87a6ade
--- /dev/null
@@ -0,0 +1,10 @@
+<?php
+
+namespace GeoIp2\Exception;
+
+/**
+ * This class represents a generic error.
+ */
+class OutOfQueriesException extends GeoIp2Exception
+{
+}
diff --git a/lib/maxmind/GeoIp2/Model/AbstractModel.php b/lib/maxmind/GeoIp2/Model/AbstractModel.php
new file mode 100644 (file)
index 0000000..c242666
--- /dev/null
@@ -0,0 +1,62 @@
+<?php
+
+namespace GeoIp2\Model;
+
+use GeoIp2\Compat\JsonSerializable;
+
+/**
+ * @ignore
+ */
+abstract class AbstractModel implements JsonSerializable
+{
+    protected $raw;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw)
+    {
+        $this->raw = $raw;
+    }
+
+    /**
+     * @ignore
+     */
+    protected function get($field)
+    {
+        if (isset($this->raw[$field])) {
+            return $this->raw[$field];
+        } else {
+            if (preg_match('/^is_/', $field)) {
+                return false;
+            } else {
+                return null;
+            }
+        }
+    }
+
+    /**
+     * @ignore
+     */
+    public function __get($attr)
+    {
+        if ($attr != "instance" && property_exists($this, $attr)) {
+            return $this->$attr;
+        }
+
+        throw new \RuntimeException("Unknown attribute: $attr");
+    }
+
+    /**
+     * @ignore
+     */
+    public function __isset($attr)
+    {
+        return $attr != "instance" && isset($this->$attr);
+    }
+
+    public function jsonSerialize()
+    {
+        return $this->raw;
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/AnonymousIp.php b/lib/maxmind/GeoIp2/Model/AnonymousIp.php
new file mode 100644 (file)
index 0000000..90e7706
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * This class provides the GeoIP2 Anonymous IP model.
+ *
+ * @property-read boolean $isAnonymous This is true if the IP address belongs to
+ *     any sort of anonymous network.
+ *
+ * @property-read boolean $isAnonymousVpn This is true if the IP address belongs to
+ *     an anonymous VPN system.
+ *
+ * @property-read boolean $isHostingProvider This is true if the IP address belongs
+ *     to a hosting provider.
+ *
+ * @property-read boolean $isPublicProxy This is true if the IP address belongs to
+ *     a public proxy.
+ *
+ * @property-read boolean $isTorExitNode This is true if the IP address is a Tor
+ *     exit node.
+ *
+ * @property-read string $ipAddress The IP address that the data in the model is
+ *     for.
+ *
+ */
+class AnonymousIp extends AbstractModel
+{
+    protected $isAnonymous;
+    protected $isAnonymousVpn;
+    protected $isHostingProvider;
+    protected $isPublicProxy;
+    protected $isTorExitNode;
+    protected $ipAddress;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw)
+    {
+        parent::__construct($raw);
+
+        $this->isAnonymous = $this->get('is_anonymous');
+        $this->isAnonymousVpn = $this->get('is_anonymous_vpn');
+        $this->isHostingProvider = $this->get('is_hosting_provider');
+        $this->isPublicProxy = $this->get('is_public_proxy');
+        $this->isTorExitNode = $this->get('is_tor_exit_node');
+        $this->ipAddress = $this->get('ip_address');
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/City.php b/lib/maxmind/GeoIp2/Model/City.php
new file mode 100644 (file)
index 0000000..fb7ddda
--- /dev/null
@@ -0,0 +1,122 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * Model class for the data returned by GeoIP2 City web service and database.
+ *
+ * The only difference between the City and Insights model classes is which
+ * fields in each record may be populated. See
+ * http://dev.maxmind.com/geoip/geoip2/web-services more details.
+ *
+ * @property-read \GeoIp2\Record\City $city City data for the requested IP
+ * address.
+ *
+ * @property-read \GeoIp2\Record\Continent $continent Continent data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\Country $country Country data for the requested
+ * IP address. This object represents the country where MaxMind believes the
+ * end user is located.
+ *
+ * @property-read \GeoIp2\Record\Location $location Location data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\Postal $postal Postal data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
+ * account.
+ *
+ * @property-read \GeoIp2\Record\Country $registeredCountry Registered country
+ * data for the requested IP address. This record represents the country
+ * where the ISP has registered a given IP block and may differ from the
+ * user's country.
+ *
+ * @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
+ * Represented country data for the requested IP address. The represented
+ * country is used for things like military bases. It is only present when
+ * the represented country differs from the country.
+ *
+ * @property-read array $subdivisions An array of {@link \GeoIp2\Record\Subdivision}
+ * objects representing the country subdivisions for the requested IP
+ * address. The number and type of subdivisions varies by country, but a
+ * subdivision is typically a state, province, county, etc. Subdivisions
+ * are ordered from most general (largest) to most specific (smallest).
+ * If the response did not contain any subdivisions, this method returns
+ * an empty array.
+ *
+ * @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An  object
+ * representing the most specific subdivision returned. If the response
+ * did not contain any subdivisions, this method returns an empty
+ * {@link \GeoIp2\Record\Subdivision} object.
+ *
+ * @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
+ * requested IP address.
+ */
+class City extends Country
+{
+    /**
+     * @ignore
+     */
+    protected $city;
+    /**
+     * @ignore
+     */
+    protected $location;
+    /**
+     * @ignore
+     */
+    protected $postal;
+    /**
+     * @ignore
+     */
+    protected $subdivisions = array();
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw, $locales = array('en'))
+    {
+        parent::__construct($raw, $locales);
+
+        $this->city = new \GeoIp2\Record\City($this->get('city'), $locales);
+        $this->location = new \GeoIp2\Record\Location($this->get('location'));
+        $this->postal = new \GeoIp2\Record\Postal($this->get('postal'));
+
+        $this->createSubdivisions($raw, $locales);
+    }
+
+    private function createSubdivisions($raw, $locales)
+    {
+        if (!isset($raw['subdivisions'])) {
+            return;
+        }
+
+        foreach ($raw['subdivisions'] as $sub) {
+            array_push(
+                $this->subdivisions,
+                new \GeoIp2\Record\Subdivision($sub, $locales)
+            );
+        }
+    }
+
+    /**
+     * @ignore
+     */
+    public function __get($attr)
+    {
+        if ($attr == 'mostSpecificSubdivision') {
+            return $this->$attr();
+        } else {
+            return parent::__get($attr);
+        }
+    }
+
+    private function mostSpecificSubdivision()
+    {
+        return empty($this->subdivisions) ?
+            new \GeoIp2\Record\Subdivision(array(), $this->locales) :
+            end($this->subdivisions);
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/ConnectionType.php b/lib/maxmind/GeoIp2/Model/ConnectionType.php
new file mode 100644 (file)
index 0000000..dccd500
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * This class provides the GeoIP2 Connection-Type model.
+ *
+ * @property-read string|null $connectionType The connection type may take the
+ *     following values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
+ *     Additional values may be added in the future.
+ *
+ * @property-read string $ipAddress The IP address that the data in the model is
+ *     for.
+ *
+ */
+class ConnectionType extends AbstractModel
+{
+    protected $connectionType;
+    protected $ipAddress;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw)
+    {
+        parent::__construct($raw);
+
+        $this->connectionType = $this->get('connection_type');
+        $this->ipAddress = $this->get('ip_address');
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/Country.php b/lib/maxmind/GeoIp2/Model/Country.php
new file mode 100644 (file)
index 0000000..05cdea7
--- /dev/null
@@ -0,0 +1,73 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * Model class for the data returned by GeoIP2 Country web service and database.
+ *
+ * The only difference between the City and Insights model classes is which
+ * fields in each record may be populated. See
+ * http://dev.maxmind.com/geoip/geoip2/web-services more details.
+ *
+ * @property-read \GeoIp2\Record\Continent $continent Continent data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\Country $country Country data for the requested
+ * IP address. This object represents the country where MaxMind believes the
+ * end user is located.
+ *
+ * @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
+ * account.
+ *
+ * @property-read \GeoIp2\Record\Country $registeredCountry Registered country
+ * data for the requested IP address. This record represents the country
+ * where the ISP has registered a given IP block and may differ from the
+ * user's country.
+ *
+ * @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
+ * Represented country data for the requested IP address. The represented
+ * country is used for things like military bases. It is only present when
+ * the represented country differs from the country.
+ *
+ * @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
+ * requested IP address.
+ */
+class Country extends AbstractModel
+{
+    protected $continent;
+    protected $country;
+    protected $locales;
+    protected $maxmind;
+    protected $registeredCountry;
+    protected $representedCountry;
+    protected $traits;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw, $locales = array('en'))
+    {
+        parent::__construct($raw);
+
+        $this->continent = new \GeoIp2\Record\Continent(
+            $this->get('continent'),
+            $locales
+        );
+        $this->country = new \GeoIp2\Record\Country(
+            $this->get('country'),
+            $locales
+        );
+        $this->maxmind = new \GeoIp2\Record\MaxMind($this->get('maxmind'));
+        $this->registeredCountry = new \GeoIp2\Record\Country(
+            $this->get('registered_country'),
+            $locales
+        );
+        $this->representedCountry = new \GeoIp2\Record\RepresentedCountry(
+            $this->get('represented_country'),
+            $locales
+        );
+        $this->traits = new \GeoIp2\Record\Traits($this->get('traits'));
+
+        $this->locales = $locales;
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/Domain.php b/lib/maxmind/GeoIp2/Model/Domain.php
new file mode 100644 (file)
index 0000000..c540644
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * This class provides the GeoIP2 Domain model.
+ *
+ * @property-read string|null $domain The second level domain associated with the
+ *     IP address. This will be something like "example.com" or
+ *     "example.co.uk", not "foo.example.com".
+ *
+ * @property-read string $ipAddress The IP address that the data in the model is
+ *     for.
+ *
+ */
+class Domain extends AbstractModel
+{
+    protected $domain;
+    protected $ipAddress;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw)
+    {
+        parent::__construct($raw);
+
+        $this->domain = $this->get('domain');
+        $this->ipAddress = $this->get('ip_address');
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Model/Enterprise.php b/lib/maxmind/GeoIp2/Model/Enterprise.php
new file mode 100644 (file)
index 0000000..12d45bd
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * Model class for the data returned by GeoIP2 Enterprise database lookups.
+ *
+ * The only difference between the City and Enterprise model classes is which
+ * fields in each record may be populated. See
+ * http://dev.maxmind.com/geoip/geoip2/web-services more details.
+ *
+ * @property-read \GeoIp2\Record\City $city City data for the requested IP
+ * address.
+ *
+ * @property-read \GeoIp2\Record\Continent $continent Continent data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\Country $country Country data for the requested
+ * IP address. This object represents the country where MaxMind believes the
+ * end user is located.
+ *
+ * @property-read \GeoIp2\Record\Location $location Location data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
+ * account.
+ *
+ * @property-read \GeoIp2\Record\Country $registeredCountry Registered country
+ * data for the requested IP address. This record represents the country
+ * where the ISP has registered a given IP block and may differ from the
+ * user's country.
+ *
+ * @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
+ * Represented country data for the requested IP address. The represented
+ * country is used for things like military bases. It is only present when
+ * the represented country differs from the country.
+ *
+ * @property-read array $subdivisions An array of {@link \GeoIp2\Record\Subdivision}
+ * objects representing the country subdivisions for the requested IP
+ * address. The number and type of subdivisions varies by country, but a
+ * subdivision is typically a state, province, county, etc. Subdivisions
+ * are ordered from most general (largest) to most specific (smallest).
+ * If the response did not contain any subdivisions, this method returns
+ * an empty array.
+ *
+ * @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An  object
+ * representing the most specific subdivision returned. If the response
+ * did not contain any subdivisions, this method returns an empty
+ * {@link \GeoIp2\Record\Subdivision} object.
+ *
+ * @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
+ * requested IP address.
+ */
+class Enterprise extends City
+{
+}
diff --git a/lib/maxmind/GeoIp2/Model/Insights.php b/lib/maxmind/GeoIp2/Model/Insights.php
new file mode 100644 (file)
index 0000000..7c0c9e1
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * Model class for the data returned by GeoIP2 Precision: Insights web service.
+ *
+ * The only difference between the City and Insights model classes is which
+ * fields in each record may be populated. See
+ * http://dev.maxmind.com/geoip/geoip2/web-services more details.
+ *
+ * @property-read \GeoIp2\Record\City $city City data for the requested IP
+ * address.
+ *
+ * @property-read \GeoIp2\Record\Continent $continent Continent data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\Country $country Country data for the requested
+ * IP address. This object represents the country where MaxMind believes the
+ * end user is located.
+ *
+ * @property-read \GeoIp2\Record\Location $location Location data for the
+ * requested IP address.
+ *
+ * @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
+ * account.
+ *
+ * @property-read \GeoIp2\Record\Country $registeredCountry Registered country
+ * data for the requested IP address. This record represents the country
+ * where the ISP has registered a given IP block and may differ from the
+ * user's country.
+ *
+ * @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
+ * Represented country data for the requested IP address. The represented
+ * country is used for things like military bases. It is only present when
+ * the represented country differs from the country.
+ *
+ * @property-read array $subdivisions An array of {@link \GeoIp2\Record\Subdivision}
+ * objects representing the country subdivisions for the requested IP
+ * address. The number and type of subdivisions varies by country, but a
+ * subdivision is typically a state, province, county, etc. Subdivisions
+ * are ordered from most general (largest) to most specific (smallest).
+ * If the response did not contain any subdivisions, this method returns
+ * an empty array.
+ *
+ * @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An  object
+ * representing the most specific subdivision returned. If the response
+ * did not contain any subdivisions, this method returns an empty
+ * {@link \GeoIp2\Record\Subdivision} object.
+ *
+ * @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
+ * requested IP address.
+ */
+class Insights extends City
+{
+}
diff --git a/lib/maxmind/GeoIp2/Model/Isp.php b/lib/maxmind/GeoIp2/Model/Isp.php
new file mode 100644 (file)
index 0000000..c2dd357
--- /dev/null
@@ -0,0 +1,47 @@
+<?php
+
+namespace GeoIp2\Model;
+
+/**
+ * This class provides the GeoIP2 Connection-Type model.
+ *
+ * @property-read integer|null $autonomousSystemNumber The autonomous system number
+ *     associated with the IP address.
+ *
+ * @property-read string|null $autonomousSystemOrganization The organization
+ *     associated with the registered autonomous system number for the IP
+ *     address.
+ *
+ * @property-read string|null $isp The name of the ISP associated with the IP
+ *     address.
+ *
+ * @property-read string|null $organization The name of the organization associated
+ *     with the IP address.
+ *
+ * @property-read string $ipAddress The IP address that the data in the model is
+ *     for.
+ *
+ */
+class Isp extends AbstractModel
+{
+    protected $autonomousSystemNumber;
+    protected $autonomousSystemOrganization;
+    protected $isp;
+    protected $organization;
+    protected $ipAddress;
+
+    /**
+     * @ignore
+     */
+    public function __construct($raw)
+    {
+        parent::__construct($raw);
+        $this->autonomousSystemNumber = $this->get('autonomous_system_number');
+        $this->autonomousSystemOrganization =
+            $this->get('autonomous_system_organization');
+        $this->isp = $this->get('isp');
+        $this->organization = $this->get('organization');
+
+        $this->ipAddress = $this->get('ip_address');
+    }
+}
diff --git a/lib/maxmind/GeoIp2/ProviderInterface.php b/lib/maxmind/GeoIp2/ProviderInterface.php
new file mode 100644 (file)
index 0000000..6c3992f
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+
+namespace GeoIp2;
+
+interface ProviderInterface
+{
+    /**
+     * @param ipAddress
+     *            IPv4 or IPv6 address to lookup.
+     * @return \GeoIp2\Model\Country A Country model for the requested IP address.
+     */
+    public function country($ipAddress);
+
+    /**
+     * @param ipAddress
+     *            IPv4 or IPv6 address to lookup.
+     * @return \GeoIp2\Model\City A City model for the requested IP address.
+     */
+    public function city($ipAddress);
+}
diff --git a/lib/maxmind/GeoIp2/Record/AbstractPlaceRecord.php b/lib/maxmind/GeoIp2/Record/AbstractPlaceRecord.php
new file mode 100644 (file)
index 0000000..0b7e7d6
--- /dev/null
@@ -0,0 +1,38 @@
+<?php
+
+namespace GeoIp2\Record;
+
+abstract class AbstractPlaceRecord extends AbstractRecord
+{
+    private $locales;
+
+    /**
+     * @ignore
+     */
+    public function __construct($record, $locales = array('en'))
+    {
+        $this->locales = $locales;
+        parent::__construct($record);
+    }
+
+    /**
+     * @ignore
+     */
+    public function __get($attr)
+    {
+        if ($attr == 'name') {
+            return $this->name();
+        } else {
+            return parent::__get($attr);
+        }
+    }
+
+    private function name()
+    {
+        foreach ($this->locales as $locale) {
+            if (isset($this->names[$locale])) {
+                return $this->names[$locale];
+            }
+        }
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Record/AbstractRecord.php b/lib/maxmind/GeoIp2/Record/AbstractRecord.php
new file mode 100644 (file)
index 0000000..7bb1c5b
--- /dev/null
@@ -0,0 +1,60 @@
+<?php
+
+namespace GeoIp2\Record;
+
+use GeoIp2\Compat\JsonSerializable;
+
+abstract class AbstractRecord implements JsonSerializable
+{
+    private $record;
+
+    /**
+     * @ignore
+     */
+    public function __construct($record)
+    {
+        $this->record = isset($record) ? $record : array();
+    }
+
+    /**
+     * @ignore
+     */
+    public function __get($attr)
+    {
+        // XXX - kind of ugly but greatly reduces boilerplate code
+        $key = $this->attributeToKey($attr);
+
+        if ($this->__isset($attr)) {
+            return $this->record[$key];
+        } elseif ($this->validAttribute($attr)) {
+            if (preg_match('/^is_/', $key)) {
+                return false;
+            } else {
+                return null;
+            }
+        } else {
+            throw new \RuntimeException("Unknown attribute: $attr");
+        }
+    }
+
+    public function __isset($attr)
+    {
+        return $this->validAttribute($attr) &&
+             isset($this->record[$this->attributeToKey($attr)]);
+    }
+
+    private function attributeToKey($attr)
+    {
+        return strtolower(preg_replace('/([A-Z])/', '_\1', $attr));
+    }
+
+    private function validAttribute($attr)
+    {
+        return in_array($attr, $this->validAttributes);
+    }
+
+    public function jsonSerialize()
+    {
+        return $this->record;
+    }
+}
diff --git a/lib/maxmind/GeoIp2/Record/City.php b/lib/maxmind/GeoIp2/Record/City.php
new file mode 100644 (file)
index 0000000..9f1560f
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * City-level data associated with an IP address.
+ *
+ * This record is returned by all location services and databases besides
+ * Country.
+ *
+ * @property-read int|null $confidence A value from 0-100 indicating MaxMind's
+ * confidence that the city is correct. This attribute is only available
+ * from the Insights service and the GeoIP2 Enterprise database.
+ *
+ * @property-read int|null $geonameId The GeoName ID for the city. This attribute
+ * is returned by all location services and databases.
+ *
+ * @property-read string|null $name The name of the city based on the locales list
+ * passed to the constructor. This attribute is returned by all location
+ * services and databases.
+ *
+ * @property-read array|null $names A array map where the keys are locale codes
+ * and the values are names. This attribute is returned by all location
+ * services and databases.
+ */
+class City extends AbstractPlaceRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array('confidence', 'geonameId', 'names');
+}
diff --git a/lib/maxmind/GeoIp2/Record/Continent.php b/lib/maxmind/GeoIp2/Record/Continent.php
new file mode 100644 (file)
index 0000000..4893e9e
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data for the continent record associated with an IP address
+ *
+ * This record is returned by all location services and databases.
+ *
+ * @property-read string|null $code A two character continent code like "NA" (North
+ * America) or "OC" (Oceania). This attribute is returned by all location
+ * services and databases.
+ *
+ * @property-read int|null $geonameId The GeoName ID for the continent. This
+ * attribute is returned by all location services and databases.
+ *
+ * @property-read string|null $name Returns the name of the continent based on the
+ * locales list passed to the constructor. This attribute is returned by all location
+ * services and databases.
+ *
+ * @property-read array|null $names An array map where the keys are locale codes
+ * and the values are names. This attribute is returned by all location
+ * services and databases.
+ */
+class Continent extends AbstractPlaceRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array(
+        'code',
+        'geonameId',
+        'names'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/Record/Country.php b/lib/maxmind/GeoIp2/Record/Country.php
new file mode 100644 (file)
index 0000000..922195b
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data for the country record associated with an IP address
+ *
+ * This record is returned by all location services and databases.
+ *
+ * @property-read int|null $confidence A value from 0-100 indicating MaxMind's
+ * confidence that the country is correct. This attribute is only available
+ * from the Insights service and the GeoIP2 Enterprise database.
+ *
+ * @property-read int|null $geonameId The GeoName ID for the country. This
+ * attribute is returned by location services and databases.
+ *
+ * @property-read string|null $isoCode The {@link
+ * http://en.wikipedia.org/wiki/ISO_3166-1 two-character ISO 3166-1 alpha
+ * code} for the country. This attribute is returned by all location services
+ * and databases.
+ *
+ * @property-read string|null $name The name of the country based on the locales
+ * list passed to the constructor. This attribute is returned by all location
+ * services and databases.
+ *
+ * @property-read array|null $names An array map where the keys are locale codes
+ * and the values are names. This attribute is returned by all location
+ * services and databases.
+ */
+class Country extends AbstractPlaceRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array(
+        'confidence',
+        'geonameId',
+        'isoCode',
+        'names'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/Record/Location.php b/lib/maxmind/GeoIp2/Record/Location.php
new file mode 100644 (file)
index 0000000..5d0742d
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data for the location record associated with an IP address
+ *
+ * This record is returned by all location services and databases besides
+ * Country.
+ *
+ * @property-read int|null $averageIncome The average income in US dollars
+ * associated with the requested IP address. This attribute is only available
+ * from the Insights service.
+ *
+ * @property-read int|null $accuracyRadius The approximate accuracy radius in
+ * kilometers around the latitude and longitude for the IP address. This is
+ * the radius where we have a 67% confidence that the device using the IP
+ * address resides within the circle centered at the latitude and longitude
+ * with the provided radius.
+ *
+ * @property-read float|null $latitude The approximate latitude of the location
+ * associated with the IP address. This value is not precise and should not be
+ * used to identify a particular address or household.
+ *
+ * @property-read float|null $longitude The approximate longitude of the location
+ * associated with the IP address. This value is not precise and should not be
+ * used to identify a particular address or household.
+ *
+ * @property-read int|null $populationDensity The estimated population per square
+ * kilometer associated with the IP address. This attribute is only available
+ * from the Insights service.
+ *
+ * @property-read int|null $metroCode The metro code of the location if the location
+ * is in the US. MaxMind returns the same metro codes as the
+ * {@link
+ * https://developers.google.com/adwords/api/docs/appendix/cities-DMAregions
+ * Google AdWords API}.
+ *
+ * @property-read string|null $timeZone The time zone associated with location, as
+ * specified by the {@link http://www.iana.org/time-zones IANA Time Zone
+ * Database}, e.g., "America/New_York".
+ */
+class Location extends AbstractRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array(
+        'averageIncome',
+        'accuracyRadius',
+        'latitude',
+        'longitude',
+        'metroCode',
+        'populationDensity',
+        'postalCode',
+        'postalConfidence',
+        'timeZone'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/Record/MaxMind.php b/lib/maxmind/GeoIp2/Record/MaxMind.php
new file mode 100644 (file)
index 0000000..8971e96
--- /dev/null
@@ -0,0 +1,19 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data about your account.
+ *
+ * This record is returned by all location services and databases.
+ *
+ * @property-read int|null $queriesRemaining The number of remaining queries you
+ * have for the service you are calling.
+ */
+class MaxMind extends AbstractRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array('queriesRemaining');
+}
diff --git a/lib/maxmind/GeoIp2/Record/Postal.php b/lib/maxmind/GeoIp2/Record/Postal.php
new file mode 100644 (file)
index 0000000..81d3011
--- /dev/null
@@ -0,0 +1,27 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data for the postal record associated with an IP address
+ *
+ * This record is returned by all location databases and services besides
+ * Country.
+ *
+ * @property-read string|null $code The postal code of the location. Postal codes
+ * are not available for all countries. In some countries, this will only
+ * contain part of the postal code. This attribute is returned by all location
+ * databases and services besides Country.
+ *
+ * @property-read int|null $confidence A value from 0-100 indicating MaxMind's
+ * confidence that the postal code is correct. This attribute is only
+ * available from the Insights service and the GeoIP2 Enterprise
+ * database.
+ */
+class Postal extends AbstractRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array('code', 'confidence');
+}
diff --git a/lib/maxmind/GeoIp2/Record/RepresentedCountry.php b/lib/maxmind/GeoIp2/Record/RepresentedCountry.php
new file mode 100644 (file)
index 0000000..dd49114
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ * Contains data for the represented country associated with an IP address
+ *
+ * This class contains the country-level data associated with an IP address
+ * for the IP's represented country. The represented country is the country
+ * represented by something like a military base.
+ *
+ * @property-read int|null $confidence A value from 0-100 indicating MaxMind's
+ * confidence that the country is correct. This attribute is only available
+ * from the Insights service and the GeoIP2 Enterprise database.
+ *
+ * @property-read int|null $geonameId The GeoName ID for the country.
+ *
+ * @property-read string|null $isoCode The {@link http://en.wikipedia.org/wiki/ISO_3166-1
+ * two-character ISO 3166-1 alpha code} for the country.
+ *
+ * @property-read string|null $name The name of the country based on the locales list
+ * passed to the constructor.
+ *
+ * @property-read array|null $names An array map where the keys are locale codes and
+ * the values are names.
+ *
+ * @property-read string|null $type A string indicating the type of entity that is
+ * representing the country. Currently we only return <code>military</code>
+ * but this could expand to include other types in the future.
+ */
+class RepresentedCountry extends Country
+{
+    protected $validAttributes = array(
+        'confidence',
+        'geonameId',
+        'isoCode',
+        'names',
+        'type'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/Record/Subdivision.php b/lib/maxmind/GeoIp2/Record/Subdivision.php
new file mode 100644 (file)
index 0000000..cb5256a
--- /dev/null
@@ -0,0 +1,45 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ *
+ * Contains data for the subdivisions associated with an IP address
+ *
+ * This record is returned by all location databases and services besides
+ * Country.
+ *
+ * @property-read int|null $confidence This is a value from 0-100 indicating
+ * MaxMind's confidence that the subdivision is correct. This attribute is
+ * only available from the Insights service and the GeoIP2 Enterprise
+ * database.
+ *
+ * @property-read int|null $geonameId This is a GeoName ID for the subdivision.
+ * This attribute is returned by all location databases and services besides
+ * Country.
+ *
+ * @property-read string|null $isoCode This is a string up to three characters long
+ * contain the subdivision portion of the {@link
+ * http://en.wikipedia.org/wiki/ISO_3166-2 ISO 3166-2 code}. This attribute
+ * is returned by all location databases and services except Country.
+ *
+ * @property-read string|null $name The name of the subdivision based on the
+ * locales list passed to the constructor. This attribute is returned by all
+ * location databases and services besides Country.
+ *
+ * @property-read array|null $names An array map where the keys are locale codes
+ * and the values are names. This attribute is returned by all location
+ * databases and services besides Country.
+ */
+class Subdivision extends AbstractPlaceRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array(
+        'confidence',
+        'geonameId',
+        'isoCode',
+        'names'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/Record/Traits.php b/lib/maxmind/GeoIp2/Record/Traits.php
new file mode 100644 (file)
index 0000000..7d8710f
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+
+namespace GeoIp2\Record;
+
+/**
+ *
+ * Contains data for the traits record associated with an IP address
+ *
+ * This record is returned by all location services and databases.
+ *
+ * @property-read int|null $autonomousSystemNumber The {@link
+ * http://en.wikipedia.org/wiki/Autonomous_system_(Internet) autonomous
+ * system number} associated with the IP address. This attribute is only
+ * available from the City and Insights web service and the GeoIP2
+ * Enterprise database.
+ *
+ * @property-read string|null $autonomousSystemOrganization The organization
+ * associated with the registered {@link
+ * http://en.wikipedia.org/wiki/Autonomous_system_(Internet) autonomous
+ * system number} for the IP address. This attribute is only available from
+ * the City and Insights web service and the GeoIP2 Enterprise
+ * database.
+ *
+ * @property-read string|null $connectionType The connection type may take the
+ * following  values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
+ * Additional values may be added in the future. This attribute is only
+ * available in the GeoIP2 Enterprise database.
+ *
+ * @property-read string|null $domain The second level domain associated with the
+ * IP address. This will be something like "example.com" or "example.co.uk",
+ * not "foo.example.com". This attribute is only available from the
+ * City and Insights web service and the GeoIP2 Enterprise
+ * database.
+ *
+ * @property-read string $ipAddress The IP address that the data in the model
+ * is for. If you performed a "me" lookup against the web service, this
+ * will be the externally routable IP address for the system the code is
+ * running on. If the system is behind a NAT, this may differ from the IP
+ * address locally assigned to it. This attribute is returned by all end
+ * points.
+ *
+ * @property-read boolean $isAnonymousProxy *Deprecated.* Please see our {@link
+ * https://www.maxmind.com/en/geoip2-anonymous-ip-database GeoIP2
+ * Anonymous IP database} to determine whether the IP address is used by an
+ * anonymizing service.
+ *
+ * @property-read boolean $isLegitimateProxy This attribute is true if MaxMind
+ * believes this IP address to be a legitimate proxy, such as an internal
+ * VPN used by a corporation. This attribute is only available in the GeoIP2
+ * Enterprise database.
+ *
+ * @property-read boolean $isSatelliteProvider *Deprecated.* Due to the
+ * increased coverage by mobile carriers, very few satellite providers now
+ * serve multiple countries. As a result, the output does not provide
+ * sufficiently relevant data for us to maintain it.
+ *
+ * @property-read string|null $isp The name of the ISP associated with the IP
+ * address. This attribute is only available from the City and Insights web
+ * services and the GeoIP2 Enterprise database.
+ *
+ * @property-read string|null $organization The name of the organization associated
+ * with the IP address. This attribute is only available from the City and
+ * Insights web services and the GeoIP2 Enterprise database.
+ *
+ * @property-read string|null $userType <p>The user type associated with the IP
+ *  address. This can be one of the following values:</p>
+ *  <ul>
+ *    <li>business
+ *    <li>cafe
+ *    <li>cellular
+ *    <li>college
+ *    <li>content_delivery_network
+ *    <li>dialup
+ *    <li>government
+ *    <li>hosting
+ *    <li>library
+ *    <li>military
+ *    <li>residential
+ *    <li>router
+ *    <li>school
+ *    <li>search_engine_spider
+ *    <li>traveler
+ * </ul>
+ * <p>
+ *   This attribute is only available from the Insights web service and the
+ *   GeoIP2 Enterprise database.
+ * </p>
+ */
+class Traits extends AbstractRecord
+{
+    /**
+     * @ignore
+     */
+    protected $validAttributes = array(
+        'autonomousSystemNumber',
+        'autonomousSystemOrganization',
+        'connectionType',
+        'domain',
+        'isAnonymousProxy',
+        'isLegitimateProxy',
+        'isSatelliteProvider',
+        'isp',
+        'ipAddress',
+        'organization',
+        'userType'
+    );
+}
diff --git a/lib/maxmind/GeoIp2/WebService/Client.php b/lib/maxmind/GeoIp2/WebService/Client.php
new file mode 100644 (file)
index 0000000..f42ec81
--- /dev/null
@@ -0,0 +1,242 @@
+<?php
+
+namespace GeoIp2\WebService;
+
+use GeoIp2\Exception\AddressNotFoundException;
+use GeoIp2\Exception\AuthenticationException;
+use GeoIp2\Exception\GeoIp2Exception;
+use GeoIp2\Exception\HttpException;
+use GeoIp2\Exception\InvalidRequestException;
+use GeoIp2\Exception\OutOfQueriesException;
+use GeoIp2\ProviderInterface;
+use MaxMind\Exception\InvalidInputException;
+use MaxMind\WebService\Client as WsClient;
+
+/**
+ * This class provides a client API for all the GeoIP2 Precision web services.
+ * The services are Country, City, and Insights. Each service returns a
+ * different set of data about an IP address, with Country returning the
+ * least data and Insights the most.
+ *
+ * Each web service is represented by a different model class, and these model
+ * classes in turn contain multiple record classes. The record classes have
+ * attributes which contain data about the IP address.
+ *
+ * If the web service does not return a particular piece of data for an IP
+ * address, the associated attribute is not populated.
+ *
+ * The web service may not return any information for an entire record, in
+ * which case all of the attributes for that record class will be empty.
+ *
+ * ## Usage ##
+ *
+ * The basic API for this class is the same for all of the web service end
+ * points. First you create a web service object with your MaxMind `$userId`
+ * and `$licenseKey`, then you call the method corresponding to a specific end
+ * point, passing it the IP address you want to look up.
+ *
+ * If the request succeeds, the method call will return a model class for
+ * the service you called. This model in turn contains multiple record
+ * classes, each of which represents part of the data returned by the web
+ * service.
+ *
+ * If the request fails, the client class throws an exception.
+ */
+class Client implements ProviderInterface
+{
+    private $locales;
+    private $client;
+    private static $basePath = '/geoip/v2.1';
+
+    const VERSION = 'v2.4.2';
+
+    /**
+     * Constructor.
+     *
+     * @param int $userId     Your MaxMind user ID
+     * @param string $licenseKey Your MaxMind license key
+     * @param array $locales  List of locale codes to use in name property
+     * from most preferred to least preferred.
+     * @param array $options Array of options. Valid options include:
+     *      * `host` - The host to use when querying the web service.
+     *      * `timeout` - Timeout in seconds.
+     *      * `connectTimeout` - Initial connection timeout in seconds.
+     *      * `proxy` - The HTTP proxy to use. May include a schema, port,
+     *        username, and password, e.g.,
+     *        `http://username:password@127.0.0.1:10`.
+     */
+    public function __construct(
+        $userId,
+        $licenseKey,
+        $locales = array('en'),
+        $options = array()
+    ) {
+        $this->locales = $locales;
+
+        // This is for backwards compatibility. Do not remove except for a
+        // major version bump.
+        if (is_string($options)) {
+            $options = array( 'host' => $options );
+        }
+
+        if (!isset($options['host'])) {
+            $options['host'] = 'geoip.maxmind.com';
+        }
+
+        $options['userAgent'] = $this->userAgent();
+
+        $this->client = new WsClient($userId, $licenseKey, $options);
+    }
+
+    private function userAgent()
+    {
+        return 'GeoIP2-API/' . Client::VERSION;
+    }
+
+    /**
+     * This method calls the GeoIP2 Precision: City service.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string. If no
+     * address is provided, the address that the web service is called
+     * from will be used.
+     *
+     * @return \GeoIp2\Model\City
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address you
+     *   provided is not in our database (e.g., a private address).
+     * @throws \GeoIp2\Exception\AuthenticationException if there is a problem
+     *   with the user ID or license key that you provided.
+     * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out
+     *   of queries.
+     * @throws \GeoIp2\Exception\InvalidRequestException} if your request was
+     *   received by the web service but is invalid for some other reason.
+     *   This may indicate an issue with this API. Please report the error to
+     *   MaxMind.
+     * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error
+     *   code or message was returned. This could indicate a problem with the
+     *   connection between your server and the web service or that the web
+     *   service returned an invalid document or 500 error code.
+     * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent
+     *   class to the above exceptions. It will be thrown directly if a 200
+     *   status code is returned but the body is invalid.
+     */
+    public function city($ipAddress = 'me')
+    {
+        return $this->responseFor('city', 'City', $ipAddress);
+    }
+
+    /**
+     * This method calls the GeoIP2 Precision: Country service.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string. If no
+     * address is provided, the address that the web service is called
+     * from will be used.
+     *
+     * @return \GeoIp2\Model\Country
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address you
+     *   provided is not in our database (e.g., a private address).
+     * @throws \GeoIp2\Exception\AuthenticationException if there is a problem
+     *   with the user ID or license key that you provided.
+     * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out
+     *   of queries.
+     * @throws \GeoIp2\Exception\InvalidRequestException} if your request was
+     *   received by the web service but is invalid for some other reason.
+     *   This may indicate an issue with this API. Please report the error to
+     *   MaxMind.
+     * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error
+     *   code or message was returned. This could indicate a problem with the
+     *   connection between your server and the web service or that the web
+     *   service returned an invalid document or 500 error code.
+     * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent
+     *   class to the above exceptions. It will be thrown directly if a 200
+     *   status code is returned but the body is invalid.
+     */
+    public function country($ipAddress = 'me')
+    {
+        return $this->responseFor('country', 'Country', $ipAddress);
+    }
+
+    /**
+     * This method calls the GeoIP2 Precision: Insights service.
+     *
+     * @param string $ipAddress IPv4 or IPv6 address as a string. If no
+     * address is provided, the address that the web service is called
+     * from will be used.
+     *
+     * @return \GeoIp2\Model\Insights
+     *
+     * @throws \GeoIp2\Exception\AddressNotFoundException if the address you
+     *   provided is not in our database (e.g., a private address).
+     * @throws \GeoIp2\Exception\AuthenticationException if there is a problem
+     *   with the user ID or license key that you provided.
+     * @throws \GeoIp2\Exception\OutOfQueriesException if your account is out
+     *   of queries.
+     * @throws \GeoIp2\Exception\InvalidRequestException} if your request was
+     *   received by the web service but is invalid for some other reason.
+     *   This may indicate an issue with this API. Please report the error to
+     *   MaxMind.
+     * @throws \GeoIp2\Exception\HttpException if an unexpected HTTP error
+     *   code or message was returned. This could indicate a problem with the
+     *   connection between your server and the web service or that the web
+     *   service returned an invalid document or 500 error code.
+     * @throws \GeoIp2\Exception\GeoIp2Exception This serves as the parent
+     *   class to the above exceptions. It will be thrown directly if a 200
+     *   status code is returned but the body is invalid.
+     */
+    public function insights($ipAddress = 'me')
+    {
+        return $this->responseFor('insights', 'Insights', $ipAddress);
+    }
+
+    private function responseFor($endpoint, $class, $ipAddress)
+    {
+        $path = implode('/', array(self::$basePath, $endpoint, $ipAddress));
+
+        try {
+            $body = $this->client->get('GeoIP2 ' . $class, $path);
+        } catch (\MaxMind\Exception\IpAddressNotFoundException $ex) {
+            throw new AddressNotFoundException(
+                $ex->getMessage(),
+                $ex->getStatusCode(),
+                $ex
+            );
+        } catch (\MaxMind\Exception\AuthenticationException $ex) {
+            throw new AuthenticationException(
+                $ex->getMessage(),
+                $ex->getStatusCode(),
+                $ex
+            );
+        } catch (\MaxMind\Exception\InsufficientFundsException $ex) {
+            throw new OutOfQueriesException(
+                $ex->getMessage(),
+                $ex->getStatusCode(),
+                $ex
+            );
+        } catch (\MaxMind\Exception\InvalidRequestException $ex) {
+            throw new InvalidRequestException(
+                $ex->getMessage(),
+                $ex->getErrorCode(),
+                $ex->getStatusCode(),
+                $ex->getUri(),
+                $ex
+            );
+        } catch (\MaxMind\Exception\HttpException $ex) {
+            throw new HttpException(
+                $ex->getMessage(),
+                $ex->getStatusCode(),
+                $ex->getUri(),
+                $ex
+            );
+        } catch (\MaxMind\Exception\WebServiceException $ex) {
+            throw new GeoIp2Exception(
+                $ex->getMessage(),
+                $ex->getCode(),
+                $ex
+            );
+        }
+
+        $class = "GeoIp2\\Model\\" . $class;
+        return new $class($body, $this->locales);
+    }
+}
diff --git a/lib/maxmind/MaxMind/Db/Reader.php b/lib/maxmind/MaxMind/Db/Reader.php
new file mode 100644 (file)
index 0000000..f524e70
--- /dev/null
@@ -0,0 +1,296 @@
+<?php
+
+namespace MaxMind\Db;
+
+use MaxMind\Db\Reader\Decoder;
+use MaxMind\Db\Reader\InvalidDatabaseException;
+use MaxMind\Db\Reader\Metadata;
+use MaxMind\Db\Reader\Util;
+
+/**
+ * Instances of this class provide a reader for the MaxMind DB format. IP
+ * addresses can be looked up using the <code>get</code> method.
+ */
+class Reader
+{
+    private static $DATA_SECTION_SEPARATOR_SIZE = 16;
+    private static $METADATA_START_MARKER = "\xAB\xCD\xEFMaxMind.com";
+    private static $METADATA_START_MARKER_LENGTH = 14;
+
+    private $decoder;
+    private $fileHandle;
+    private $fileSize;
+    private $ipV4Start;
+    private $metadata;
+
+    /**
+     * Constructs a Reader for the MaxMind DB format. The file passed to it must
+     * be a valid MaxMind DB file such as a GeoIp2 database file.
+     *
+     * @param string $database
+     *            the MaxMind DB file to use.
+     * @throws \InvalidArgumentException for invalid database path or unknown arguments
+     * @throws \MaxMind\Db\Reader\InvalidDatabaseException
+     *             if the database is invalid or there is an error reading
+     *             from it.
+     */
+    public function __construct($database)
+    {
+        if (func_num_args() != 1) {
+            throw new \InvalidArgumentException(
+                'The constructor takes exactly one argument.'
+            );
+        }
+
+        if (!is_readable($database)) {
+            throw new \InvalidArgumentException(
+                "The file \"$database\" does not exist or is not readable."
+            );
+        }
+        $this->fileHandle = @fopen($database, 'rb');
+        if ($this->fileHandle === false) {
+            throw new \InvalidArgumentException(
+                "Error opening \"$database\"."
+            );
+        }
+        $this->fileSize = @filesize($database);
+        if ($this->fileSize === false) {
+            throw new \UnexpectedValueException(
+                "Error determining the size of \"$database\"."
+            );
+        }
+
+        $start = $this->findMetadataStart($database);
+        $metadataDecoder = new Decoder($this->fileHandle, $start);
+        list($metadataArray) = $metadataDecoder->decode($start);
+        $this->metadata = new Metadata($metadataArray);
+        $this->decoder = new Decoder(
+            $this->fileHandle,
+            $this->metadata->searchTreeSize + self::$DATA_SECTION_SEPARATOR_SIZE
+        );
+    }
+
+    /**
+     * Looks up the <code>address</code> in the MaxMind DB.
+     *
+     * @param string $ipAddress
+     *            the IP address to look up.
+     * @return array the record for the IP address.
+     * @throws \BadMethodCallException if this method is called on a closed database.
+     * @throws \InvalidArgumentException if something other than a single IP address is passed to the method.
+     * @throws InvalidDatabaseException
+     *             if the database is invalid or there is an error reading
+     *             from it.
+     */
+    public function get($ipAddress)
+    {
+        if (func_num_args() != 1) {
+            throw new \InvalidArgumentException(
+                'Method takes exactly one argument.'
+            );
+        }
+
+        if (!is_resource($this->fileHandle)) {
+            throw new \BadMethodCallException(
+                'Attempt to read from a closed MaxMind DB.'
+            );
+        }
+
+        if (!filter_var($ipAddress, FILTER_VALIDATE_IP)) {
+            throw new \InvalidArgumentException(
+                "The value \"$ipAddress\" is not a valid IP address."
+            );
+        }
+
+        if ($this->metadata->ipVersion == 4 && strrpos($ipAddress, ':')) {
+            throw new \InvalidArgumentException(
+                "Error looking up $ipAddress. You attempted to look up an"
+                . " IPv6 address in an IPv4-only database."
+            );
+        }
+        $pointer = $this->findAddressInTree($ipAddress);
+        if ($pointer == 0) {
+            return null;
+        }
+        return $this->resolveDataPointer($pointer);
+    }
+
+    private function findAddressInTree($ipAddress)
+    {
+        // XXX - could simplify. Done as a byte array to ease porting
+        $rawAddress = array_merge(unpack('C*', inet_pton($ipAddress)));
+
+        $bitCount = count($rawAddress) * 8;
+
+        // The first node of the tree is always node 0, at the beginning of the
+        // value
+        $node = $this->startNode($bitCount);
+
+        for ($i = 0; $i < $bitCount; $i++) {
+            if ($node >= $this->metadata->nodeCount) {
+                break;
+            }
+            $tempBit = 0xFF & $rawAddress[$i >> 3];
+            $bit = 1 & ($tempBit >> 7 - ($i % 8));
+
+            $node = $this->readNode($node, $bit);
+        }
+        if ($node == $this->metadata->nodeCount) {
+            // Record is empty
+            return 0;
+        } elseif ($node > $this->metadata->nodeCount) {
+            // Record is a data pointer
+            return $node;
+        }
+        throw new InvalidDatabaseException("Something bad happened");
+    }
+
+
+    private function startNode($length)
+    {
+        // Check if we are looking up an IPv4 address in an IPv6 tree. If this
+        // is the case, we can skip over the first 96 nodes.
+        if ($this->metadata->ipVersion == 6 && $length == 32) {
+            return $this->ipV4StartNode();
+        }
+        // The first node of the tree is always node 0, at the beginning of the
+        // value
+        return 0;
+    }
+
+    private function ipV4StartNode()
+    {
+        // This is a defensive check. There is no reason to call this when you
+        // have an IPv4 tree.
+        if ($this->metadata->ipVersion == 4) {
+            return 0;
+        }
+
+        if ($this->ipV4Start != 0) {
+            return $this->ipV4Start;
+        }
+        $node = 0;
+
+        for ($i = 0; $i < 96 && $node < $this->metadata->nodeCount; $i++) {
+            $node = $this->readNode($node, 0);
+        }
+        $this->ipV4Start = $node;
+        return $node;
+    }
+
+    private function readNode($nodeNumber, $index)
+    {
+        $baseOffset = $nodeNumber * $this->metadata->nodeByteSize;
+
+        // XXX - probably could condense this.
+        switch ($this->metadata->recordSize) {
+            case 24:
+                $bytes = Util::read($this->fileHandle, $baseOffset + $index * 3, 3);
+                list(, $node) = unpack('N', "\x00" . $bytes);
+                return $node;
+            case 28:
+                $middleByte = Util::read($this->fileHandle, $baseOffset + 3, 1);
+                list(, $middle) = unpack('C', $middleByte);
+                if ($index == 0) {
+                    $middle = (0xF0 & $middle) >> 4;
+                } else {
+                    $middle = 0x0F & $middle;
+                }
+                $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 3);
+                list(, $node) = unpack('N', chr($middle) . $bytes);
+                return $node;
+            case 32:
+                $bytes = Util::read($this->fileHandle, $baseOffset + $index * 4, 4);
+                list(, $node) = unpack('N', $bytes);
+                return $node;
+            default:
+                throw new InvalidDatabaseException(
+                    'Unknown record size: '
+                    . $this->metadata->recordSize
+                );
+        }
+    }
+
+    private function resolveDataPointer($pointer)
+    {
+        $resolved = $pointer - $this->metadata->nodeCount
+            + $this->metadata->searchTreeSize;
+        if ($resolved > $this->fileSize) {
+            throw new InvalidDatabaseException(
+                "The MaxMind DB file's search tree is corrupt"
+            );
+        }
+
+        list($data) = $this->decoder->decode($resolved);
+        return $data;
+    }
+
+    /*
+     * This is an extremely naive but reasonably readable implementation. There
+     * are much faster algorithms (e.g., Boyer-Moore) for this if speed is ever
+     * an issue, but I suspect it won't be.
+     */
+    private function findMetadataStart($filename)
+    {
+        $handle = $this->fileHandle;
+        $fstat = fstat($handle);
+        $fileSize = $fstat['size'];
+        $marker = self::$METADATA_START_MARKER;
+        $markerLength = self::$METADATA_START_MARKER_LENGTH;
+
+        for ($i = 0; $i < $fileSize - $markerLength + 1; $i++) {
+            for ($j = 0; $j < $markerLength; $j++) {
+                fseek($handle, $fileSize - $i - $j - 1);
+                $matchBit = fgetc($handle);
+                if ($matchBit != $marker[$markerLength - $j - 1]) {
+                    continue 2;
+                }
+            }
+            return $fileSize - $i;
+        }
+        throw new InvalidDatabaseException(
+            "Error opening database file ($filename). " .
+            'Is this a valid MaxMind DB file?'
+        );
+    }
+
+    /**
+     * @throws \InvalidArgumentException if arguments are passed to the method.
+     * @throws \BadMethodCallException if the database has been closed.
+     * @return Metadata object for the database.
+     */
+    public function metadata()
+    {
+        if (func_num_args()) {
+            throw new \InvalidArgumentException(
+                'Method takes no arguments.'
+            );
+        }
+
+        // Not technically required, but this makes it consistent with
+        // C extension and it allows us to change our implementation later.
+        if (!is_resource($this->fileHandle)) {
+            throw new \BadMethodCallException(
+                'Attempt to read from a closed MaxMind DB.'
+            );
+        }
+
+        return $this->metadata;
+    }
+
+    /**
+     * Closes the MaxMind DB and returns resources to the system.
+     *
+     * @throws \Exception
+     *             if an I/O error occurs.
+     */
+    public function close()
+    {
+        if (!is_resource($this->fileHandle)) {
+            throw new \BadMethodCallException(
+                'Attempt to close a closed MaxMind DB.'
+            );
+        }
+        fclose($this->fileHandle);
+    }
+}
diff --git a/lib/maxmind/MaxMind/Db/Reader/Decoder.php b/lib/maxmind/MaxMind/Db/Reader/Decoder.php
new file mode 100644 (file)
index 0000000..4575b27
--- /dev/null
@@ -0,0 +1,309 @@
+<?php
+
+namespace MaxMind\Db\Reader;
+
+use MaxMind\Db\Reader\InvalidDatabaseException;
+use MaxMind\Db\Reader\Util;
+
+class Decoder
+{
+
+    private $fileStream;
+    private $pointerBase;
+    // This is only used for unit testing
+    private $pointerTestHack;
+    private $switchByteOrder;
+
+    private $types = array(
+        0 => 'extended',
+        1 => 'pointer',
+        2 => 'utf8_string',
+        3 => 'double',
+        4 => 'bytes',
+        5 => 'uint16',
+        6 => 'uint32',
+        7 => 'map',
+        8 => 'int32',
+        9 => 'uint64',
+        10 => 'uint128',
+        11 => 'array',
+        12 => 'container',
+        13 => 'end_marker',
+        14 => 'boolean',
+        15 => 'float',
+    );
+
+    public function __construct(
+        $fileStream,
+        $pointerBase = 0,
+        $pointerTestHack = false
+    ) {
+        $this->fileStream = $fileStream;
+        $this->pointerBase = $pointerBase;
+        $this->pointerTestHack = $pointerTestHack;
+
+        $this->switchByteOrder = $this->isPlatformLittleEndian();
+    }
+
+
+    public function decode($offset)
+    {
+        list(, $ctrlByte) = unpack(
+            'C',
+            Util::read($this->fileStream, $offset, 1)
+        );
+        $offset++;
+
+        $type = $this->types[$ctrlByte >> 5];
+
+        // Pointers are a special case, we don't read the next $size bytes, we
+        // use the size to determine the length of the pointer and then follow
+        // it.
+        if ($type == 'pointer') {
+            list($pointer, $offset) = $this->decodePointer($ctrlByte, $offset);
+
+            // for unit testing
+            if ($this->pointerTestHack) {
+                return array($pointer);
+            }
+
+            list($result) = $this->decode($pointer);
+
+            return array($result, $offset);
+        }
+
+        if ($type == 'extended') {
+            list(, $nextByte) = unpack(
+                'C',
+                Util::read($this->fileStream, $offset, 1)
+            );
+
+            $typeNum = $nextByte + 7;
+
+            if ($typeNum < 8) {
+                throw new InvalidDatabaseException(
+                    "Something went horribly wrong in the decoder. An extended type "
+                    . "resolved to a type number < 8 ("
+                    . $this->types[$typeNum]
+                    . ")"
+                );
+            }
+
+            $type = $this->types[$typeNum];
+            $offset++;
+        }
+
+        list($size, $offset) = $this->sizeFromCtrlByte($ctrlByte, $offset);
+
+        return $this->decodeByType($type, $offset, $size);
+    }
+
+    private function decodeByType($type, $offset, $size)
+    {
+        switch ($type) {
+            case 'map':
+                return $this->decodeMap($size, $offset);
+            case 'array':
+                return $this->decodeArray($size, $offset);
+            case 'boolean':
+                return array($this->decodeBoolean($size), $offset);
+        }
+
+        $newOffset = $offset + $size;
+        $bytes = Util::read($this->fileStream, $offset, $size);
+        switch ($type) {
+            case 'utf8_string':
+                return array($this->decodeString($bytes), $newOffset);
+            case 'double':
+                $this->verifySize(8, $size);
+                return array($this->decodeDouble($bytes), $newOffset);
+            case 'float':
+                $this->verifySize(4, $size);
+                return array($this->decodeFloat($bytes), $newOffset);
+            case 'bytes':
+                return array($bytes, $newOffset);
+            case 'uint16':
+            case 'uint32':
+                return array($this->decodeUint($bytes), $newOffset);
+            case 'int32':
+                return array($this->decodeInt32($bytes), $newOffset);
+            case 'uint64':
+            case 'uint128':
+                return array($this->decodeBigUint($bytes, $size), $newOffset);
+            default:
+                throw new InvalidDatabaseException(
+                    "Unknown or unexpected type: " . $type
+                );
+        }
+    }
+
+    private function verifySize($expected, $actual)
+    {
+        if ($expected != $actual) {
+            throw new InvalidDatabaseException(
+                "The MaxMind DB file's data section contains bad data (unknown data type or corrupt data)"
+            );
+        }
+    }
+
+    private function decodeArray($size, $offset)
+    {
+        $array = array();
+
+        for ($i = 0; $i < $size; $i++) {
+            list($value, $offset) = $this->decode($offset);
+            array_push($array, $value);
+        }
+
+        return array($array, $offset);
+    }
+
+    private function decodeBoolean($size)
+    {
+        return $size == 0 ? false : true;
+    }
+
+    private function decodeDouble($bits)
+    {
+        // XXX - Assumes IEEE 754 double on platform
+        list(, $double) = unpack('d', $this->maybeSwitchByteOrder($bits));
+        return $double;
+    }
+
+    private function decodeFloat($bits)
+    {
+        // XXX - Assumes IEEE 754 floats on platform
+        list(, $float) = unpack('f', $this->maybeSwitchByteOrder($bits));
+        return $float;
+    }
+
+    private function decodeInt32($bytes)
+    {
+        $bytes = $this->zeroPadLeft($bytes, 4);
+        list(, $int) = unpack('l', $this->maybeSwitchByteOrder($bytes));
+        return $int;
+    }
+
+    private function decodeMap($size, $offset)
+    {
+
+        $map = array();
+
+        for ($i = 0; $i < $size; $i++) {
+            list($key, $offset) = $this->decode($offset);
+            list($value, $offset) = $this->decode($offset);
+            $map[$key] = $value;
+        }
+
+        return array($map, $offset);
+    }
+
+    private $pointerValueOffset = array(
+        1 => 0,
+        2 => 2048,
+        3 => 526336,
+        4 => 0,
+    );
+
+    private function decodePointer($ctrlByte, $offset)
+    {
+        $pointerSize = (($ctrlByte >> 3) & 0x3) + 1;
+
+        $buffer = Util::read($this->fileStream, $offset, $pointerSize);
+        $offset = $offset + $pointerSize;
+
+        $packed = $pointerSize == 4
+            ? $buffer
+            : (pack('C', $ctrlByte & 0x7)) . $buffer;
+
+        $unpacked = $this->decodeUint($packed);
+        $pointer = $unpacked + $this->pointerBase
+            + $this->pointerValueOffset[$pointerSize];
+
+        return array($pointer, $offset);
+    }
+
+    private function decodeUint($bytes)
+    {
+        list(, $int) = unpack('N', $this->zeroPadLeft($bytes, 4));
+        return $int;
+    }
+
+    private function decodeBigUint($bytes, $byteLength)
+    {
+        $maxUintBytes = log(PHP_INT_MAX, 2) / 8;
+
+        if ($byteLength == 0) {
+            return 0;
+        }
+
+        $numberOfLongs = ceil($byteLength / 4);
+        $paddedLength = $numberOfLongs * 4;
+        $paddedBytes = $this->zeroPadLeft($bytes, $paddedLength);
+        $unpacked = array_merge(unpack("N$numberOfLongs", $paddedBytes));
+
+        $integer = 0;
+
+        // 2^32
+        $twoTo32 = '4294967296';
+
+        foreach ($unpacked as $part) {
+            // We only use gmp or bcmath if the final value is too big
+            if ($byteLength <= $maxUintBytes) {
+                $integer = ($integer << 32) + $part;
+            } elseif (extension_loaded('gmp')) {
+                $integer = gmp_strval(gmp_add(gmp_mul($integer, $twoTo32), $part));
+            } elseif (extension_loaded('bcmath')) {
+                $integer = bcadd(bcmul($integer, $twoTo32), $part);
+            } else {
+                throw new \RuntimeException(
+                    'The gmp or bcmath extension must be installed to read this database.'
+                );
+            }
+        }
+        return $integer;
+    }
+
+    private function decodeString($bytes)
+    {
+        // XXX - NOOP. As far as I know, the en