From aca6a0122e39df9970865d3d831ff19b06f75cd7 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 5 Sep 2016 19:50:26 +1000 Subject: [PATCH 01/72] WIP Issue #9 - Fixed message on all pages, need to work on UI and DB. --- auth.php | 7 +++++++ classes/outagelib.php | 36 ++++++++++++++++++++++++++++++++++-- lib.php | 13 +++++++++++++ renderer.php | 14 ++++++++++++-- res/outage.css | 3 +++ settings.php | 1 - 6 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 res/outage.css diff --git a/auth.php b/auth.php index b8838f9..c43863f 100644 --- a/auth.php +++ b/auth.php @@ -49,4 +49,11 @@ class auth_plugin_outage extends auth_plugin_base public function user_login($username, $password) { return false; } + + /** + * Login page hook. + */ + function loginpage_hook() { + \auth_outage\outagelib::initialize(); + } } diff --git a/classes/outagelib.php b/classes/outagelib.php index 909ee8b..8f36e08 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -28,8 +28,9 @@ if (!defined('MOODLE_INTERNAL')) { * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class outagelib -{ +class outagelib { + private static $initialized = false; + /** * Initializes admin pages for outage. * @@ -39,9 +40,40 @@ class outagelib global $PAGE; admin_externalpage_setup('auth_outage_manage'); $PAGE->set_url(new \moodle_url('/auth/outage/list.php')); + return self::get_renderer(); + } + + /** + * Returns the outage renderer. + * @return \renderer_base + */ + public static function get_renderer() { + global $PAGE; return $PAGE->get_renderer('auth_outage'); } + public static function initialize() { + global $CFG; + + // Many hooks can call it, execute only once. + if (self::$initialized) { + return; + } + self::$initialized = true; + + // Stop if no current outage is found. + $outage = new \auth_outage\models\outage([ + 'starttime' => time() - 60, // 1 minute ago. + 'stoptime' => time() + 60 * 60, // In 1 hour. + 'warningduration' => 1, // Does not matter. + 'title' => 'Fixed Outage', + 'description' => '

This is an OUTAGE.

' + ]); + // FIXME Get from DB instead. + if (!$outage) return; + $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($outage); + } + /** * Loads data from an object or array into another object. It ensures no new fields are created in the $obj. * diff --git a/lib.php b/lib.php index 56943f8..78ccf77 100644 --- a/lib.php +++ b/lib.php @@ -23,3 +23,16 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ defined('MOODLE_INTERNAL') || die; + +function auth_outage_extend_navigation_user() { + \auth_outage\outagelib::initialize(); +} + +function auth_outage_extend_navigation($data) { + // Never called? + \auth_outage\outagelib::initialize(); +} + +function auth_outage_extend_navigation_frontpage() { + \auth_outage\outagelib::initialize(); +} diff --git a/renderer.php b/renderer.php index d6848b1..46e5a7b 100644 --- a/renderer.php +++ b/renderer.php @@ -29,8 +29,7 @@ if (!defined('MOODLE_INTERNAL')) { * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class auth_outage_renderer extends plugin_renderer_base -{ +class auth_outage_renderer extends plugin_renderer_base { public function rendersubtitle($subtitlekey) { if (!is_string($subtitlekey)) { throw new \InvalidArgumentException('$subtitle is not a string.'); @@ -124,4 +123,15 @@ class auth_outage_renderer extends plugin_renderer_base ) ); } + + public function renderbar($outage) { + global $PAGE; + + $PAGE->requires->css(new moodle_url('/auth/outage/res/outage.css')); + + return html_writer::div( + html_writer::tag('b', $outage->title), + 'auth_outage_warningbar' + ); + } } diff --git a/res/outage.css b/res/outage.css new file mode 100644 index 0000000..65ca782 --- /dev/null +++ b/res/outage.css @@ -0,0 +1,3 @@ +.auth_outage_warningbar { + background-color: red; +} \ No newline at end of file diff --git a/settings.php b/settings.php index 1507e1b..fa082c6 100644 --- a/settings.php +++ b/settings.php @@ -25,7 +25,6 @@ defined('MOODLE_INTERNAL') || die; // FIXME If plugin not installed, it is still generating the category Outage under Auth Plugins. - if ($hassiteconfig) { // Configure default settings page. $settings->visiblename = get_string('menudefaults', 'auth_outage'); From 13418a6a28e35f10cc4b373449f430a0e2d72dfd Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 14:00:14 +1000 Subject: [PATCH 02/72] Issue #9 - Hooks installed, getting current outage implemented, missing adding information to the UI. --- classes/models/outage.php | 10 +++++-- classes/outagedb.php | 30 ++++++++++++++++++- classes/outagelib.php | 17 ++++------- lib.php | 9 +++++- tests/outagedb_test.php | 62 +++++++++++++++++++++++++++++++++++++-- 5 files changed, 110 insertions(+), 18 deletions(-) diff --git a/classes/models/outage.php b/classes/models/outage.php index aca44be..a610c5e 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -27,8 +27,7 @@ namespace auth_outage\models; use auth_outage\outagelib; -class outage -{ +class outage { /** * @var int Outage ID (auto generated by the DB). */ @@ -85,6 +84,13 @@ class outage if (is_object($data) || is_array($data)) { outagelib::data2object($data, $this); + + // FIXME types are wrong. Is this behaving as expected? + foreach (['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warningduration'] + as $f) { + $this->$f = ($this->$f === null) ? null : (int)$this->$f; + } + return; } diff --git a/classes/outagedb.php b/classes/outagedb.php index 59e2821..5aee078 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -135,4 +135,32 @@ final class outagedb { $DB->delete_records('auth_outage', ['id' => $id]); } -} \ No newline at end of file + + public static function getactive($time = null) { + global $DB; + + if ($time === null) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time must be null or an int.'); + } + + // TODO Is there a way to use Moodle API instead of writing SQL (conditions not equals)? + // TODO Query not fully using indexes (starttime + 90) + // Gets any active outage (already started or during warning period). + // Gets only one record if available, the one that starts(ed) first and that stops last. + $now = time(); + $data = $DB->get_record_sql(' + SELECT * + FROM {auth_outage} + WHERE (starttime - warningduration <= :now1 AND stoptime >= :now2) + ORDER BY starttime ASC, stoptime DESC, title ASC + LIMIT 1 + ', + ['now1' => $now, 'now2' => $now] + ); + + return ($data === false) ? null : new \auth_outage\models\outage($data); + } +} diff --git a/classes/outagelib.php b/classes/outagelib.php index 8f36e08..0e1492a 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -52,6 +52,9 @@ class outagelib { return $PAGE->get_renderer('auth_outage'); } + /** + * Will check for ongoing or warning outages and will attach the message bar as required. + */ public static function initialize() { global $CFG; @@ -61,17 +64,9 @@ class outagelib { } self::$initialized = true; - // Stop if no current outage is found. - $outage = new \auth_outage\models\outage([ - 'starttime' => time() - 60, // 1 minute ago. - 'stoptime' => time() + 60 * 60, // In 1 hour. - 'warningduration' => 1, // Does not matter. - 'title' => 'Fixed Outage', - 'description' => '

This is an OUTAGE.

' - ]); - // FIXME Get from DB instead. - if (!$outage) return; - $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($outage); + if (($active = outagedb::getactive()) == null) return; + + $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($active); } /** diff --git a/lib.php b/lib.php index 78ccf77..7eb1e0b 100644 --- a/lib.php +++ b/lib.php @@ -29,7 +29,14 @@ function auth_outage_extend_navigation_user() { } function auth_outage_extend_navigation($data) { - // Never called? + global $CFG; + + // FIXME if function is not used, remove it completely. + if ($CFG->debugdisplay) { + var_dump($data); + throw new \Exception("Check outage/lib.php"); + } + \auth_outage\outagelib::initialize(); } diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 5226c64..77704f1 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -29,8 +29,7 @@ use auth_outage\outagedb; defined('MOODLE_INTERNAL') || die(); -class outagedb_test extends advanced_testcase -{ +class outagedb_test extends advanced_testcase { /** * Make sure we can save and update. */ @@ -95,7 +94,6 @@ class outagedb_test extends advanced_testcase * Perform some tests on the data itself, checking values after inserted and updated. */ public function test_basiccrud() { - return; $this->resetAfterTest(true); // Create some outages. @@ -134,6 +132,64 @@ class outagedb_test extends advanced_testcase } } + public function test_getactive() { + $this->resetAfterTest(true); + + // Have a consistent time for now (no seconds variation), helps debugging. + $now = time(); + + // Should never fail. + self::assertEquals([], outagedb::getall(), 'Ensure there are no other outages that can affect the test.'); + self::assertNull(outagedb::getactive($now), 'There should be no active outage at this point.'); + + // An outage that starts in the future and is not in warning period. + self::saveoutage($now, 2, 3, 1); + self::assertNull(outagedb::getactive($now), 'No active outages yet.'); + + // An outage that is already in the past. + self::saveoutage($now, -3, -2, 1); + self::assertNull(outagedb::getactive($now), 'No active outages yet.'); + + // An outage in warning period. + $activeid = self::saveoutage($now, 1, 2, 2); + self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + + // Another outage in warning period, but ignored as it starts after the previous one. + self::saveoutage($now, 2, 3, 3); + self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + + // An ongoing outage. + $activeid = self::saveoutage($now, -2, 2, 1); + self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + + // Another ongoing outage but ignored because it started after the previous one. + self::saveoutage($now, -1, 2, 1); + self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + + // Another ongoing outage starting at the same time, but ignored as it stops before the previous one. + self::saveoutage($now, -2, 1, 1); + self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + } + + /** + * Helper function to create an outage then save it to the database. + * + * @param $now int Timestamp for now, such as time(). + * @param $start int In how many hours this outage starts. Can be negative. + * @param $stop int In how many hours this outage finishes. Can be negative. + * @param $warning int Warning duration in hours. + * @return int Id the of created outage. + */ + private static function saveoutage($now, $start, $stop, $warning) { + return outagedb::save(new outage([ + 'starttime' => $now + ($start * 60 * 60), + 'stoptime' => $now + ($stop * 60 * 60), + 'warningduration' => ($warning * 60 * 60), + 'title' => 'Test Outage', + 'description' => 'Test Outage Description.' + ])); + } + /** * Helper function to create an outage for tests. * From d0c4a1c0befd58e50f1bc0b954950d97981da3c7 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 14:11:10 +1000 Subject: [PATCH 03/72] Issue #9 - Added missing method phpdocs --- classes/outagedb.php | 8 ++++++++ res/outage.css | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index 5aee078..3990d9b 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -136,6 +136,14 @@ final class outagedb { $DB->delete_records('auth_outage', ['id' => $id]); } + /** + * Gets the most important active outage, considering importance as: + * - Ongoing outages more important than outages in warning period. + * - Outages that start earlier are more important. + * - Outages that stop later are more important. + * @param int|null $time Timestamp considered to check for outages, null for current date/time. + * @return outage|null The outage or null if no active outages were found. + */ public static function getactive($time = null) { global $DB; diff --git a/res/outage.css b/res/outage.css index 65ca782..54ee5f9 100644 --- a/res/outage.css +++ b/res/outage.css @@ -1,3 +1,3 @@ .auth_outage_warningbar { background-color: red; -} \ No newline at end of file +} From 4cccb518f311d7a9163c9a56ea6abeb609a2764f Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 14:21:11 +1000 Subject: [PATCH 04/72] Issue #9 - Fixed code standards. --- auth.php | 2 +- classes/models/outage.php | 4 ++-- classes/outagedb.php | 3 +-- classes/outagelib.php | 4 +++- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/auth.php b/auth.php index c43863f..c1fbf7c 100644 --- a/auth.php +++ b/auth.php @@ -53,7 +53,7 @@ class auth_plugin_outage extends auth_plugin_base /** * Login page hook. */ - function loginpage_hook() { + public function loginpage_hook() { \auth_outage\outagelib::initialize(); } } diff --git a/classes/models/outage.php b/classes/models/outage.php index a610c5e..c023c6f 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -86,8 +86,8 @@ class outage { outagelib::data2object($data, $this); // FIXME types are wrong. Is this behaving as expected? - foreach (['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warningduration'] - as $f) { + $fields = ['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warningduration']; + foreach ($fields as $f) { $this->$f = ($this->$f === null) ? null : (int)$this->$f; } diff --git a/classes/outagedb.php b/classes/outagedb.php index 3990d9b..946f156 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -164,8 +164,7 @@ final class outagedb { FROM {auth_outage} WHERE (starttime - warningduration <= :now1 AND stoptime >= :now2) ORDER BY starttime ASC, stoptime DESC, title ASC - LIMIT 1 - ', + LIMIT 1', ['now1' => $now, 'now2' => $now] ); diff --git a/classes/outagelib.php b/classes/outagelib.php index 0e1492a..83b34dc 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -64,7 +64,9 @@ class outagelib { } self::$initialized = true; - if (($active = outagedb::getactive()) == null) return; + if (($active = outagedb::getactive()) == null) { + return; + } $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($active); } From be477ee7875d44b34da30051cbb8aa777dee6b16 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 14:26:49 +1000 Subject: [PATCH 05/72] Issue #9 - Fixed code standards. --- classes/outagedb.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index 946f156..896a8f3 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -162,9 +162,10 @@ final class outagedb { $data = $DB->get_record_sql(' SELECT * FROM {auth_outage} - WHERE (starttime - warningduration <= :now1 AND stoptime >= :now2) + WHERE (starttime - warningduration <= :now1 AND stoptime >= :now2) ORDER BY starttime ASC, stoptime DESC, title ASC - LIMIT 1', + LIMIT 1 + ', ['now1' => $now, 'now2' => $now] ); From 583516a3c7c7e329db7487ab8f7a0170b5803e00 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 17:17:26 +1000 Subject: [PATCH 06/72] Issue #9 - Checking if outage is ongoing or just a warning. Improved hooks, tests and HTML output. --- auth.php | 2 +- classes/models/outage.php | 14 ++++++++++++++ classes/outagedb.php | 5 ++--- classes/outagelib.php | 4 +++- lang/en/auth_outage.php | 2 ++ lib.php | 18 ++++-------------- renderer.php | 12 +++++++++++- res/outage.css | 4 ++++ tests/outage_test.php | 37 +++++++++++++++++++++++++++++++++++-- 9 files changed, 76 insertions(+), 22 deletions(-) diff --git a/auth.php b/auth.php index c1fbf7c..e03e823 100644 --- a/auth.php +++ b/auth.php @@ -54,6 +54,6 @@ class auth_plugin_outage extends auth_plugin_base * Login page hook. */ public function loginpage_hook() { - \auth_outage\outagelib::initialize(); + \auth_outage\outagelib::inject(); } } diff --git a/classes/models/outage.php b/classes/models/outage.php index c023c6f..7827e38 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -96,4 +96,18 @@ class outage { throw new \InvalidArgumentException('$data must be null (default), an array or an object.'); } + + public function is_ongoing($time = null) { + if ($time === null) { + $time = time(); + } + if (!is_int($time) || ($time <= 0)) { + throw new \InvalidArgumentException('$time must be an positive int.'); + } + if (is_null($this->starttime) || is_null($this->stoptime)) { + return false; + } + + return (($this->starttime <= $time) && ($time < $this->stoptime)); + } } \ No newline at end of file diff --git a/classes/outagedb.php b/classes/outagedb.php index 896a8f3..f7ef64e 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -158,15 +158,14 @@ final class outagedb { // TODO Query not fully using indexes (starttime + 90) // Gets any active outage (already started or during warning period). // Gets only one record if available, the one that starts(ed) first and that stops last. - $now = time(); $data = $DB->get_record_sql(' SELECT * FROM {auth_outage} - WHERE (starttime - warningduration <= :now1 AND stoptime >= :now2) + WHERE (starttime - warningduration <= :datetime1 AND stoptime >= :datetime2) ORDER BY starttime ASC, stoptime DESC, title ASC LIMIT 1 ', - ['now1' => $now, 'now2' => $now] + ['datetime1' => $time, 'datetime2' => $time] ); return ($data === false) ? null : new \auth_outage\models\outage($data); diff --git a/classes/outagelib.php b/classes/outagelib.php index 83b34dc..62cb1be 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -55,8 +55,9 @@ class outagelib { /** * Will check for ongoing or warning outages and will attach the message bar as required. */ - public static function initialize() { + public static function inject() { global $CFG; + global $PAGE; // Many hooks can call it, execute only once. if (self::$initialized) { @@ -68,6 +69,7 @@ class outagelib { return; } + $PAGE->add_body_class('auth_outage_active'); $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($active); } diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 0febcae..efe9311 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -32,6 +32,8 @@ $string['defaultwarningtimedescription'] = 'Default warning time (in minutes) fo $string['description'] = 'Public description'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; +$string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; +$string['messageoutagewarning'] = 'There is an scheduled downtime from {$a->start} until {$a->stop}.'; $string['modify'] = 'Modify'; $string['modifyoutage'] = 'Modify Outage'; $string['outagecreate'] = 'Create Outage'; diff --git a/lib.php b/lib.php index 7eb1e0b..31903ec 100644 --- a/lib.php +++ b/lib.php @@ -24,22 +24,12 @@ */ defined('MOODLE_INTERNAL') || die; +// FIXME hook not installing in courses/index.php page. + function auth_outage_extend_navigation_user() { - \auth_outage\outagelib::initialize(); -} - -function auth_outage_extend_navigation($data) { - global $CFG; - - // FIXME if function is not used, remove it completely. - if ($CFG->debugdisplay) { - var_dump($data); - throw new \Exception("Check outage/lib.php"); - } - - \auth_outage\outagelib::initialize(); + \auth_outage\outagelib::inject(); } function auth_outage_extend_navigation_frontpage() { - \auth_outage\outagelib::initialize(); + \auth_outage\outagelib::inject(); } diff --git a/renderer.php b/renderer.php index 46e5a7b..2a4bbb1 100644 --- a/renderer.php +++ b/renderer.php @@ -129,8 +129,18 @@ class auth_outage_renderer extends plugin_renderer_base { $PAGE->requires->css(new moodle_url('/auth/outage/res/outage.css')); + $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); + $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); + + $message = get_string( + $outage->is_ongoing() ? 'messageoutageongoing' : 'messageoutagewarning', + 'auth_outage', + ['start' => $start, 'stop' => $stop] + ); + return html_writer::div( - html_writer::tag('b', $outage->title), + html_writer::div($outage->title, 'auth_outage_warningbar_title') + . html_writer::div($message, 'auth_outage_warningbar_message'), 'auth_outage_warningbar' ); } diff --git a/res/outage.css b/res/outage.css index 54ee5f9..c605e0a 100644 --- a/res/outage.css +++ b/res/outage.css @@ -1,3 +1,7 @@ .auth_outage_warningbar { background-color: red; } + +.auth_outage_warningbar_title { + font-size: 120%; +} \ No newline at end of file diff --git a/tests/outage_test.php b/tests/outage_test.php index b62097d..0c0bd87 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -28,8 +28,7 @@ use auth_outage\models\outage; defined('MOODLE_INTERNAL') || die(); -class outage_test extends basic_testcase -{ +class outage_test extends basic_testcase { public function test_constructor() { $outage = new outage(); // Very important, this should never change. @@ -39,4 +38,38 @@ class outage_test extends basic_testcase self::assertNull($v); } } + + public function test_isongoing() { + $now = time(); + + // In the past. + $outage = new outage([ + 'starttime' => $now + (-3 * 60 * 60), + 'stoptime' => $now + (-2 * 60 * 60), + 'warningduration' => 2 * 60 * 60, + 'title' => '', + 'description' => '' + ]); + self::assertFalse($outage->is_ongoing($now)); + + // In the present (ongoing). + $outage = new outage([ + 'starttime' => $now + (-1 * 60 * 60), + 'stoptime' => $now + (1 * 60 * 60), + 'warningduration' => 2 * 60 * 60, + 'title' => '', + 'description' => '' + ]); + self::assertTrue($outage->is_ongoing($now)); + + // In the future. + $outage = new outage([ + 'starttime' => $now + (1 * 60 * 60), + 'stoptime' => $now + (2 * 60 * 60), + 'warningduration' => 2 * 60 * 60, + 'title' => '', + 'description' => '' + ]); + self::assertFalse($outage->is_ongoing($now)); + } } From a661197a0fd7878633ebe3bb64ad454ff41c8d25 Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Tue, 6 Sep 2016 17:07:26 +1000 Subject: [PATCH 07/72] Update README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1b7f328..4761814 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,15 @@ What is this? ------------- -This is a Moodle plugin which makes the student expereince of planned outages nicer, and provides extra tools for administrators and testers that help before and after the outage window. +This is a Moodle plugin which makes the student experience of planned outages nicer, and provides extra tools for administrators and testers that help before and after the outage window. -The main idea is that instead of an outage being a very booleon on/off situation, this plugin creates the concept of graduated outages where at predefined times before an outage and after, different levels of warning can be provided to students letting them know what is about to happen and why. +The main idea is that instead of an outage being a very booleon on/off situation, this plugin creates the concept of graduated outages where at predefined times before an outage and after, different levels of warning and access can be provided to students and testers letting them know what is about to happen and why. Why it is an auth plugin? ------------------------- -One of the graduated stages this plugin introduces is a 'tester only' mode. This is conceptually similar to the maintenance mode but enables testers to login and confirm the state after an upgrade without needing full admin privileges. +One of the graduated stages this plugin introduces is a 'tester only' mode which disables login for most normal users. This is conceptually similar to the maintenance mode but enables testers to login and confirm the state after an upgrade without needing full admin privileges. Installation @@ -29,7 +29,7 @@ Installation 1. Install the plugin the same as any standard moodle plugin either via the Moodle plugin directory, or you can use git to clone it into your source: - git clone git@github.com:catalyst/moodle-auth_outage.git auth/basic + ```git clone git@github.com:catalyst/moodle-auth_outage.git auth/basic``` Or install via the Moodle plugin directory: From 034383f4f22e9508ab0229eac0d681ac90947e09 Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Tue, 6 Sep 2016 16:47:51 +1000 Subject: [PATCH 08/72] Removed br tag --- renderer.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/renderer.php b/renderer.php index 2a4bbb1..7bb827a 100644 --- a/renderer.php +++ b/renderer.php @@ -57,12 +57,13 @@ class auth_outage_renderer extends plugin_renderer_base { $url = new moodle_url('/auth/outage/create.php'); $img = html_writer::empty_tag('img', ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); - $html .= html_writer::empty_tag('br') - . html_writer::link( + $html .= html_writer::tag('p', + html_writer::link( $url, $img . ' ' . get_string('outagecreate', 'auth_outage'), - ['title' => get_string('remove')]) - . html_writer::empty_tag('br'); + ['title' => get_string('remove')] + ) + ); return $html; } From a37c7989499fa16b5a47456620bb836de95085eb Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Tue, 6 Sep 2016 17:08:23 +1000 Subject: [PATCH 09/72] Added crumb trail items for edit page --- change.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/change.php b/change.php index 675b05d..af70983 100644 --- a/change.php +++ b/change.php @@ -53,7 +53,7 @@ $data = get_object_vars($outage); $data['description'] = ['text' => $data['description'], 'format' => '1']; $mform->set_data($data); - +$PAGE->navbar->add($outage->title); echo $OUTPUT->header(); echo $renderer->rendersubtitle('modifyoutage'); $mform->display(); From 180b30fe8c0160ba5b04a092ad7a84405be494c5 Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Tue, 6 Sep 2016 17:14:16 +1000 Subject: [PATCH 10/72] Added crumb trail node for new outages --- create.php | 1 + 1 file changed, 1 insertion(+) diff --git a/create.php b/create.php index c48af19..329a938 100644 --- a/create.php +++ b/create.php @@ -43,6 +43,7 @@ if ($mform->is_cancelled()) { redirect('/auth/outage/list.php#auth_outage_id_' . $id); } +$PAGE->navbar->add(get_string('outagecreate', 'auth_outage')); echo $OUTPUT->header(); $mform->display(); From 9344b57effe98b3e016b82f152a794bd1343fe10 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 19:51:47 +1000 Subject: [PATCH 11/72] Issue #9 - Improving frontend. --- README.md | 1 + classes/outagelib.php | 6 ++++-- docs/2016-09-06_screenshot.png | Bin 0 -> 140284 bytes renderer.php | 16 +++++++++++----- res/outage.css | 34 ++++++++++++++++++++++++++++++--- 5 files changed, 47 insertions(+), 10 deletions(-) create mode 100644 docs/2016-09-06_screenshot.png diff --git a/README.md b/README.md index 4761814..cd0db63 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Why it is an auth plugin? One of the graduated stages this plugin introduces is a 'tester only' mode which disables login for most normal users. This is conceptually similar to the maintenance mode but enables testers to login and confirm the state after an upgrade without needing full admin privileges. +![Screenshot as of 2016-09-06](docs/2016-09-06_screenshot.png?raw=true) Installation ------------ diff --git a/classes/outagelib.php b/classes/outagelib.php index 62cb1be..a6081b7 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -69,8 +69,10 @@ class outagelib { return; } - $PAGE->add_body_class('auth_outage_active'); - $CFG->additionalhtmltopofbody .= self::get_renderer()->renderbar($active); + // FIXME Code below is raising error at http://moodle.test/my/ for example. + // $PAGE->add_body_class('auth_outage_active'); + $CFG->additionalhtmltopofbody = self::get_renderer()->renderbar($active) + . $CFG->additionalhtmltopofbody; } /** diff --git a/docs/2016-09-06_screenshot.png b/docs/2016-09-06_screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..1ce724433965e5132f52f5997affc81834cbba4d GIT binary patch literal 140284 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%0ABk&IPy4PX%Q0bVB<8VMZ18>p& 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 JF6*2UngG*{yOIC^ literal 0 HcmV?d00001 diff --git a/renderer.php b/renderer.php index 7bb827a..18d6444 100644 --- a/renderer.php +++ b/renderer.php @@ -139,10 +139,16 @@ class auth_outage_renderer extends plugin_renderer_base { ['start' => $start, 'stop' => $stop] ); - return html_writer::div( - html_writer::div($outage->title, 'auth_outage_warningbar_title') - . html_writer::div($message, 'auth_outage_warningbar_message'), - 'auth_outage_warningbar' - ); + return + html_writer::div( + html_writer::div( + html_writer::div($outage->title, 'auth_outage_warningbar_box_title') + . html_writer::div($message, 'auth_outage_warningbar_box_message'), + 'auth_outage_warningbar_box' + ), + 'auth_outage_warningbar' + ) + . + html_writer::div(' ', 'auth_outage_warningbar_spacer'); } } diff --git a/res/outage.css b/res/outage.css index c605e0a..d703444 100644 --- a/res/outage.css +++ b/res/outage.css @@ -1,7 +1,35 @@ .auth_outage_warningbar { - background-color: red; + position: fixed; + top: 0px; + left: 0px; + height: 90px; + z-index: 9999; + width: 100%; + text-align: center; + background-color: white; } -.auth_outage_warningbar_title { - font-size: 120%; +.auth_outage_warningbar_box { + border-top: 2px dashed #a00000; + border-bottom: 2px dashed #a00000; + background-color: #ffcccc; + color: #a00000; +} + +.auth_outage_warningbar_box_title { + font-size: 200%; + font-weight: bold; + margin: 10px 0; +} + +.auth_outage_warningbar_box_message { + margin-bottom: 5px; +} + +.navbar.navbar-fixed-top { + top: 90px; +} + +.auth_outage_warningbar_spacer { + height: 80px; } \ No newline at end of file From abe029afceaf5fd4a5b050aadc069811b07942cd Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 20:01:02 +1000 Subject: [PATCH 12/72] Issue #9 - Fixed readme image placement. --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cd0db63..33f9748 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,14 @@ This is a Moodle plugin which makes the student experience of planned outages ni The main idea is that instead of an outage being a very booleon on/off situation, this plugin creates the concept of graduated outages where at predefined times before an outage and after, different levels of warning and access can be provided to students and testers letting them know what is about to happen and why. +![Screenshot as of 2016-09-06](docs/2016-09-06_screenshot.png?raw=true) + Why it is an auth plugin? ------------------------- One of the graduated stages this plugin introduces is a 'tester only' mode which disables login for most normal users. This is conceptually similar to the maintenance mode but enables testers to login and confirm the state after an upgrade without needing full admin privileges. -![Screenshot as of 2016-09-06](docs/2016-09-06_screenshot.png?raw=true) Installation ------------ From 0251377852173e686add1bd8bbaf770420e4cf11 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 6 Sep 2016 22:39:47 +1000 Subject: [PATCH 13/72] Issue #16 - Check if plugin is enabled before adding menu items. --- settings.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/settings.php b/settings.php index fa082c6..5af7589 100644 --- a/settings.php +++ b/settings.php @@ -24,8 +24,11 @@ */ defined('MOODLE_INTERNAL') || die; -// FIXME If plugin not installed, it is still generating the category Outage under Auth Plugins. -if ($hassiteconfig) { +// Check if plugin is enabled, if not do not create menu entries. +$enabled = core_plugin_manager::instance()->get_enabled_plugins('auth'); +$enabled = array_key_exists('outage', $enabled); + +if ($hassiteconfig && $enabled) { // Configure default settings page. $settings->visiblename = get_string('menudefaults', 'auth_outage'); $settings->add( @@ -54,4 +57,4 @@ if ($hassiteconfig) { new moodle_url($CFG->wwwroot . '/auth/outage/list.php') ) ); -} \ No newline at end of file +} From 68cfce9b48dc33420bea17f83f883c42ab870a5d Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 7 Sep 2016 09:51:48 +1000 Subject: [PATCH 14/72] Issue #9 - Fixed outagedb::getactive() to make better usage of Moodle DB API, removing hardcoded LIMIT from SQL Query. --- classes/outagedb.php | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index f7ef64e..a731367 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -154,20 +154,21 @@ final class outagedb { throw new \InvalidArgumentException('$time must be null or an int.'); } - // TODO Is there a way to use Moodle API instead of writing SQL (conditions not equals)? // TODO Query not fully using indexes (starttime + 90) // Gets any active outage (already started or during warning period). // Gets only one record if available, the one that starts(ed) first and that stops last. - $data = $DB->get_record_sql(' - SELECT * - FROM {auth_outage} - WHERE (starttime - warningduration <= :datetime1 AND stoptime >= :datetime2) - ORDER BY starttime ASC, stoptime DESC, title ASC - LIMIT 1 - ', - ['datetime1' => $time, 'datetime2' => $time] + $data = $DB->get_records_select( + 'auth_outage', + '(starttime - warningduration <= :datetime1 AND stoptime >= :datetime2)', + ['datetime1' => $time, 'datetime2' => $time], + 'starttime ASC, stoptime DESC, title ASC', + '*', + 0, + 1 ); - return ($data === false) ? null : new \auth_outage\models\outage($data); + // Not using $DB->get_record_select instead because there is no 'limit' parameter. + // Allowing multiple records still raises an internal error. + return (count($data) == 0) ? null : new \auth_outage\models\outage(array_shift($data)); } } From 5b19d3a0a108988606be8497241d1f7e6af30a2c Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 7 Sep 2016 10:21:33 +1000 Subject: [PATCH 15/72] Explicit content box on CSS - Issue #9 --- res/outage.css | 1 + 1 file changed, 1 insertion(+) diff --git a/res/outage.css b/res/outage.css index d703444..2572bf7 100644 --- a/res/outage.css +++ b/res/outage.css @@ -1,4 +1,5 @@ .auth_outage_warningbar { + box-sizing: content-box; position: fixed; top: 0px; left: 0px; From a63d2f9d4c76b03d40ae05a941c10e561863ca5e Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 7 Sep 2016 10:40:06 +1000 Subject: [PATCH 16/72] Simplified code to check if enabled. Issue #16 --- settings.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/settings.php b/settings.php index 5af7589..7e2361e 100644 --- a/settings.php +++ b/settings.php @@ -24,11 +24,7 @@ */ defined('MOODLE_INTERNAL') || die; -// Check if plugin is enabled, if not do not create menu entries. -$enabled = core_plugin_manager::instance()->get_enabled_plugins('auth'); -$enabled = array_key_exists('outage', $enabled); - -if ($hassiteconfig && $enabled) { +if ($hassiteconfig && is_enabled_auth('outage')) { // Configure default settings page. $settings->visiblename = get_string('menudefaults', 'auth_outage'); $settings->add( From 4ff0e8fbcf610f2297748738e466d47fc5243f46 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 7 Sep 2016 10:48:02 +1000 Subject: [PATCH 17/72] Code standards. --- auth.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/auth.php b/auth.php index e03e823..ede7ad8 100644 --- a/auth.php +++ b/auth.php @@ -43,8 +43,10 @@ class auth_plugin_outage extends auth_plugin_base } /** - * Do not authenticate users. + * @param string $username Not unsed in this plugin. + * @param string $password Not unsed in this plugin. * @return bool False + * @SuppressWarnings("unused") */ public function user_login($username, $password) { return false; From 2a120f0ebe0d32da20cff9143b450b20a668a9c6 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 7 Sep 2016 11:31:58 +1000 Subject: [PATCH 18/72] Renamed methods, files and language keywords in order to match Moodle style. Issue #16 --- classes/forms/outage/delete.php | 2 +- classes/outagedb.php | 19 +++++++++--------- classes/outagelib.php | 2 +- remove.php => delete.php | 6 +++--- change.php => edit.php | 8 ++++---- lang/en/auth_outage.php | 7 +++---- lib.php | 2 +- list.php => manage.php | 2 +- create.php => new.php | 4 ++-- renderer.php | 20 +++++++++---------- settings.php | 2 +- tests/outagedb_test.php | 34 ++++++++++++++++----------------- 12 files changed, 53 insertions(+), 55 deletions(-) rename remove.php => delete.php (93%) rename change.php => edit.php (90%) rename list.php => manage.php (95%) rename create.php => new.php (93%) diff --git a/classes/forms/outage/delete.php b/classes/forms/outage/delete.php index 26139fa..23cbcd9 100644 --- a/classes/forms/outage/delete.php +++ b/classes/forms/outage/delete.php @@ -41,7 +41,7 @@ class delete extends \moodleform { $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); - $this->add_action_buttons(true, get_string('remove')); + $this->add_action_buttons(true, get_string('delete')); } /** diff --git a/classes/outagedb.php b/classes/outagedb.php index a731367..2b13d88 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -14,20 +14,19 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +namespace auth_outage; + +use auth_outage\models\outage; + /** - * The DB Context to manipulate Outages. Singleton class. + * The DB Context to manipulate Outages. * * @package auth_outage * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -namespace auth_outage; - -use auth_outage\models\outage; - -final class outagedb { +class outagedb { /** * Private constructor, use static methods instead. */ @@ -37,7 +36,7 @@ final class outagedb { /** * Gets all outage entries. */ - public static function getall() { + public static function get_all() { global $DB; $outages = []; @@ -55,7 +54,7 @@ final class outagedb { * @param $id int Outage id to get. * @return outage|null Returns the outage or null if not found. */ - public static function getbyid($id) { + public static function get_by_id($id) { global $DB; if (!is_int($id)) { @@ -144,7 +143,7 @@ final class outagedb { * @param int|null $time Timestamp considered to check for outages, null for current date/time. * @return outage|null The outage or null if no active outages were found. */ - public static function getactive($time = null) { + public static function get_active($time = null) { global $DB; if ($time === null) { diff --git a/classes/outagelib.php b/classes/outagelib.php index a6081b7..b35cfb7 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -65,7 +65,7 @@ class outagelib { } self::$initialized = true; - if (($active = outagedb::getactive()) == null) { + if (($active = outagedb::get_active()) == null) { return; } diff --git a/remove.php b/delete.php similarity index 93% rename from remove.php rename to delete.php index b5db3f0..f5ca3cb 100644 --- a/remove.php +++ b/delete.php @@ -35,14 +35,14 @@ $renderer = outagelib::pagesetup(); $mform = new \auth_outage\forms\outage\delete(); if ($mform->is_cancelled()) { - redirect('/auth/outage/list.php'); + redirect('/auth/outage/manage.php'); } else if ($fromform = $mform->get_data()) { outagedb::delete($fromform->id); - redirect('/auth/outage/list.php'); + redirect('/auth/outage/manage.php'); } $id = required_param('id', PARAM_INT); -$outage = outagedb::getbyid($id); +$outage = outagedb::get_by_id($id); if ($outage == null) { throw new invalid_parameter_exception('Outage #' . $id . ' not found.'); } diff --git a/change.php b/edit.php similarity index 90% rename from change.php rename to edit.php index af70983..0e44b3e 100644 --- a/change.php +++ b/edit.php @@ -36,16 +36,16 @@ $renderer = outagelib::pagesetup(); $mform = new \auth_outage\forms\outage\edit(); if ($mform->is_cancelled()) { - redirect('/auth/outage/list.php'); + redirect('/auth/outage/manage.php'); } else if ($fromform = $mform->get_data()) { $fromform = outagelib::parseformdata($fromform); $outage = new outage($fromform); $id = outagedb::save($outage); - redirect('/auth/outage/list.php#auth_outage_id_' . $id); + redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } $id = required_param('id', PARAM_INT); -$outage = outagedb::getbyid($id); +$outage = outagedb::get_by_id($id); if ($outage == null) { throw new invalid_parameter_exception('Outage #' . $id . ' not found.'); } @@ -55,6 +55,6 @@ $mform->set_data($data); $PAGE->navbar->add($outage->title); echo $OUTPUT->header(); -echo $renderer->rendersubtitle('modifyoutage'); +echo $renderer->rendersubtitle('outageedit'); $mform->display(); echo $OUTPUT->footer(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index efe9311..2200137 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -34,11 +34,10 @@ $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; $string['messageoutagewarning'] = 'There is an scheduled downtime from {$a->start} until {$a->stop}.'; -$string['modify'] = 'Modify'; -$string['modifyoutage'] = 'Modify Outage'; +$string['outageedit'] = 'Edit Outage'; $string['outagecreate'] = 'Create Outage'; -$string['outageremove'] = 'Remove Outage'; -$string['outageremovewarning'] = 'You are about to permanently remove the outage below. This cannot be undone.'; +$string['outagedelete'] = 'Delete Outage'; +$string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage'; $string['starttimeerrornotinfuture'] = 'Start time must be in the future.'; diff --git a/lib.php b/lib.php index 31903ec..c89f4d6 100644 --- a/lib.php +++ b/lib.php @@ -24,7 +24,7 @@ */ defined('MOODLE_INTERNAL') || die; -// FIXME hook not installing in courses/index.php page. +// FIXME hook not installing in courses/index.php page as guest. function auth_outage_extend_navigation_user() { \auth_outage\outagelib::inject(); diff --git a/list.php b/manage.php similarity index 95% rename from list.php rename to manage.php index dd2c840..d5af095 100644 --- a/list.php +++ b/manage.php @@ -33,6 +33,6 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -echo $renderer->renderoutagelist(outagedb::getall()); +echo $renderer->renderoutagelist(outagedb::get_all()); echo $OUTPUT->footer(); diff --git a/create.php b/new.php similarity index 93% rename from create.php rename to new.php index 329a938..e6fb30d 100644 --- a/create.php +++ b/new.php @@ -35,12 +35,12 @@ outagelib::pagesetup(); $mform = new \auth_outage\forms\outage\edit(); if ($mform->is_cancelled()) { - redirect('/auth/outage/list.php'); + redirect('/auth/outage/manage.php'); } else if ($fromform = $mform->get_data()) { $fromform = outagelib::parseformdata($fromform); $outage = new outage($fromform); $id = outagedb::save($outage); - redirect('/auth/outage/list.php#auth_outage_id_' . $id); + redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } $PAGE->navbar->add(get_string('outagecreate', 'auth_outage')); diff --git a/renderer.php b/renderer.php index 18d6444..d50bc15 100644 --- a/renderer.php +++ b/renderer.php @@ -38,8 +38,8 @@ class auth_outage_renderer extends plugin_renderer_base { } public function renderdeleteconfirmation(outage $outage) { - return $this->rendersubtitle('outageremove') - . html_writer::tag('p', get_string('outageremovewarning', 'auth_outage')) + return $this->rendersubtitle('outagedelete') + . html_writer::tag('p', get_string('outagedeletewarning', 'auth_outage')) . $this->renderoutage($outage, false); } @@ -54,14 +54,14 @@ class auth_outage_renderer extends plugin_renderer_base { } // Add 'add' button. - $url = new moodle_url('/auth/outage/create.php'); + $url = new moodle_url('/auth/outage/new.php'); $img = html_writer::empty_tag('img', ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); $html .= html_writer::tag('p', html_writer::link( $url, $img . ' ' . get_string('outagecreate', 'auth_outage'), - ['title' => get_string('remove')] + ['title' => get_string('delete')] ) ); @@ -83,19 +83,19 @@ class auth_outage_renderer extends plugin_renderer_base { trim($modified->firstname . ' ' . $modified->lastname) ); - $url = new moodle_url('/auth/outage/change.php', ['id' => $outage->id]); + $url = new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]); $img = html_writer::empty_tag( 'img', - ['src' => $OUTPUT->pix_url('t/edit'), 'alt' => get_string('modify', 'auth_outage'), 'class' => 'iconsmall'] + ['src' => $OUTPUT->pix_url('t/edit'), 'alt' => get_string('edit'), 'class' => 'iconsmall'] ); - $linkedit = html_writer::link($url, $img, ['title' => get_string('modify', 'auth_outage')]); + $linkedit = html_writer::link($url, $img, ['title' => get_string('edit')]); - $url = new moodle_url('/auth/outage/remove.php', ['id' => $outage->id]); + $url = new moodle_url('/auth/outage/delete.php', ['id' => $outage->id]); $img = html_writer::empty_tag( 'img', - ['src' => $OUTPUT->pix_url('t/delete'), 'alt' => get_string('remove'), 'class' => 'iconsmall'] + ['src' => $OUTPUT->pix_url('t/delete'), 'alt' => get_string('delete'), 'class' => 'iconsmall'] ); - $linkdelete = html_writer::link($url, $img, ['title' => get_string('remove')]); + $linkdelete = html_writer::link($url, $img, ['title' => get_string('delete')]); // TODO use language pack below, solve together with Issue #12. return html_writer::div( diff --git a/settings.php b/settings.php index 7e2361e..61dfc18 100644 --- a/settings.php +++ b/settings.php @@ -50,7 +50,7 @@ if ($hassiteconfig && is_enabled_auth('outage')) { new admin_externalpage( 'auth_outage_manage', get_string('menumanage', 'auth_outage'), - new moodle_url($CFG->wwwroot . '/auth/outage/list.php') + new moodle_url($CFG->wwwroot . '/auth/outage/manage.php') ) ); } diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 77704f1..30a2ab0 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -51,12 +51,12 @@ class outagedb_test extends advanced_testcase { // Create something. $id = outagedb::save($this->createoutage(1)); // Get should work. - $outage = outagedb::getbyid($id); + $outage = outagedb::get_by_id($id); self::assertNotNull($outage); // Delete it. outagedb::delete($id); // Get should be null. - $outage = outagedb::getbyid($id); + $outage = outagedb::get_by_id($id); self::assertNull($outage); } @@ -70,7 +70,7 @@ class outagedb_test extends advanced_testcase { // Delete it. outagedb::delete($id); // Should not exist anymore. - self::assertNull(outagedb::getbyid($id)); + self::assertNull(outagedb::get_by_id($id)); } /** @@ -80,14 +80,14 @@ class outagedb_test extends advanced_testcase { $this->resetAfterTest(true); $amount = 10; // Should start empty. - $outages = outagedb::getall(); + $outages = outagedb::get_all(); self::assertSame([], $outages); // Create some stuff outages. for ($i = 0; $i < $amount; $i++) { outagedb::save($this->createoutage($i)); } // Count entries created. - self::assertSame($amount, count(outagedb::getall())); + self::assertSame($amount, count(outagedb::get_all())); } /** @@ -107,7 +107,7 @@ class outagedb_test extends advanced_testcase { // With all created outages. foreach ($outages as $id => $outage) { // Get it. - $inserted = outagedb::getbyid($id); + $inserted = outagedb::get_by_id($id); self::assertNotNull($inserted); // Check its data. foreach (['starttime', 'stoptime', 'warningduration', 'title', 'description'] as $field) { @@ -122,12 +122,12 @@ class outagedb_test extends advanced_testcase { $inserted->title = 'Title ID' . $id; outagedb::save($inserted); // Get it again and check data. - $updated = outagedb::getbyid($id); + $updated = outagedb::get_by_id($id); self::assertSame('Title ID' . $id, $updated->title); self::assertSame($inserted->description, $updated->description); // Delete it. outagedb::delete($id); - $deleted = outagedb::getbyid($id); + $deleted = outagedb::get_by_id($id); self::assertNull($deleted); } } @@ -139,36 +139,36 @@ class outagedb_test extends advanced_testcase { $now = time(); // Should never fail. - self::assertEquals([], outagedb::getall(), 'Ensure there are no other outages that can affect the test.'); - self::assertNull(outagedb::getactive($now), 'There should be no active outage at this point.'); + self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); + self::assertNull(outagedb::get_active($now), 'There should be no active outage at this point.'); // An outage that starts in the future and is not in warning period. self::saveoutage($now, 2, 3, 1); - self::assertNull(outagedb::getactive($now), 'No active outages yet.'); + self::assertNull(outagedb::get_active($now), 'No active outages yet.'); // An outage that is already in the past. self::saveoutage($now, -3, -2, 1); - self::assertNull(outagedb::getactive($now), 'No active outages yet.'); + self::assertNull(outagedb::get_active($now), 'No active outages yet.'); // An outage in warning period. $activeid = self::saveoutage($now, 1, 2, 2); - self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); // Another outage in warning period, but ignored as it starts after the previous one. self::saveoutage($now, 2, 3, 3); - self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); // An ongoing outage. $activeid = self::saveoutage($now, -2, 2, 1); - self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); // Another ongoing outage but ignored because it started after the previous one. self::saveoutage($now, -1, 2, 1); - self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); // Another ongoing outage starting at the same time, but ignored as it stops before the previous one. self::saveoutage($now, -2, 1, 1); - self::assertSame($activeid, outagedb::getactive($now)->id, 'Wrong active outage picked.'); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); } /** From efee27af4045693a28743474b7bf5442b516d9c4 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 11:13:46 +1000 Subject: [PATCH 19/72] Solving all warnings from moodle-plugin-ci check. --- classes/outagelib.php | 3 --- db/upgrade.php | 34 ++++++++++++++++++++++++++++++++++ res/outage.css | 20 ++++++++++---------- 3 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 db/upgrade.php diff --git a/classes/outagelib.php b/classes/outagelib.php index b35cfb7..d893572 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -57,7 +57,6 @@ class outagelib { */ public static function inject() { global $CFG; - global $PAGE; // Many hooks can call it, execute only once. if (self::$initialized) { @@ -69,8 +68,6 @@ class outagelib { return; } - // FIXME Code below is raising error at http://moodle.test/my/ for example. - // $PAGE->add_body_class('auth_outage_active'); $CFG->additionalhtmltopofbody = self::get_renderer()->renderbar($active) . $CFG->additionalhtmltopofbody; } diff --git a/db/upgrade.php b/db/upgrade.php new file mode 100644 index 0000000..7ed51be --- /dev/null +++ b/db/upgrade.php @@ -0,0 +1,34 @@ +. + +/** + * Outage plugin upgrade code + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +defined('MOODLE_INTERNAL') || die(); + +/** + * @param int $oldversion the version we are upgrading from + * @return bool result + * @SuppressWarnings("unused") + */ +function xmldb_auth_outage_upgrade($oldversion) { + return true; +} diff --git a/res/outage.css b/res/outage.css index 2572bf7..1269b0a 100644 --- a/res/outage.css +++ b/res/outage.css @@ -1,19 +1,19 @@ .auth_outage_warningbar { - box-sizing: content-box; - position: fixed; - top: 0px; - left: 0px; - height: 90px; - z-index: 9999; - width: 100%; - text-align: center; background-color: white; + box-sizing: content-box; + height: 90px; + left: 0; + position: fixed; + text-align: center; + top: 0; + width: 100%; + z-index: 9999; } .auth_outage_warningbar_box { - border-top: 2px dashed #a00000; - border-bottom: 2px dashed #a00000; background-color: #ffcccc; + border-bottom: 2px dashed #a00000; + border-top: 2px dashed #a00000; color: #a00000; } From a4de5d6bf188dd8fbcecd0e076e8b146f742c82d Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 14:31:51 +1000 Subject: [PATCH 20/72] Issue #10 - Added link to warning bar and info page. --- classes/forms/gohome.php | 43 +++++++++++++++++++++++++++ classes/outagelib.php | 4 +-- info.php | 52 +++++++++++++++++++++++++++++++++ lang/en/auth_outage.php | 2 ++ renderer.php | 33 ++++++++++++++++----- res/{outage.css => default.css} | 7 ++++- settings.php | 7 +++++ 7 files changed, 138 insertions(+), 10 deletions(-) create mode 100644 classes/forms/gohome.php create mode 100644 info.php rename res/{outage.css => default.css} (79%) diff --git a/classes/forms/gohome.php b/classes/forms/gohome.php new file mode 100644 index 0000000..a457ca5 --- /dev/null +++ b/classes/forms/gohome.php @@ -0,0 +1,43 @@ +. + +namespace auth_outage\forms; + +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. +} + +require_once($CFG->libdir . '/formslib.php'); + +/** + * Home form. Shows a button 'Continue' and moves to home page once clicked. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class gohome extends \moodleform { + const TITLE_MAX_CHARS = 100; + + /** + * {@inheritDoc} + * @see moodleform::definition() + */ + public function definition() { + $this->add_action_buttons(false, get_string('continue')); + } +} \ No newline at end of file diff --git a/classes/outagelib.php b/classes/outagelib.php index d893572..cbe46f5 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -39,7 +39,7 @@ class outagelib { public static function pagesetup() { global $PAGE; admin_externalpage_setup('auth_outage_manage'); - $PAGE->set_url(new \moodle_url('/auth/outage/list.php')); + $PAGE->set_url(new \moodle_url('/auth/outage/manage.php')); return self::get_renderer(); } @@ -68,7 +68,7 @@ class outagelib { return; } - $CFG->additionalhtmltopofbody = self::get_renderer()->renderbar($active) + $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active) . $CFG->additionalhtmltopofbody; } diff --git a/info.php b/info.php new file mode 100644 index 0000000..73783f4 --- /dev/null +++ b/info.php @@ -0,0 +1,52 @@ +. + +/** + * List outages + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\outagedb; +use auth_outage\outagelib; + +require_once('../../config.php'); + +$outage = outagedb::get_active(); +if (is_null($outage)) { + redirect(new moodle_url('/')); +} + +$PAGE->set_context(context_system::instance()); +$PAGE->set_title("Outage Warning"); +$PAGE->set_heading("Outage Warning"); +$PAGE->set_url(new \moodle_url('/auth/outage/info.php')); + +$mform = new \auth_outage\forms\gohome(); +if ($mform->get_data()) { + redirect(new moodle_url('/')); +} + + +echo $OUTPUT->header(); + +echo outagelib::get_renderer()->renderoutagepage($outage); +$mform->display(); + +echo $OUTPUT->footer(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 2200137..01fee89 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -24,6 +24,8 @@ */ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; +$string['defaultlayoutcss'] = 'Layout CSS'; +$string['defaultlayoutcssdescription'] = 'This CSS code will be used to display the Outage Warning Bar.'; $string['defaultwarningmessage'] = 'Default Warning Message'; $string['defaultwarningmessagedescription'] = 'Default warning message for outages. Use [from] and [until] placeholders as required.'; $string['defaultwarningmessagevalue'] = 'There is an scheduled maintenance from [from] to [until] and our system will not be available during that time.'; diff --git a/renderer.php b/renderer.php index d50bc15..3f76b52 100644 --- a/renderer.php +++ b/renderer.php @@ -125,10 +125,23 @@ class auth_outage_renderer extends plugin_renderer_base { ); } - public function renderbar($outage) { - global $PAGE; + public function renderoutagepage(outage $outage) { + $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); + $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); - $PAGE->requires->css(new moodle_url('/auth/outage/res/outage.css')); + return html_writer::div( + html_writer::tag('p', + html_writer::tag('b', 'From: ') + . $start + . html_writer::tag('b', ' Until: ') + . $stop + ) + . html_writer::div($outage->description) + ); + } + + public function renderoutagebar(outage $outage) { + global $CFG; $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); @@ -140,15 +153,21 @@ class auth_outage_renderer extends plugin_renderer_base { ); return - html_writer::div( + html_writer::tag('style', $CFG->auth_outage_css) + . html_writer::div( html_writer::div( html_writer::div($outage->title, 'auth_outage_warningbar_box_title') - . html_writer::div($message, 'auth_outage_warningbar_box_message'), + . html_writer::div( + $message . ' ' + . html_writer::tag('small', + '[' . html_writer::link(new moodle_url('/auth/outage/info.php'), 'more') . ']' + ), + 'auth_outage_warningbar_box_message' + ), 'auth_outage_warningbar_box' ), 'auth_outage_warningbar' ) - . - html_writer::div(' ', 'auth_outage_warningbar_spacer'); + . html_writer::div(' ', 'auth_outage_warningbar_spacer'); } } diff --git a/res/outage.css b/res/default.css similarity index 79% rename from res/outage.css rename to res/default.css index 1269b0a..67a68b6 100644 --- a/res/outage.css +++ b/res/default.css @@ -1,3 +1,8 @@ +/* +This file is used as default value for the 'auth_outage_css' settings. +If you need to make chances here, remember to update your settings inside Moodle. +*/ + .auth_outage_warningbar { background-color: white; box-sizing: content-box; @@ -33,4 +38,4 @@ .auth_outage_warningbar_spacer { height: 80px; -} \ No newline at end of file +} diff --git a/settings.php b/settings.php index 61dfc18..aca99c7 100644 --- a/settings.php +++ b/settings.php @@ -39,6 +39,13 @@ if ($hassiteconfig && is_enabled_auth('outage')) { get_string('defaultwarningmessagevalue', 'auth_outage'), PARAM_TEXT) ); + $settings->add( + new admin_setting_configtextarea('auth_outage_css', + get_string('defaultlayoutcss', 'auth_outage'), + get_string('defaultlayoutcssdescription', 'auth_outage'), + file_get_contents($CFG->dirroot.'/auth/outage/res/default.css'), + PARAM_TEXT) + ); // Create category for Outage. $ADMIN->add('authsettings', new admin_category('auth_outage', get_string('pluginname', 'auth_outage'))); // Add settings page toconfigure defaults. From b4edc0e94af2f4a774a4820edf6dca2edfcd30bc Mon Sep 17 00:00:00 2001 From: Brendan Heywood Date: Thu, 8 Sep 2016 14:37:44 +1000 Subject: [PATCH 21/72] Lang tweak --- lang/en/auth_outage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 2200137..b99167d 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -39,7 +39,7 @@ $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageslist'] = 'Outages List'; -$string['pluginname'] = 'Outage'; +$string['pluginname'] = 'Outage manager'; $string['starttimeerrornotinfuture'] = 'Start time must be in the future.'; $string['starttime'] = 'Start date and time'; $string['stoptimeerrornotafterstart'] = 'Stop time must be after start time.'; From 54e6c405652e3b8bee2fd6c7abaa4e06badb8b98 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 14:43:45 +1000 Subject: [PATCH 22/72] Issue #13 - Removed continue button from info page. Link to outage in warning bar opens in new tab. --- classes/forms/gohome.php | 43 ---------------------------------------- info.php | 7 ------- renderer.php | 4 +++- 3 files changed, 3 insertions(+), 51 deletions(-) delete mode 100644 classes/forms/gohome.php diff --git a/classes/forms/gohome.php b/classes/forms/gohome.php deleted file mode 100644 index a457ca5..0000000 --- a/classes/forms/gohome.php +++ /dev/null @@ -1,43 +0,0 @@ -. - -namespace auth_outage\forms; - -if (!defined('MOODLE_INTERNAL')) { - die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. -} - -require_once($CFG->libdir . '/formslib.php'); - -/** - * Home form. Shows a button 'Continue' and moves to home page once clicked. - * - * @package auth_outage - * @author Daniel Thee Roperto - * @copyright Catalyst IT - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class gohome extends \moodleform { - const TITLE_MAX_CHARS = 100; - - /** - * {@inheritDoc} - * @see moodleform::definition() - */ - public function definition() { - $this->add_action_buttons(false, get_string('continue')); - } -} \ No newline at end of file diff --git a/info.php b/info.php index 73783f4..4fca52b 100644 --- a/info.php +++ b/info.php @@ -38,15 +38,8 @@ $PAGE->set_title("Outage Warning"); $PAGE->set_heading("Outage Warning"); $PAGE->set_url(new \moodle_url('/auth/outage/info.php')); -$mform = new \auth_outage\forms\gohome(); -if ($mform->get_data()) { - redirect(new moodle_url('/')); -} - - echo $OUTPUT->header(); echo outagelib::get_renderer()->renderoutagepage($outage); -$mform->display(); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index 3f76b52..0b9f8ab 100644 --- a/renderer.php +++ b/renderer.php @@ -160,7 +160,9 @@ class auth_outage_renderer extends plugin_renderer_base { . html_writer::div( $message . ' ' . html_writer::tag('small', - '[' . html_writer::link(new moodle_url('/auth/outage/info.php'), 'more') . ']' + '[' . html_writer::link( + new moodle_url('/auth/outage/info.php'), 'more', ['target' => 'outage'] + ) . ']' ), 'auth_outage_warningbar_box_message' ), From 2b862b44b290b31f22951c1867199cc3fd3d4166 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 14:54:42 +1000 Subject: [PATCH 23/72] Issue #13 - Added link to edit outage from info page. --- renderer.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/renderer.php b/renderer.php index 0b9f8ab..a925b7e 100644 --- a/renderer.php +++ b/renderer.php @@ -129,6 +129,16 @@ class auth_outage_renderer extends plugin_renderer_base { $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); + $admin = ''; + if (is_siteadmin()) { + $admin = html_writer::tag('div', + '[' . html_writer::link( + new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + get_string('outageedit', 'auth_outage') + ) . ']' + ); + } + return html_writer::div( html_writer::tag('p', html_writer::tag('b', 'From: ') @@ -137,6 +147,7 @@ class auth_outage_renderer extends plugin_renderer_base { . $stop ) . html_writer::div($outage->description) + . $admin ); } From 07ee61f4f386035534d3edfa78ae619b0c882f94 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 15:23:15 +1000 Subject: [PATCH 24/72] Issue #20 - Allow changing also past and present outages. --- classes/forms/outage/edit.php | 3 --- lang/en/auth_outage.php | 1 - 2 files changed, 4 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 3419b48..26d3c77 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -72,9 +72,6 @@ class edit extends \moodleform { public function validation($data, $files) { $errors = parent::validation($data, $files); - if ($data['starttime'] <= time()) { - $errors['starttime'] = get_string('starttimeerrornotinfuture', 'auth_outage'); - } if ($data['stoptime'] <= $data['starttime']) { $errors['stoptime'] = get_string('stoptimeerrornotafterstart', 'auth_outage'); } diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index f57638e..d158959 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -42,7 +42,6 @@ $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage manager'; -$string['starttimeerrornotinfuture'] = 'Start time must be in the future.'; $string['starttime'] = 'Start date and time'; $string['stoptimeerrornotafterstart'] = 'Stop time must be after start time.'; $string['stoptime'] = 'Stop date and time'; From 4e46e934d0b897247a3ea0210cf27fdee8898a21 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 8 Sep 2016 17:25:28 +1000 Subject: [PATCH 25/72] WIP Issue #17 - Settings page done, missing forms and other references to warning duration and stop time. --- classes/forms/outage/edit.php | 4 +++- classes/models/outage.php | 8 ++++---- classes/outagedb.php | 2 +- db/install.xml | 4 ++-- lang/en/auth_outage.php | 20 +++++++++++++------- new.php | 10 ++++++++++ renderer.php | 2 +- settings.php | 30 +++++++++++++++++++++--------- tests/outagedb_test.php | 2 +- 9 files changed, 56 insertions(+), 26 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 26d3c77..21512e5 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -45,7 +45,7 @@ class edit extends \moodleform { $mform->addElement('date_time_selector', 'starttime', get_string('starttime', 'auth_outage')); - $mform->addElement('date_time_selector', 'stoptime', get_string('stoptime', 'auth_outage')); + $mform->addElement('duration', 'outageduration', get_string('outageduration', 'auth_outage')); $mform->addElement('duration', 'warningduration', get_string('warningduration', 'auth_outage')); @@ -59,6 +59,8 @@ class edit extends \moodleform { $mform->addElement('editor', 'description', get_string('description', 'auth_outage')); + $mform->addElement('static', 'usagehints', '', get_string('textplaceholdershint', 'auth_outage')); + $this->add_action_buttons(); } diff --git a/classes/models/outage.php b/classes/models/outage.php index 7827e38..6eb68fc 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -44,9 +44,9 @@ class outage { public $stoptime = null; /** - * @var int Amount of minutes before outage starts to show the warning message. + * @var int Warning start timestamp. */ - public $warningduration = null; + public $warntime = null; /** * @var string Short description of the outage (no HTML). @@ -85,8 +85,8 @@ class outage { if (is_object($data) || is_array($data)) { outagelib::data2object($data, $this); - // FIXME types are wrong. Is this behaving as expected? - $fields = ['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warningduration']; + // Adjust field types as needed. + $fields = ['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warntime']; foreach ($fields as $f) { $this->$f = ($this->$f === null) ? null : (int)$this->$f; } diff --git a/classes/outagedb.php b/classes/outagedb.php index 2b13d88..99fd2d4 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -158,7 +158,7 @@ class outagedb { // Gets only one record if available, the one that starts(ed) first and that stops last. $data = $DB->get_records_select( 'auth_outage', - '(starttime - warningduration <= :datetime1 AND stoptime >= :datetime2)', + '(warntime <= :datetime1 AND stoptime >= :datetime2)', ['datetime1' => $time, 'datetime2' => $time], 'starttime ASC, stoptime DESC, title ASC', '*', diff --git a/db/install.xml b/db/install.xml index f375742..7f6326b 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -9,7 +9,7 @@ - + diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index d158959..82cf8c9 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -26,12 +26,17 @@ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; $string['defaultlayoutcss'] = 'Layout CSS'; $string['defaultlayoutcssdescription'] = 'This CSS code will be used to display the Outage Warning Bar.'; -$string['defaultwarningmessage'] = 'Default Warning Message'; -$string['defaultwarningmessagedescription'] = 'Default warning message for outages. Use [from] and [until] placeholders as required.'; -$string['defaultwarningmessagevalue'] = 'There is an scheduled maintenance from [from] to [until] and our system will not be available during that time.'; -$string['defaultwarningtime'] = 'Default Warning Time'; -$string['defaultwarningtimedescription'] = 'Default warning time (in minutes) for outages.'; -$string['description'] = 'Public description'; +$string['defaultoutageduration'] = 'Outage Duration'; +$string['defaultoutagedurationdescription'] = 'Default duration (in minutes) of an outage.'; +$string['defaultwarningduration'] = 'Warning Duration'; +$string['defaultwarningdurationdescription'] = 'Default warning time (in minutes) for outages.'; +$string['defaultwarningtitle'] = 'Title'; +$string['defaultwarningtitledescription'] = 'Default title for outages. Use {{start}} and {{stop}} placeholders as required.'; +$string['defaultwarningtitlevalue'] = 'System down from {{start}} to {{stop}}.'; +$string['defaultwarningdescription'] = 'Description'; +$string['defaultwarningdescriptiondescription'] = 'Default warning message for outages. Use {{start}} and {{stop}} placeholders as required.'; +$string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; +$string['description'] = 'Public Description'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; @@ -40,11 +45,12 @@ $string['outageedit'] = 'Edit Outage'; $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; +$string['outageduration'] = 'Outage Duration'; $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage manager'; $string['starttime'] = 'Start date and time'; $string['stoptimeerrornotafterstart'] = 'Stop time must be after start time.'; -$string['stoptime'] = 'Stop date and time'; +$string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; $string['title'] = 'Title'; diff --git a/new.php b/new.php index e6fb30d..b60d9d9 100644 --- a/new.php +++ b/new.php @@ -43,6 +43,16 @@ if ($mform->is_cancelled()) { redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } +$config = get_config('auth_outage'); +$defaults = [ + 'starttime' => time(), + 'outageduration' => ($config->default_duration * 60), + 'warningduration' => ($config->warning_duration * 60), + 'title' => $config->warning_title, + 'description' => ['text' => $config->warning_description, 'format' => '1'] +]; +$mform->set_data($defaults); + $PAGE->navbar->add(get_string('outagecreate', 'auth_outage')); echo $OUTPUT->header(); diff --git a/renderer.php b/renderer.php index a925b7e..c0928c9 100644 --- a/renderer.php +++ b/renderer.php @@ -105,7 +105,7 @@ class auth_outage_renderer extends plugin_renderer_base { . html_writer::tag('i', $outage->description) . html_writer::empty_tag('br') . html_writer::tag('b', 'Warning: ') - . userdate($outage->starttime - ($outage->warningduration * 60)) + . userdate($outage->warntime, '%d %h %Y %l:%M%P') . html_writer::empty_tag('br') . html_writer::tag('b', 'Starts: ') . userdate($outage->starttime, '%d %h %Y %l:%M%P') diff --git a/settings.php b/settings.php index aca99c7..b6c0b94 100644 --- a/settings.php +++ b/settings.php @@ -28,19 +28,31 @@ if ($hassiteconfig && is_enabled_auth('outage')) { // Configure default settings page. $settings->visiblename = get_string('menudefaults', 'auth_outage'); $settings->add( - new admin_setting_configtext('auth_outage_warning_period', - get_string('defaultwarningtime', 'auth_outage'), - get_string('defaultwarningtimedescription', 'auth_outage'), - 120, PARAM_INT)); + new admin_setting_configtext('auth_outage/default_duration', + get_string('defaultoutageduration', 'auth_outage'), + get_string('defaultoutagedurationdescription', 'auth_outage'), + 60, PARAM_INT)); $settings->add( - new admin_setting_configtextarea('auth_outage_warning_text', - get_string('defaultwarningmessage', 'auth_outage'), - get_string('defaultwarningmessagedescription', 'auth_outage'), - get_string('defaultwarningmessagevalue', 'auth_outage'), + new admin_setting_configtext('auth_outage/warning_duration', + get_string('defaultwarningduration', 'auth_outage'), + get_string('defaultwarningdurationdescription', 'auth_outage'), + 60, PARAM_INT)); + $settings->add( + new admin_setting_configtext('auth_outage/warning_title', + get_string('defaultwarningtitle', 'auth_outage'), + get_string('defaultwarningtitledescription', 'auth_outage'), + get_string('defaultwarningtitlevalue', 'auth_outage'), PARAM_TEXT) ); $settings->add( - new admin_setting_configtextarea('auth_outage_css', + new admin_setting_configtextarea('auth_outage/warning_description', + get_string('defaultwarningdescription', 'auth_outage'), + get_string('defaultwarningdescriptiondescription', 'auth_outage'), + get_string('defaultwarningdescriptionvalue', 'auth_outage'), + PARAM_TEXT) + ); + $settings->add( + new admin_setting_configtextarea('auth_outage/css', get_string('defaultlayoutcss', 'auth_outage'), get_string('defaultlayoutcssdescription', 'auth_outage'), file_get_contents($CFG->dirroot.'/auth/outage/res/default.css'), diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 30a2ab0..0c01258 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -184,7 +184,7 @@ class outagedb_test extends advanced_testcase { return outagedb::save(new outage([ 'starttime' => $now + ($start * 60 * 60), 'stoptime' => $now + ($stop * 60 * 60), - 'warningduration' => ($warning * 60 * 60), + 'warntime' => $now - ($warning * 60 * 60), 'title' => 'Test Outage', 'description' => 'Test Outage Description.' ])); From 6bb9320ef63865fdad235c8cd0ae5d43b508b131 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 9 Sep 2016 11:39:44 +1000 Subject: [PATCH 26/72] Outage remodelled both in DB and PHP Class. Issue #17. --- classes/forms/outage/edit.php | 48 ++++++++++++++++++++++++++++++++--- classes/models/outage.php | 40 +++++++++++++++++++++++++++++ classes/outagelib.php | 17 ------------- edit.php | 8 ++---- lang/en/auth_outage.php | 4 +-- new.php | 4 +-- renderer.php | 8 +++--- tests/outagedb_test.php | 42 +++++++++++++++--------------- version.php | 2 +- 9 files changed, 115 insertions(+), 58 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 21512e5..f3132ad 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -16,6 +16,8 @@ namespace auth_outage\forms\outage; +use \auth_outage\models\outage; + if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } @@ -53,7 +55,7 @@ class edit extends \moodleform { 'text', 'title', get_string('title', 'auth_outage'), - 'maxlength="'.self::TITLE_MAX_CHARS.'"' + 'maxlength="' . self::TITLE_MAX_CHARS . '"' ); $mform->setType('title', PARAM_TEXT); @@ -74,8 +76,8 @@ class edit extends \moodleform { public function validation($data, $files) { $errors = parent::validation($data, $files); - if ($data['stoptime'] <= $data['starttime']) { - $errors['stoptime'] = get_string('stoptimeerrornotafterstart', 'auth_outage'); + if ($data['outageduration'] <= 0) { + $errors['outageduration'] = get_string('outagedurationerrorinvalid', 'auth_outage'); } if ($data['warningduration'] <= 0) { $errors['warningduration'] = get_string('warningdurationerrorinvalid', 'auth_outage'); @@ -92,4 +94,44 @@ class edit extends \moodleform { return $errors; } + /** + * Return submitted data if properly submitted or returns NULL if validation fails. + * @return outage submitted data; NULL if not valid or not submitted or cancelled + */ + public function get_data() { + // Fetch data and check if description is the correct format. + $data = parent::get_data(); + if (is_null($data)) { + return null; + } + if ($data->description['format'] != '1') { + debugging('Not implemented for format ' . $data->description['format'], DEBUG_DEVELOPER); + return null; + } + // Return an outage. + return new outage([ + 'id' => ($data->id === 0) ? null : $data->id, + 'starttime' => $data->starttime, + 'stoptime' => $data->starttime + $data->outageduration, + 'warntime' => $data->starttime - $data->warningduration, + 'title' => $data->title, + 'description' => $data->description['text'] + ]); + } + + /** + * Load in existing outage as form defaults. + * + * @param outage $outage outage object with default values + */ + public function set_data(outage $outage) { + $this->_form->setDefaults([ + 'id' => $outage->id, + 'starttime' => $outage->starttime, + 'outageduration' => $outage->stoptime - $outage->starttime, + 'warningduration' => $outage->starttime - $outage->warntime, + 'title' => $outage->title, + 'description' => ['text' => $outage->description, 'format' => '1'] + ]); + } } \ No newline at end of file diff --git a/classes/models/outage.php b/classes/models/outage.php index 6eb68fc..80abaed 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -97,6 +97,11 @@ class outage { throw new \InvalidArgumentException('$data must be null (default), an array or an object.'); } + /** + * Checks if the outage is happening. + * @param int|null $time Null to check if the outage is happening now or another time to use as reference. + * @return bool True if outage has started but not yet stopped. False otherwise including if in warning period. + */ public function is_ongoing($time = null) { if ($time === null) { $time = time(); @@ -110,4 +115,39 @@ class outage { return (($this->starttime <= $time) && ($time < $this->stoptime)); } + + /** + * Get the title with properly replaced placeholders such as {{start}} and {{stop}}. + * @return string Title. + */ + public function get_title() { + return $this->replace_placeholders($this->title); + } + + /** + * Get the description with properly replaced placeholders such as {{start}} and {{stop}}. + * @return string Description. + */ + public function get_description() { + return $this->replace_placeholders($this->description); + } + + /** + * Returns the input string with all placeholders replaced. + * @param $str string Input string. + * @return string Output string. + */ + private function replace_placeholders($str) { + return str_replace( + [ + '{{start}}', + '{{stop}}' + ], + [ + userdate($this->starttime, get_string('strftimedatetimeshort')), + userdate($this->stoptime, get_string('strftimedatetimeshort')), + ], + $str + ); + } } \ No newline at end of file diff --git a/classes/outagelib.php b/classes/outagelib.php index cbe46f5..7c63d02 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -98,21 +98,4 @@ class outagelib { } } } - - /** - * Parses data from the form ensuring it is valid for an outage object. - * - * @param $data stdClass The input data. - * @return stdClass The parsed data. - */ - public static function parseformdata(\stdClass $data) { - if ($data->description['format'] != '1') { - throw new \InvalidArgumentException('Not implemented for format ' . $data->description['format']); - } - if ($data->id === 0) { - $data->id = null; - } - $data->description = $data->description['text']; - return $data; - } } \ No newline at end of file diff --git a/edit.php b/edit.php index 0e44b3e..c8c177e 100644 --- a/edit.php +++ b/edit.php @@ -37,9 +37,7 @@ $mform = new \auth_outage\forms\outage\edit(); if ($mform->is_cancelled()) { redirect('/auth/outage/manage.php'); -} else if ($fromform = $mform->get_data()) { - $fromform = outagelib::parseformdata($fromform); - $outage = new outage($fromform); +} else if ($outage = $mform->get_data()) { $id = outagedb::save($outage); redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } @@ -49,9 +47,7 @@ $outage = outagedb::get_by_id($id); if ($outage == null) { throw new invalid_parameter_exception('Outage #' . $id . ' not found.'); } -$data = get_object_vars($outage); -$data['description'] = ['text' => $data['description'], 'format' => '1']; -$mform->set_data($data); +$mform->set_data($outage); $PAGE->navbar->add($outage->title); echo $OUTPUT->header(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 82cf8c9..524eef9 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -46,13 +46,13 @@ $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageduration'] = 'Outage Duration'; +$string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage manager'; $string['starttime'] = 'Start date and time'; -$string['stoptimeerrornotafterstart'] = 'Stop time must be after start time.'; $string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; $string['title'] = 'Title'; -$string['warningdurationerrorinvalid'] = 'Warning duration cannot be zero.'; +$string['warningdurationerrorinvalid'] = 'Warning duration must be positive.'; $string['warningduration'] = 'Warning duration'; diff --git a/new.php b/new.php index b60d9d9..06a5f79 100644 --- a/new.php +++ b/new.php @@ -36,9 +36,7 @@ outagelib::pagesetup(); $mform = new \auth_outage\forms\outage\edit(); if ($mform->is_cancelled()) { redirect('/auth/outage/manage.php'); -} else if ($fromform = $mform->get_data()) { - $fromform = outagelib::parseformdata($fromform); - $outage = new outage($fromform); +} else if ($outage = $mform->get_data()) { $id = outagedb::save($outage); redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } diff --git a/renderer.php b/renderer.php index c0928c9..573be9c 100644 --- a/renderer.php +++ b/renderer.php @@ -146,14 +146,12 @@ class auth_outage_renderer extends plugin_renderer_base { . html_writer::tag('b', ' Until: ') . $stop ) - . html_writer::div($outage->description) + . html_writer::div($outage->get_description()) . $admin ); } public function renderoutagebar(outage $outage) { - global $CFG; - $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); @@ -164,10 +162,10 @@ class auth_outage_renderer extends plugin_renderer_base { ); return - html_writer::tag('style', $CFG->auth_outage_css) + html_writer::tag('style', get_config('auth_outage', 'css')) . html_writer::div( html_writer::div( - html_writer::div($outage->title, 'auth_outage_warningbar_box_title') + html_writer::div($outage->get_title(), 'auth_outage_warningbar_box_title') . html_writer::div( $message . ' ' . html_writer::tag('small', diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 0c01258..c694d56 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -110,7 +110,7 @@ class outagedb_test extends advanced_testcase { $inserted = outagedb::get_by_id($id); self::assertNotNull($inserted); // Check its data. - foreach (['starttime', 'stoptime', 'warningduration', 'title', 'description'] as $field) { + foreach (['starttime', 'stoptime', 'warntime', 'title', 'description'] as $field) { self::assertSame($outage->$field, $inserted->$field, 'Field ' . $field . ' does not match.'); } // Check generated data. @@ -138,36 +138,35 @@ class outagedb_test extends advanced_testcase { // Have a consistent time for now (no seconds variation), helps debugging. $now = time(); - // Should never fail. self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); self::assertNull(outagedb::get_active($now), 'There should be no active outage at this point.'); - // An outage that starts in the future and is not in warning period. - self::saveoutage($now, 2, 3, 1); + self::saveoutage($now, 1, 2, 3, + 'An outage that starts in the future and is not in warning period.'); self::assertNull(outagedb::get_active($now), 'No active outages yet.'); - // An outage that is already in the past. - self::saveoutage($now, -3, -2, 1); + self::saveoutage($now, -3, -2, -1, + 'An outage that is already in the past.'); self::assertNull(outagedb::get_active($now), 'No active outages yet.'); - // An outage in warning period. - $activeid = self::saveoutage($now, 1, 2, 2); + $activeid = self::saveoutage($now, -2, 1, 2, + 'An outage in warning period.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); - // Another outage in warning period, but ignored as it starts after the previous one. - self::saveoutage($now, 2, 3, 3); + self::saveoutage($now, -1, 2, 3, + 'Another outage in warning period, but ignored as it starts after the previous one.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); - // An ongoing outage. - $activeid = self::saveoutage($now, -2, 2, 1); + $activeid = self::saveoutage($now, -3, -2, 2, + 'An ongoing outage.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); - // Another ongoing outage but ignored because it started after the previous one. - self::saveoutage($now, -1, 2, 1); + self::saveoutage($now, -3, -1, 1, + 'Another ongoing outage but ignored because it started after the previous one.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); - // Another ongoing outage starting at the same time, but ignored as it stops before the previous one. - self::saveoutage($now, -2, 1, 1); + self::saveoutage($now, -3, -2, 1, + 'Another ongoing outage starting at the same time, but ignored as it stops before the previous one.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); } @@ -175,17 +174,18 @@ class outagedb_test extends advanced_testcase { * Helper function to create an outage then save it to the database. * * @param $now int Timestamp for now, such as time(). + * @param $warning int In how many hours the warning starts. Can be negative. * @param $start int In how many hours this outage starts. Can be negative. * @param $stop int In how many hours this outage finishes. Can be negative. - * @param $warning int Warning duration in hours. + * @param $title string Title for the outage. * @return int Id the of created outage. */ - private static function saveoutage($now, $start, $stop, $warning) { + private static function saveoutage($now, $warning, $start, $stop, $title) { return outagedb::save(new outage([ 'starttime' => $now + ($start * 60 * 60), 'stoptime' => $now + ($stop * 60 * 60), - 'warntime' => $now - ($warning * 60 * 60), - 'title' => 'Test Outage', + 'warntime' => $now + ($warning * 60 * 60), + 'title' => $title, 'description' => 'Test Outage Description.' ])); } @@ -200,7 +200,7 @@ class outagedb_test extends advanced_testcase { return new outage([ 'starttime' => $i * 100, 'stoptime' => $i * 100 + 50, - 'warningduration' => $i * 60, + 'warntime' => $i * 60, 'title' => 'The Title ' . $i, 'description' => 'A description in HTML.' ]); diff --git a/version.php b/version.php index 6a77e7d..c7db839 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } -$plugin->version = 2016090500; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2016090900; // The current plugin version (Date: YYYYMMDDXX). $plugin->release = $plugin->version; // Same as version $plugin->requires = 2014051200; // Requires Moodle 2.7 or later. $plugin->component = "auth_outage"; From b1e001427b7a2311df3bec913796d9d29ab70b94 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 9 Sep 2016 11:45:20 +1000 Subject: [PATCH 27/72] Fixed missing newlines at end of files. Issue #17. --- classes/forms/outage/edit.php | 2 +- classes/models/outage.php | 2 +- classes/outagelib.php | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index f3132ad..08967f0 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -134,4 +134,4 @@ class edit extends \moodleform { 'description' => ['text' => $outage->description, 'format' => '1'] ]); } -} \ No newline at end of file +} diff --git a/classes/models/outage.php b/classes/models/outage.php index 80abaed..a799078 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -150,4 +150,4 @@ class outage { $str ); } -} \ No newline at end of file +} diff --git a/classes/outagelib.php b/classes/outagelib.php index 7c63d02..bded0da 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -98,4 +98,4 @@ class outagelib { } } } -} \ No newline at end of file +} From 64d09721cf3892e317e6ce4c0aebfa2aa930d1f1 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 9 Sep 2016 12:12:52 +1000 Subject: [PATCH 28/72] Fixed form edit set_data() signature. Issue #17. --- classes/forms/outage/edit.php | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 08967f0..ee21a6b 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -124,14 +124,18 @@ class edit extends \moodleform { * * @param outage $outage outage object with default values */ - public function set_data(outage $outage) { - $this->_form->setDefaults([ - 'id' => $outage->id, - 'starttime' => $outage->starttime, - 'outageduration' => $outage->stoptime - $outage->starttime, - 'warningduration' => $outage->starttime - $outage->warntime, - 'title' => $outage->title, - 'description' => ['text' => $outage->description, 'format' => '1'] - ]); + public function set_data($outage) { + if ($outage instanceof outage) { + $this->_form->setDefaults([ + 'id' => $outage->id, + 'starttime' => $outage->starttime, + 'outageduration' => $outage->stoptime - $outage->starttime, + 'warningduration' => $outage->starttime - $outage->warntime, + 'title' => $outage->title, + 'description' => ['text' => $outage->description, 'format' => '1'] + ]); + } else { + throw new \InvalidArgumentException('$default_values must be an outage object.'); + } } } From 4c4df5340263d832fc37405a843b60c9238c4e79 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 9 Sep 2016 15:57:24 +1000 Subject: [PATCH 29/72] Issue #15 - Added countdown to navbar, redirect to info.php when ended. --- lang/en/auth_outage.php | 3 +- renderer.php | 26 ++---- settings.php | 2 +- res/default.css => views/warningbar.css | 0 views/warningbar.php | 104 ++++++++++++++++++++++++ 5 files changed, 114 insertions(+), 21 deletions(-) rename res/default.css => views/warningbar.css (100%) create mode 100644 views/warningbar.php diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 524eef9..08c8555 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -40,7 +40,7 @@ $string['description'] = 'Public Description'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; -$string['messageoutagewarning'] = 'There is an scheduled downtime from {$a->start} until {$a->stop}.'; +$string['messageoutagewarning'] = 'Shutting down in {{countdown}} ...'; $string['outageedit'] = 'Edit Outage'; $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; @@ -49,6 +49,7 @@ $string['outageduration'] = 'Outage Duration'; $string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage manager'; +$string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; $string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; diff --git a/renderer.php b/renderer.php index 573be9c..4ca1f9e 100644 --- a/renderer.php +++ b/renderer.php @@ -152,6 +152,8 @@ class auth_outage_renderer extends plugin_renderer_base { } public function renderoutagebar(outage $outage) { + global $CFG; + $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); @@ -161,24 +163,10 @@ class auth_outage_renderer extends plugin_renderer_base { ['start' => $start, 'stop' => $stop] ); - return - html_writer::tag('style', get_config('auth_outage', 'css')) - . html_writer::div( - html_writer::div( - html_writer::div($outage->get_title(), 'auth_outage_warningbar_box_title') - . html_writer::div( - $message . ' ' - . html_writer::tag('small', - '[' . html_writer::link( - new moodle_url('/auth/outage/info.php'), 'more', ['target' => 'outage'] - ) . ']' - ), - 'auth_outage_warningbar_box_message' - ), - 'auth_outage_warningbar_box' - ), - 'auth_outage_warningbar' - ) - . html_writer::div(' ', 'auth_outage_warningbar_spacer'); + ob_start(); + require($CFG->dirroot . '/auth/outage/views/warningbar.php'); + $html = ob_get_contents(); + ob_end_clean(); + return $html; } } diff --git a/settings.php b/settings.php index b6c0b94..3df7fc2 100644 --- a/settings.php +++ b/settings.php @@ -55,7 +55,7 @@ if ($hassiteconfig && is_enabled_auth('outage')) { new admin_setting_configtextarea('auth_outage/css', get_string('defaultlayoutcss', 'auth_outage'), get_string('defaultlayoutcssdescription', 'auth_outage'), - file_get_contents($CFG->dirroot.'/auth/outage/res/default.css'), + file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css'), PARAM_TEXT) ); // Create category for Outage. diff --git a/res/default.css b/views/warningbar.css similarity index 100% rename from res/default.css rename to views/warningbar.css diff --git a/views/warningbar.php b/views/warningbar.php new file mode 100644 index 0000000..fc26212 --- /dev/null +++ b/views/warningbar.php @@ -0,0 +1,104 @@ +. + +/** + * View included by the renderer to output the outage warning bar. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. +} + +// If debugging include directly from file, otherwise use plugin settings. +echo html_writer::tag('style', + debugging() ? file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css') : get_config('auth_outage', 'css') +); + +?> + +
+
+
get_title(); ?>
+
+ + + [ 'outage'] + ); ?>] + +
+
+
+ + + +
 
From cf78e2e18013c41d9370f42b4d9aaecc1a731f3d Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 9 Sep 2016 16:22:19 +1000 Subject: [PATCH 30/72] Issue #15 - If admin, do not redirect -- stop at 0:00:00. --- views/warningbar.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/views/warningbar.php b/views/warningbar.php index fc26212..e4b9333 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -77,7 +77,12 @@ echo html_writer::tag('style', var missing = this.countdown - elapsed; if (missing <= 0) { clearInterval(this.timer); - location.href = ''; + missing = 0; + } else { this.span.innerHTML = this.text.replace('{{countdown}}', this.seconds2hms(missing)); From 4462a9c07b19adc899cb5c90a7bc652dc05c41ce Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 09:53:50 +1000 Subject: [PATCH 31/72] Bugfix - Fixed problem preventing the creation of a new outage. --- classes/forms/outage/edit.php | 2 +- new.php | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index ee21a6b..e43aa57 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -135,7 +135,7 @@ class edit extends \moodleform { 'description' => ['text' => $outage->description, 'format' => '1'] ]); } else { - throw new \InvalidArgumentException('$default_values must be an outage object.'); + throw new \InvalidArgumentException('$outage must be an outage object.'); } } } diff --git a/new.php b/new.php index 06a5f79..f9f3305 100644 --- a/new.php +++ b/new.php @@ -42,13 +42,13 @@ if ($mform->is_cancelled()) { } $config = get_config('auth_outage'); -$defaults = [ +$defaults = new outage([ 'starttime' => time(), - 'outageduration' => ($config->default_duration * 60), - 'warningduration' => ($config->warning_duration * 60), + 'stoptime' => time() + ($config->default_duration * 60), + 'warntime' => time() - ($config->warning_duration * 60), 'title' => $config->warning_title, - 'description' => ['text' => $config->warning_description, 'format' => '1'] -]; + 'description' => $config->warning_description +]); $mform->set_data($defaults); $PAGE->navbar->add(get_string('outagecreate', 'auth_outage')); From 7edefc84624e49d4ffb9689127f2c379dab0f624 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 09:55:57 +1000 Subject: [PATCH 32/72] BugFix - Fixed a problem that would not allow admins to use the system while in maintenance. --- views/warningbar.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/warningbar.php b/views/warningbar.php index e4b9333..3638823 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -79,7 +79,7 @@ echo html_writer::tag('style', clearInterval(this.timer); missing = 0; From 96f9bb7ec268dc3c00f75a0a1cd88232d1ceabbd Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 11:12:12 +1000 Subject: [PATCH 33/72] Issue #11 - Standard manage table implemented, all outages still on the same table. --- classes/forms/outage/edit.php | 4 +- classes/models/outage.php | 55 ++++++++++++++++++++++++ classes/tables/manage.php | 79 +++++++++++++++++++++++++++++++++++ lang/en/auth_outage.php | 9 ++++ manage.php | 4 +- renderer.php | 25 ----------- 6 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 classes/tables/manage.php diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index e43aa57..a4e8152 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -129,8 +129,8 @@ class edit extends \moodleform { $this->_form->setDefaults([ 'id' => $outage->id, 'starttime' => $outage->starttime, - 'outageduration' => $outage->stoptime - $outage->starttime, - 'warningduration' => $outage->starttime - $outage->warntime, + 'outageduration' => $outage->get_duration(), + 'warningduration' => $outage->get_warning_duration(), 'title' => $outage->title, 'description' => ['text' => $outage->description, 'format' => '1'] ]); diff --git a/classes/models/outage.php b/classes/models/outage.php index a799078..ab24dd3 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -28,6 +28,29 @@ namespace auth_outage\models; use auth_outage\outagelib; class outage { + private static function get_seconds_duration_string($duration) { + if (!is_int($duration)) { + throw new \InvalidArgumentException('$seconds must be an int.'); + } + + if (($duration < 60) || ($duration % 60 != 0)) { + return $duration . ' ' . get_string('durationseconds', 'auth_outage'); + } + + $duration /= 60; + if (($duration < 60) || ($duration % 60 != 0)) { + return $duration . ' ' . get_string('durationminutes', 'auth_outage'); + } + + $duration /= 60; + if (($duration < 60) || ($duration % 24 != 0)) { + return $duration . ' ' . get_string('durationhours', 'auth_outage'); + } + + $duration /= 24; + return $duration . ' ' . get_string('durationdays', 'auth_outage'); + } + /** * @var int Outage ID (auto generated by the DB). */ @@ -150,4 +173,36 @@ class outage { $str ); } + + /** + * Gets the duration of the outage (start to stop, warning not included). + * @return int Duration in seconds. + */ + public function get_duration() { + return $this->stoptime - $this->starttime; + } + + /** + * Gets the duration of the outage (start to stop, warning not included). + * @return string The duration as text, for example '6 hour(s)'. + */ + public function get_duration_string() { + return self::get_seconds_duration_string($this->get_duration()); + } + + /** + * Gets the warning duration from the outage (from warning time to start time). + * @return int Warning duration in seconds. + */ + public function get_warning_duration() { + return $this->starttime - $this->warntime; + } + + /** + * Gets the warning duration from the outage (from warning time to start time). + * @return string The warning duration as text, for example '6 hour(s)'. + */ + public function get_warning_duration_string() { + return self::get_seconds_duration_string($this->get_warning_duration()); + } } diff --git a/classes/tables/manage.php b/classes/tables/manage.php new file mode 100644 index 0000000..a34e32b --- /dev/null +++ b/classes/tables/manage.php @@ -0,0 +1,79 @@ +. + +namespace auth_outage\tables; + +require_once($CFG->libdir . '/tablelib.php'); + +/** + * Manage outages table. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manage extends \flexible_table { + private static $autoid = 0; + + public function __construct($id = null) { + global $PAGE; + + $id = (is_null($id) ? self::$autoid++ : $id); + parent::__construct('auth_outage_manage_' . $id); + + $this->define_columns(['starttime', 'stopsafter', 'warnbefore', 'title', '']); + + $this->define_headers([ + get_string('tableheaderstarttime', 'auth_outage'), + get_string('tableheaderstopsafter', 'auth_outage'), + get_string('tableheaderwarnbefore', 'auth_outage'), + get_string('tableheadertitle', 'auth_outage'), + '', + ] + ); + + $this->define_baseurl($PAGE->url); + $this->set_attribute('class', 'generaltable admintable'); + $this->setup(); + } + + public function set_data(array $outages) { + global $OUTPUT; + foreach ($outages as $outage) { + $buttons = ''; + + $url = new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]); + $html = \html_writer::empty_tag('img', array('src' => $OUTPUT->pix_url('t/edit'), 'alt' => get_string('edit'), 'class' => 'iconsmall')); + $buttons .= \html_writer::link($url, $html, array('title' => get_string('edit'))); + + $url = new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]); + $html = \html_writer::empty_tag('img', array('src' => $OUTPUT->pix_url('t/delete'), 'alt' => get_string('delete'), 'class' => 'iconsmall')); + $buttons .= \html_writer::link($url, $html, array('title' => get_string('delete'))); + + // Table columns 'name', 'action', 'role', 'parent', 'continue', 'priority', 'data'. + $values = [ + userdate($outage->starttime, get_string('tablerowstarts', 'auth_outage')), + $outage->get_duration_string(), + $outage->get_warning_duration_string(), + $outage->get_title(), + $buttons, + ]; + + $this->add_data($values); + } + } +} \ No newline at end of file diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 08c8555..14cc147 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -37,6 +37,10 @@ $string['defaultwarningdescription'] = 'Description'; $string['defaultwarningdescriptiondescription'] = 'Default warning message for outages. Use {{start}} and {{stop}} placeholders as required.'; $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; +$string['durationseconds'] = 'second(s)'; +$string['durationminutes'] = 'minutes(s)'; +$string['durationhours'] = 'hour(s)'; +$string['durationdays'] = 'day(s)'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; @@ -51,6 +55,11 @@ $string['outageslist'] = 'Outages List'; $string['pluginname'] = 'Outage manager'; $string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; +$string['tableheaderstarttime'] = 'Starts on'; +$string['tableheaderstopsafter'] = 'Stops after'; +$string['tableheaderwarnbefore'] = 'Warns before'; +$string['tableheadertitle'] = 'Title'; +$string['tablerowstarts'] = '%d/%m/%Y %H:%M'; $string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; diff --git a/manage.php b/manage.php index d5af095..f695786 100644 --- a/manage.php +++ b/manage.php @@ -33,6 +33,8 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -echo $renderer->renderoutagelist(outagedb::get_all()); +$table = new \auth_outage\tables\manage(); +$table->set_data(outagedb::get_all()); +echo $table->finish_output(); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index 4ca1f9e..dfb8b9c 100644 --- a/renderer.php +++ b/renderer.php @@ -43,31 +43,6 @@ class auth_outage_renderer extends plugin_renderer_base { . $this->renderoutage($outage, false); } - public function renderoutagelist(array $outages) { - global $OUTPUT; - - $html = $this->rendersubtitle('outageslist'); - - // Generate list of outages. - foreach ($outages as $outage) { - $html .= $this->renderoutage($outage, true); - } - - // Add 'add' button. - $url = new moodle_url('/auth/outage/new.php'); - $img = html_writer::empty_tag('img', - ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); - $html .= html_writer::tag('p', - html_writer::link( - $url, - $img . ' ' . get_string('outagecreate', 'auth_outage'), - ['title' => get_string('delete')] - ) - ); - - return $html; - } - private function renderoutage(outage $outage, $buttons) { global $OUTPUT; From 0d8ab19f835c27999f99e983e78b5c67a81e480d Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 11:28:56 +1000 Subject: [PATCH 34/72] Issue #11 - Renderer refactored. --- classes/tables/manage.php | 34 ++++++++++++++++++++-------------- manage.php | 4 +--- renderer.php | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 17 deletions(-) diff --git a/classes/tables/manage.php b/classes/tables/manage.php index a34e32b..da5fd11 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -54,26 +54,32 @@ class manage extends \flexible_table { public function set_data(array $outages) { global $OUTPUT; foreach ($outages as $outage) { - $buttons = ''; + $buttons = \html_writer::link( + new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/edit'), + 'alt' => get_string('edit'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('edit')] + ) + . \html_writer::link( + new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/delete'), + 'alt' => get_string('delete'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('delete')] + ); - $url = new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]); - $html = \html_writer::empty_tag('img', array('src' => $OUTPUT->pix_url('t/edit'), 'alt' => get_string('edit'), 'class' => 'iconsmall')); - $buttons .= \html_writer::link($url, $html, array('title' => get_string('edit'))); - - $url = new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]); - $html = \html_writer::empty_tag('img', array('src' => $OUTPUT->pix_url('t/delete'), 'alt' => get_string('delete'), 'class' => 'iconsmall')); - $buttons .= \html_writer::link($url, $html, array('title' => get_string('delete'))); - - // Table columns 'name', 'action', 'role', 'parent', 'continue', 'priority', 'data'. - $values = [ + $this->add_data([ userdate($outage->starttime, get_string('tablerowstarts', 'auth_outage')), $outage->get_duration_string(), $outage->get_warning_duration_string(), $outage->get_title(), $buttons, - ]; - - $this->add_data($values); + ]); } } } \ No newline at end of file diff --git a/manage.php b/manage.php index f695786..d2e103a 100644 --- a/manage.php +++ b/manage.php @@ -33,8 +33,6 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -$table = new \auth_outage\tables\manage(); -$table->set_data(outagedb::get_all()); -echo $table->finish_output(); +$renderer->renderoutagelist(outagedb::get_all()); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index dfb8b9c..e563b8c 100644 --- a/renderer.php +++ b/renderer.php @@ -43,6 +43,33 @@ class auth_outage_renderer extends plugin_renderer_base { . $this->renderoutage($outage, false); } + /** + * Outputs the HTML data listing all given outages. + * @param array $outages Outages to list. + */ + public function renderoutagelist(array $outages) { + global $OUTPUT; + + echo $this->rendersubtitle('outageslist'); + + // Generate list of outages. + $table = new \auth_outage\tables\manage(); + $table->set_data($outages); + $table->finish_output(); // It will output HTML. + + // Add 'add' button. + $url = new moodle_url('/auth/outage/new.php'); + $img = html_writer::empty_tag('img', + ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); + echo html_writer::tag('p', + html_writer::link( + $url, + $img . ' ' . get_string('outagecreate', 'auth_outage'), + ['title' => get_string('delete')] + ) + ); + } + private function renderoutage(outage $outage, $buttons) { global $OUTPUT; @@ -126,6 +153,12 @@ class auth_outage_renderer extends plugin_renderer_base { ); } + /** + * Renders the warning bar. + * @param outage $outage The outage to show in the warning bar. + * @return string HTML of the warning bar. + * @SuppressWarnings("unused") because $message is used inside require(...) + */ public function renderoutagebar(outage $outage) { global $CFG; From e13bc05f5edc471fd60cb889e9816e2d2c04fa4a Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 11:49:11 +1000 Subject: [PATCH 35/72] Issue #11 - Tests for outagedb get_all_active. --- tests/outagedb_test.php | 48 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index c694d56..770ba1d 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -30,6 +30,18 @@ defined('MOODLE_INTERNAL') || die(); class outagedb_test extends advanced_testcase { + /** + * Creates an array of ids in from the given outages array. + * @param $outages + */ + private static function createidarray(array $outages) { + $ids = []; + foreach ($outages as $outage) { + $ids[] = $outage->id; + } + return $ids; + } + /** * Make sure we can save and update. */ @@ -170,6 +182,42 @@ class outagedb_test extends advanced_testcase { self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); } + public function test_getallactive() { + $this->resetAfterTest(true); + + // Have a consistent time for now (no seconds variation), helps debugging. + $now = time(); + + self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); + self::assertEquals([], outagedb::get_all_active($now), 'There should be no active outages at this point.'); + + self::saveoutage($now, 1, 2, 3, 'An outage that starts in the future and is not in warning period.'); + self::assertEquals([], outagedb::get_all_active($now), 'No active outages yet.'); + + self::saveoutage($now, -3, -2, -1, 'An outage that is already in the past.'); + self::assertEquals([], outagedb::get_all_active($now), 'No active outages yet.'); + + $id1 = self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); + self::assertEquals([$id1], + self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); + + $id2 = self::saveoutage($now, -1, 2, 3, 'Another outage in warning period.'); + self::assertEquals([$id1, $id2], + self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); + + $id3 = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); + self::assertEquals([$id3, $id1, $id2], + self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); + + $id4 = self::saveoutage($now, -3, -1, 1, 'Another ongoing outage.'); + self::assertEquals([$id3, $id4, $id1, $id2], + self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); + + $id5 = self::saveoutage($now, -3, -2, 1, 'Yet another ongoing outage.'); + self::assertEquals([$id3, $id5, $id4, $id1, $id2], + self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); + } + /** * Helper function to create an outage then save it to the database. * From 9f2c5b08d867041d0099445d913bea1e120275d5 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 11:53:42 +1000 Subject: [PATCH 36/72] Issue #11 - Solved tests for outagedb get_all_active. --- classes/outagedb.php | 37 ++++++++++++++++++++++++++++++++++--- 1 file changed, 34 insertions(+), 3 deletions(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index 99fd2d4..b2d7221 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -153,9 +153,6 @@ class outagedb { throw new \InvalidArgumentException('$time must be null or an int.'); } - // TODO Query not fully using indexes (starttime + 90) - // Gets any active outage (already started or during warning period). - // Gets only one record if available, the one that starts(ed) first and that stops last. $data = $DB->get_records_select( 'auth_outage', '(warntime <= :datetime1 AND stoptime >= :datetime2)', @@ -170,4 +167,38 @@ class outagedb { // Allowing multiple records still raises an internal error. return (count($data) == 0) ? null : new \auth_outage\models\outage(array_shift($data)); } + + /** + * Gets all active outages, sorted by importance as: + * - Ongoing outages more important than outages in warning period. + * - Outages that start earlier are more important. + * - Outages that stop later are more important. + * @param int|null $time Timestamp considered to check for outages, null for current date/time. + * @return array An array of outages or an empty array if no active outage found. + */ + public static function get_all_active($time = null) { + global $DB; + + if ($time === null) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time must be null or an int.'); + } + + $outages = []; + + $rs = $DB->get_recordset_select( + 'auth_outage', + '(warntime <= :datetime1 AND stoptime >= :datetime2)', + ['datetime1' => $time, 'datetime2' => $time], + 'starttime ASC, stoptime DESC, title ASC', + '*'); + foreach ($rs as $r) { + $outages[] = new outage($r); + } + $rs->close(); + + return $outages; + } } From 22f85930ce0b0bdf367c6c8d9253b1d23dcef407 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 12:10:22 +1000 Subject: [PATCH 37/72] Issue #11 - TDD to get future and past outages (outagedb). --- classes/outagedb.php | 68 ++++++++++++++++++++++++++++++++++++++--- tests/outagedb_test.php | 62 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index b2d7221..e211796 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -169,10 +169,7 @@ class outagedb { } /** - * Gets all active outages, sorted by importance as: - * - Ongoing outages more important than outages in warning period. - * - Outages that start earlier are more important. - * - Outages that stop later are more important. + * Gets all active outages (including in warning period). * @param int|null $time Timestamp considered to check for outages, null for current date/time. * @return array An array of outages or an empty array if no active outage found. */ @@ -201,4 +198,67 @@ class outagedb { return $outages; } + + /** + * Gets all future outages not in warning period. + * @param int|null $time Timestamp considered to check for outages, null for current date/time. + * @return array An array of outages or an empty array if no future outage found. + */ + public static function get_all_future($time = null) { + global $DB; + + if ($time === null) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time must be null or an int.'); + } + + $outages = []; + + $rs = $DB->get_recordset_select( + 'auth_outage', + 'warntime > :datetime', + ['datetime' => $time], + 'starttime ASC, stoptime DESC, title ASC', + '*'); + foreach ($rs as $r) { + $outages[] = new outage($r); + } + $rs->close(); + + return $outages; + } + + + /** + * Gets all past outages. + * @param int|null $time Timestamp considered to check for outages, null for current date/time. + * @return array An array of outages or an empty array if no past outage found. + */ + public static function get_all_past($time = null) { + global $DB; + + if ($time === null) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time must be null or an int.'); + } + + $outages = []; + + $rs = $DB->get_recordset_select( + 'auth_outage', + 'stoptime < :datetime', + ['datetime' => $time], + 'stoptime DESC, starttime DESC, title ASC', + '*'); + foreach ($rs as $r) { + $outages[] = new outage($r); + } + $rs->close(); + + return $outages; + } } diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 770ba1d..976ae11 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -218,6 +218,68 @@ class outagedb_test extends advanced_testcase { self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); } + public function test_getallfuture() { + $this->resetAfterTest(true); + + // Have a consistent time for now (no seconds variation), helps debugging. + $now = time(); + + self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); + self::assertEquals([], outagedb::get_all_future($now), 'There should be no future outages at this point.'); + + self::saveoutage($now, -3, -2, -1, 'A past outage.'); + self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); + + self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); + self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); + + self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); + self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); + + $id1 = self::saveoutage($now, 2, 3, 4, 'A future outage.'); + self::assertEquals([$id1], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id2 = self::saveoutage($now, 1, 4, 5, 'Another future outage.'); + self::assertEquals([$id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id3 = self::saveoutage($now, 1, 3, 5, 'Yet another future outage.'); + self::assertEquals([$id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + } + + public function test_getallpast() { + $this->resetAfterTest(true); + + // Have a consistent time for now (no seconds variation), helps debugging. + $now = time(); + + self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); + self::assertEquals([], outagedb::get_all_past($now), 'There should be no future outages at this point.'); + + self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); + self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + + self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); + self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + + self::saveoutage($now, 2, 3, 4, 'A future outage.'); + self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + + $id1 = self::saveoutage($now, -8, -6, -4, 'A past outage.'); + self::assertEquals([$id1], + self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + + $id2 = self::saveoutage($now, -8, -7, -5, 'Another past outage.'); + self::assertEquals([$id1, $id2], + self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + + $id3 = self::saveoutage($now, -8, -5, -3, 'Yet another past outage.'); + self::assertEquals([$id3, $id1, $id2], + self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + } + /** * Helper function to create an outage then save it to the database. * From 25abb3ae82d5148a5ef5feea2c2fd5ffc11ffd6b Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 12:58:16 +1000 Subject: [PATCH 38/72] Issue #11 - Manage lists outages in separate tables. --- auth.php | 4 +-- classes/tables/manage.php | 55 ++++++++++++++++++++++++++------------- info.php | 7 ++--- lang/en/auth_outage.php | 5 +++- manage.php | 2 +- renderer.php | 46 +++++++++++++++++++++++--------- 6 files changed, 81 insertions(+), 38 deletions(-) diff --git a/auth.php b/auth.php index ede7ad8..6a12f9c 100644 --- a/auth.php +++ b/auth.php @@ -43,8 +43,8 @@ class auth_plugin_outage extends auth_plugin_base } /** - * @param string $username Not unsed in this plugin. - * @param string $password Not unsed in this plugin. + * @param string $username Not used in this plugin. + * @param string $password Not used in this plugin. * @return bool False * @SuppressWarnings("unused") */ diff --git a/classes/tables/manage.php b/classes/tables/manage.php index da5fd11..564117a 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -51,27 +51,46 @@ class manage extends \flexible_table { $this->setup(); } - public function set_data(array $outages) { + public function set_data(array $outages, $editdelete) { global $OUTPUT; + if (!is_bool($editdelete)) { + throw new \InvalidArgumentException('$editdelete must be a bool.'); + } + foreach ($outages as $outage) { $buttons = \html_writer::link( - new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ - 'src' => $OUTPUT->pix_url('t/edit'), - 'alt' => get_string('edit'), - 'class' => 'iconsmall' - ]), - ['title' => get_string('edit')] - ) - . \html_writer::link( - new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ - 'src' => $OUTPUT->pix_url('t/delete'), - 'alt' => get_string('delete'), - 'class' => 'iconsmall' - ]), - ['title' => get_string('delete')] - ); + new \moodle_url('/auth/outage/info.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/preview'), + 'alt' => get_string('view'), + 'class' => 'iconsmall', + + ]), + [ + 'title' => get_string('view'), + 'target' => '_blank', + ] + ); + if ($editdelete) { + $buttons .= \html_writer::link( + new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/edit'), + 'alt' => get_string('edit'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('edit')] + ) + . \html_writer::link( + new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/delete'), + 'alt' => get_string('delete'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('delete')] + ); + } $this->add_data([ userdate($outage->starttime, get_string('tablerowstarts', 'auth_outage')), diff --git a/info.php b/info.php index 4fca52b..1ac03cb 100644 --- a/info.php +++ b/info.php @@ -28,14 +28,15 @@ use auth_outage\outagelib; require_once('../../config.php'); -$outage = outagedb::get_active(); +$id = optional_param('id', null, PARAM_INT); +$outage = is_null($id) ? outagedb::get_active() : outagedb::get_by_id($id); if (is_null($outage)) { redirect(new moodle_url('/')); } $PAGE->set_context(context_system::instance()); -$PAGE->set_title("Outage Warning"); -$PAGE->set_heading("Outage Warning"); +$PAGE->set_title($outage->get_title()); +$PAGE->set_heading($outage->get_title()); $PAGE->set_url(new \moodle_url('/auth/outage/info.php')); echo $OUTPUT->header(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 14cc147..e364bdd 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -45,13 +45,16 @@ $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; $string['messageoutagewarning'] = 'Shutting down in {{countdown}} ...'; +$string['notfound'] = 'No outages found.'; $string['outageedit'] = 'Edit Outage'; $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageduration'] = 'Outage Duration'; $string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; -$string['outageslist'] = 'Outages List'; +$string['outageslistactive'] = 'Active Outages'; +$string['outageslistfuture'] = 'Future Outages'; +$string['outageslistpast'] = 'Past Outages'; $string['pluginname'] = 'Outage manager'; $string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; diff --git a/manage.php b/manage.php index d2e103a..c0fa8fb 100644 --- a/manage.php +++ b/manage.php @@ -33,6 +33,6 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -$renderer->renderoutagelist(outagedb::get_all()); +$renderer->renderoutagelist(outagedb::get_all_active(), outagedb::get_all_future(), outagedb::get_all_past()); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index e563b8c..f7a33e9 100644 --- a/renderer.php +++ b/renderer.php @@ -47,27 +47,47 @@ class auth_outage_renderer extends plugin_renderer_base { * Outputs the HTML data listing all given outages. * @param array $outages Outages to list. */ - public function renderoutagelist(array $outages) { + public function renderoutagelist(array $active, array $future, array $past) { global $OUTPUT; - echo $this->rendersubtitle('outageslist'); + if (!empty($active)) { + echo $this->rendersubtitle('outageslistactive'); + $table = new \auth_outage\tables\manage(); + $table->set_data($active, true); + $table->finish_output(); + } - // Generate list of outages. - $table = new \auth_outage\tables\manage(); - $table->set_data($outages); - $table->finish_output(); // It will output HTML. + echo $this->rendersubtitle('outageslistfuture'); + if (empty($future)) { + echo html_writer::tag('p', html_writer::tag('small', get_string('notfound', 'auth_outage'))); + } else { + $table = new \auth_outage\tables\manage(); + $table->set_data($future, true); + $table->finish_output(); + } + + echo $this->rendersubtitle('outageslistpast'); + if (empty($past)) { + echo html_writer::tag('p', html_writer::tag('small', get_string('notfound', 'auth_outage'))); + } else { + $table = new \auth_outage\tables\manage(); + $table->set_data($past, false); + $table->finish_output(); + } // Add 'add' button. $url = new moodle_url('/auth/outage/new.php'); $img = html_writer::empty_tag('img', ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); - echo html_writer::tag('p', - html_writer::link( - $url, - $img . ' ' . get_string('outagecreate', 'auth_outage'), - ['title' => get_string('delete')] - ) - ); + echo + html_writer::empty_tag('hr') + . html_writer::tag('p', + html_writer::link( + $url, + $img . ' ' . get_string('outagecreate', 'auth_outage'), + ['title' => get_string('delete')] + ) + ); } private function renderoutage(outage $outage, $buttons) { From ee771daecf24797dc9b0d034c5473b252d855c64 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 13:57:33 +1000 Subject: [PATCH 39/72] Issue #11 - Small fixed as suggested by Brendan. --- classes/forms/outage/edit.php | 6 ++-- classes/models/outage.php | 39 ---------------------- classes/outagedb.php | 34 +------------------ classes/tables/manage.php | 17 +++++++--- edit.php | 2 +- lang/en/auth_outage.php | 7 ++-- manage.php | 2 +- renderer.php | 33 +++++++------------ tests/outagedb_test.php | 62 +++++++++++------------------------ 9 files changed, 53 insertions(+), 149 deletions(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index a4e8152..9ac36e1 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -45,17 +45,17 @@ class edit extends \moodleform { $mform->addElement('hidden', 'id'); $mform->setType('id', PARAM_INT); + $mform->addElement('duration', 'warningduration', get_string('warningduration', 'auth_outage')); + $mform->addElement('date_time_selector', 'starttime', get_string('starttime', 'auth_outage')); $mform->addElement('duration', 'outageduration', get_string('outageduration', 'auth_outage')); - $mform->addElement('duration', 'warningduration', get_string('warningduration', 'auth_outage')); - $mform->addElement( 'text', 'title', get_string('title', 'auth_outage'), - 'maxlength="' . self::TITLE_MAX_CHARS . '"' + 'maxlength="' . self::TITLE_MAX_CHARS . '" size="60"' ); $mform->setType('title', PARAM_TEXT); diff --git a/classes/models/outage.php b/classes/models/outage.php index ab24dd3..320f7ab 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -28,29 +28,6 @@ namespace auth_outage\models; use auth_outage\outagelib; class outage { - private static function get_seconds_duration_string($duration) { - if (!is_int($duration)) { - throw new \InvalidArgumentException('$seconds must be an int.'); - } - - if (($duration < 60) || ($duration % 60 != 0)) { - return $duration . ' ' . get_string('durationseconds', 'auth_outage'); - } - - $duration /= 60; - if (($duration < 60) || ($duration % 60 != 0)) { - return $duration . ' ' . get_string('durationminutes', 'auth_outage'); - } - - $duration /= 60; - if (($duration < 60) || ($duration % 24 != 0)) { - return $duration . ' ' . get_string('durationhours', 'auth_outage'); - } - - $duration /= 24; - return $duration . ' ' . get_string('durationdays', 'auth_outage'); - } - /** * @var int Outage ID (auto generated by the DB). */ @@ -182,14 +159,6 @@ class outage { return $this->stoptime - $this->starttime; } - /** - * Gets the duration of the outage (start to stop, warning not included). - * @return string The duration as text, for example '6 hour(s)'. - */ - public function get_duration_string() { - return self::get_seconds_duration_string($this->get_duration()); - } - /** * Gets the warning duration from the outage (from warning time to start time). * @return int Warning duration in seconds. @@ -197,12 +166,4 @@ class outage { public function get_warning_duration() { return $this->starttime - $this->warntime; } - - /** - * Gets the warning duration from the outage (from warning time to start time). - * @return string The warning duration as text, for example '6 hour(s)'. - */ - public function get_warning_duration_string() { - return self::get_seconds_duration_string($this->get_warning_duration()); - } } diff --git a/classes/outagedb.php b/classes/outagedb.php index e211796..2726590 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -168,37 +168,6 @@ class outagedb { return (count($data) == 0) ? null : new \auth_outage\models\outage(array_shift($data)); } - /** - * Gets all active outages (including in warning period). - * @param int|null $time Timestamp considered to check for outages, null for current date/time. - * @return array An array of outages or an empty array if no active outage found. - */ - public static function get_all_active($time = null) { - global $DB; - - if ($time === null) { - $time = time(); - } - if (!is_int($time)) { - throw new \InvalidArgumentException('$time must be null or an int.'); - } - - $outages = []; - - $rs = $DB->get_recordset_select( - 'auth_outage', - '(warntime <= :datetime1 AND stoptime >= :datetime2)', - ['datetime1' => $time, 'datetime2' => $time], - 'starttime ASC, stoptime DESC, title ASC', - '*'); - foreach ($rs as $r) { - $outages[] = new outage($r); - } - $rs->close(); - - return $outages; - } - /** * Gets all future outages not in warning period. * @param int|null $time Timestamp considered to check for outages, null for current date/time. @@ -218,7 +187,7 @@ class outagedb { $rs = $DB->get_recordset_select( 'auth_outage', - 'warntime > :datetime', + 'stoptime >= :datetime', ['datetime' => $time], 'starttime ASC, stoptime DESC, title ASC', '*'); @@ -230,7 +199,6 @@ class outagedb { return $outages; } - /** * Gets all past outages. * @param int|null $time Timestamp considered to check for outages, null for current date/time. diff --git a/classes/tables/manage.php b/classes/tables/manage.php index 564117a..c6fa874 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -38,11 +38,11 @@ class manage extends \flexible_table { $this->define_columns(['starttime', 'stopsafter', 'warnbefore', 'title', '']); $this->define_headers([ + get_string('tableheaderwarnbefore', 'auth_outage'), get_string('tableheaderstarttime', 'auth_outage'), get_string('tableheaderstopsafter', 'auth_outage'), - get_string('tableheaderwarnbefore', 'auth_outage'), get_string('tableheadertitle', 'auth_outage'), - '', + get_string('actions'), ] ); @@ -71,6 +71,7 @@ class manage extends \flexible_table { 'target' => '_blank', ] ); + $title = $outage->get_title(); if ($editdelete) { $buttons .= \html_writer::link( new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), @@ -90,13 +91,19 @@ class manage extends \flexible_table { ]), ['title' => get_string('delete')] ); + + $title = \html_writer::link( + new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + $title, + ['title' => get_string('edit')] + ); } $this->add_data([ + format_time($outage->get_warning_duration()), userdate($outage->starttime, get_string('tablerowstarts', 'auth_outage')), - $outage->get_duration_string(), - $outage->get_warning_duration_string(), - $outage->get_title(), + format_time($outage->get_duration()), + $title, $buttons, ]); } diff --git a/edit.php b/edit.php index c8c177e..1d5461e 100644 --- a/edit.php +++ b/edit.php @@ -49,7 +49,7 @@ if ($outage == null) { } $mform->set_data($outage); -$PAGE->navbar->add($outage->title); +$PAGE->navbar->add($outage->get_title()); echo $OUTPUT->header(); echo $renderer->rendersubtitle('outageedit'); $mform->display(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index e364bdd..6fbc561 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -52,9 +52,8 @@ $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageduration'] = 'Outage Duration'; $string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; -$string['outageslistactive'] = 'Active Outages'; -$string['outageslistfuture'] = 'Future Outages'; -$string['outageslistpast'] = 'Past Outages'; +$string['outageslistfuture'] = 'Planned outages'; +$string['outageslistpast'] = 'Outage history'; $string['pluginname'] = 'Outage manager'; $string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; @@ -62,7 +61,7 @@ $string['tableheaderstarttime'] = 'Starts on'; $string['tableheaderstopsafter'] = 'Stops after'; $string['tableheaderwarnbefore'] = 'Warns before'; $string['tableheadertitle'] = 'Title'; -$string['tablerowstarts'] = '%d/%m/%Y %H:%M'; +$string['tablerowstarts'] = '%d %h %Y at %H:%M'; $string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; diff --git a/manage.php b/manage.php index c0fa8fb..ba6bab1 100644 --- a/manage.php +++ b/manage.php @@ -33,6 +33,6 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -$renderer->renderoutagelist(outagedb::get_all_active(), outagedb::get_all_future(), outagedb::get_all_past()); +$renderer->renderoutagelist(outagedb::get_all_future(), outagedb::get_all_past()); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index f7a33e9..b6d023a 100644 --- a/renderer.php +++ b/renderer.php @@ -47,15 +47,20 @@ class auth_outage_renderer extends plugin_renderer_base { * Outputs the HTML data listing all given outages. * @param array $outages Outages to list. */ - public function renderoutagelist(array $active, array $future, array $past) { + public function renderoutagelist(array $future, array $past) { global $OUTPUT; - if (!empty($active)) { - echo $this->rendersubtitle('outageslistactive'); - $table = new \auth_outage\tables\manage(); - $table->set_data($active, true); - $table->finish_output(); - } + // Add 'add' button. + $url = new moodle_url('/auth/outage/new.php'); + $img = html_writer::empty_tag('img', + ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); + echo html_writer::tag('p', + html_writer::link( + $url, + $img . ' ' . get_string('outagecreate', 'auth_outage'), + ['title' => get_string('delete')] + ) + ); echo $this->rendersubtitle('outageslistfuture'); if (empty($future)) { @@ -74,20 +79,6 @@ class auth_outage_renderer extends plugin_renderer_base { $table->set_data($past, false); $table->finish_output(); } - - // Add 'add' button. - $url = new moodle_url('/auth/outage/new.php'); - $img = html_writer::empty_tag('img', - ['src' => $OUTPUT->pix_url('t/add'), 'alt' => get_string('create'), 'class' => 'iconsmall']); - echo - html_writer::empty_tag('hr') - . html_writer::tag('p', - html_writer::link( - $url, - $img . ' ' . get_string('outagecreate', 'auth_outage'), - ['title' => get_string('delete')] - ) - ); } private function renderoutage(outage $outage, $buttons) { diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 976ae11..d00112f 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -182,42 +182,6 @@ class outagedb_test extends advanced_testcase { self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); } - public function test_getallactive() { - $this->resetAfterTest(true); - - // Have a consistent time for now (no seconds variation), helps debugging. - $now = time(); - - self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); - self::assertEquals([], outagedb::get_all_active($now), 'There should be no active outages at this point.'); - - self::saveoutage($now, 1, 2, 3, 'An outage that starts in the future and is not in warning period.'); - self::assertEquals([], outagedb::get_all_active($now), 'No active outages yet.'); - - self::saveoutage($now, -3, -2, -1, 'An outage that is already in the past.'); - self::assertEquals([], outagedb::get_all_active($now), 'No active outages yet.'); - - $id1 = self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); - self::assertEquals([$id1], - self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); - - $id2 = self::saveoutage($now, -1, 2, 3, 'Another outage in warning period.'); - self::assertEquals([$id1, $id2], - self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); - - $id3 = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); - self::assertEquals([$id3, $id1, $id2], - self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); - - $id4 = self::saveoutage($now, -3, -1, 1, 'Another ongoing outage.'); - self::assertEquals([$id3, $id4, $id1, $id2], - self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); - - $id5 = self::saveoutage($now, -3, -2, 1, 'Yet another ongoing outage.'); - self::assertEquals([$id3, $id5, $id4, $id1, $id2], - self::createidarray(outagedb::get_all_active($now)), 'Wrong actives data.'); - } - public function test_getallfuture() { $this->resetAfterTest(true); @@ -230,12 +194,6 @@ class outagedb_test extends advanced_testcase { self::saveoutage($now, -3, -2, -1, 'A past outage.'); self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); - self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); - self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); - - self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); - self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); - $id1 = self::saveoutage($now, 2, 3, 4, 'A future outage.'); self::assertEquals([$id1], self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); @@ -247,6 +205,26 @@ class outagedb_test extends advanced_testcase { $id3 = self::saveoutage($now, 1, 3, 5, 'Yet another future outage.'); self::assertEquals([$id3, $id1, $id2], self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id4 = self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); + self::assertEquals([$id4, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id5 = self::saveoutage($now, -1, 2, 3, 'Another outage in warning period.'); + self::assertEquals([$id4, $id5, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id6 = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); + self::assertEquals([$id6, $id4, $id5, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id7 = self::saveoutage($now, -3, -1, 1, 'Another ongoing outage.'); + self::assertEquals([$id6, $id7, $id4, $id5, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + + $id8 = self::saveoutage($now, -3, -2, 1, 'Yet another ongoing outage.'); + self::assertEquals([$id6, $id8, $id7, $id4, $id5, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); } public function test_getallpast() { From 928b72ba081a7ae1ec13fc3234794b60abf3bca6 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 14:05:45 +1000 Subject: [PATCH 40/72] Issue #11 - Date time formating. --- classes/models/outage.php | 4 ++-- classes/tables/manage.php | 2 +- lang/en/auth_outage.php | 6 +----- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/classes/models/outage.php b/classes/models/outage.php index 320f7ab..ebc2894 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -144,8 +144,8 @@ class outage { '{{stop}}' ], [ - userdate($this->starttime, get_string('strftimedatetimeshort')), - userdate($this->stoptime, get_string('strftimedatetimeshort')), + userdate($this->starttime, get_string('datetimeformat', 'auth_outage')), + userdate($this->stoptime, get_string('datetimeformat', 'auth_outage')), ], $str ); diff --git a/classes/tables/manage.php b/classes/tables/manage.php index c6fa874..418fba8 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -101,7 +101,7 @@ class manage extends \flexible_table { $this->add_data([ format_time($outage->get_warning_duration()), - userdate($outage->starttime, get_string('tablerowstarts', 'auth_outage')), + userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), format_time($outage->get_duration()), $title, $buttons, diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 6fbc561..68a95d9 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -24,6 +24,7 @@ */ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; +$string['datetimeformat'] = '%d %h %Y at %I:%M%P'; $string['defaultlayoutcss'] = 'Layout CSS'; $string['defaultlayoutcssdescription'] = 'This CSS code will be used to display the Outage Warning Bar.'; $string['defaultoutageduration'] = 'Outage Duration'; @@ -37,10 +38,6 @@ $string['defaultwarningdescription'] = 'Description'; $string['defaultwarningdescriptiondescription'] = 'Default warning message for outages. Use {{start}} and {{stop}} placeholders as required.'; $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; -$string['durationseconds'] = 'second(s)'; -$string['durationminutes'] = 'minutes(s)'; -$string['durationhours'] = 'hour(s)'; -$string['durationdays'] = 'day(s)'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; @@ -61,7 +58,6 @@ $string['tableheaderstarttime'] = 'Starts on'; $string['tableheaderstopsafter'] = 'Stops after'; $string['tableheaderwarnbefore'] = 'Warns before'; $string['tableheadertitle'] = 'Title'; -$string['tablerowstarts'] = '%d %h %Y at %H:%M'; $string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; From f109c0a5f7d380d55470934b3c388b0390726ee1 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 14:26:10 +1000 Subject: [PATCH 41/72] Issue #11 - Added {{duration}} placeholder, changed default title. --- classes/models/outage.php | 4 +++- lang/en/auth_outage.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/classes/models/outage.php b/classes/models/outage.php index ebc2894..5b0b8d9 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -141,11 +141,13 @@ class outage { return str_replace( [ '{{start}}', - '{{stop}}' + '{{stop}}', + '{{duration}}', ], [ userdate($this->starttime, get_string('datetimeformat', 'auth_outage')), userdate($this->stoptime, get_string('datetimeformat', 'auth_outage')), + format_time($this->get_duration()), ], $str ); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 68a95d9..0a25870 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -33,7 +33,7 @@ $string['defaultwarningduration'] = 'Warning Duration'; $string['defaultwarningdurationdescription'] = 'Default warning time (in minutes) for outages.'; $string['defaultwarningtitle'] = 'Title'; $string['defaultwarningtitledescription'] = 'Default title for outages. Use {{start}} and {{stop}} placeholders as required.'; -$string['defaultwarningtitlevalue'] = 'System down from {{start}} to {{stop}}.'; +$string['defaultwarningtitlevalue'] = 'System down from {{start}} for {{duration}}.'; $string['defaultwarningdescription'] = 'Description'; $string['defaultwarningdescriptiondescription'] = 'Default warning message for outages. Use {{start}} and {{stop}} placeholders as required.'; $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; From f05360bfc337c92c743718a1af3424c63fc6dbfc Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 16:32:13 +1000 Subject: [PATCH 42/72] Issue #8 - Added calendar synchronization. --- classes/outagedb.php | 63 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/classes/outagedb.php b/classes/outagedb.php index 2726590..85da1d9 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -20,6 +20,7 @@ use auth_outage\models\outage; /** * The DB Context to manipulate Outages. + * It will also commit changes to the calendar as you change outages. * * @package auth_outage * @author Daniel Thee Roperto @@ -96,6 +97,8 @@ class outagedb { \auth_outage\event\outage_created::create( ['objectid' => $outage->id, 'other' => (array)$outage] )->trigger(); + // Create calendar entry. + self::calendar_create($outage); } else { // Remove the createdby field so it does not get updated. unset($outage->createdby); @@ -104,6 +107,8 @@ class outagedb { \auth_outage\event\outage_updated::create( ['objectid' => $outage->id, 'other' => (array)$outage] )->trigger(); + // Update calendar entry. + self::calendar_update($outage); } // All done, return the id. @@ -132,7 +137,9 @@ class outagedb { $event->add_record_snapshot('auth_outage', $previous); $event->trigger(); + // Delete it and remove from calendar. $DB->delete_records('auth_outage', ['id' => $id]); + self::calendar_delete($id); } /** @@ -229,4 +236,60 @@ class outagedb { return $outages; } + + private static function calendar_create(outage $outage) { + \calendar_event::create(self::calendar_data($outage)); + } + + private static function calendar_update(outage $outage) { + $event = self::calendar_load($outage->id); + + if (is_null($event)) { + debugging('Cannot update calendar entry for outage #'.$outage->id.', event not found. Creating it...'); + self::calendar_create($outage); + } else { + $event->update(self::calendar_data($outage)); + } + } + + private static function calendar_delete($outageid) { + $event = self::calendar_load($outageid); + + // If not found (was not created before) ignore it. + if (is_null($event)) { + debugging('Cannot delete calendar entry for outage #'.$outageid.', event not found. Ignoring it...'); + }else{ + $event->delete(); + } + } + + private static function calendar_data(outage $outage) { + return [ + 'name' => $outage->get_title(), + 'description' => $outage->get_description(), + 'courseid' => 1, + 'groupid' => 0, + 'userid' => 0, + 'modulename' => '', + 'instance' => $outage->id, + 'eventtype' => 'auth_outage', + 'timestart' => $outage->starttime, + 'visible' => true, + 'timeduration' => $outage->get_duration(), + ]; + } + + private static function calendar_load($outageid) { + global $DB; + + $event = $DB->get_record_select( + 'event', + "(eventtype = 'auth_outage' AND instance = :outageid)", + ['outageid' => $outageid], + 'id', + IGNORE_MISSING + ); + + return ($event === false) ? null : \calendar_event::load($event->id); + } } From cbbb861fd808051040061447c8ae11254492f8f8 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 17:52:08 +1000 Subject: [PATCH 43/72] Issue #8 - Fixed failing phpunit tests. --- classes/outagedb.php | 12 +++++++++--- tests/outagedb_test.php | 8 ++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/classes/outagedb.php b/classes/outagedb.php index 85da1d9..260f4a6 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -16,6 +16,12 @@ namespace auth_outage; +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. +} + +require_once($CFG->dirroot . '/calendar/lib.php'); + use auth_outage\models\outage; /** @@ -245,7 +251,7 @@ class outagedb { $event = self::calendar_load($outage->id); if (is_null($event)) { - debugging('Cannot update calendar entry for outage #'.$outage->id.', event not found. Creating it...'); + debugging('Cannot update calendar entry for outage #' . $outage->id . ', event not found. Creating it...'); self::calendar_create($outage); } else { $event->update(self::calendar_data($outage)); @@ -257,8 +263,8 @@ class outagedb { // If not found (was not created before) ignore it. if (is_null($event)) { - debugging('Cannot delete calendar entry for outage #'.$outageid.', event not found. Ignoring it...'); - }else{ + debugging('Cannot delete calendar entry for outage #' . $outageid . ', event not found. Ignoring it...'); + } else { $event->delete(); } } diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index d00112f..adb83b3 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -30,6 +30,14 @@ defined('MOODLE_INTERNAL') || die(); class outagedb_test extends advanced_testcase { + /** + * Ensure DB tests run as admin. + */ + public function setUp() { + parent::setUp(); + $this->setAdminUser(); + } + /** * Creates an array of ids in from the given outages array. * @param $outages From b8c08192d14f738967dd2a4dc826ad9d65e55afd Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 18:22:50 +1000 Subject: [PATCH 44/72] Issue #28 - Added hints for the edit outage form. --- classes/forms/outage/edit.php | 5 +++++ lang/en/auth_outage.php | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 9ac36e1..6ad9628 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -46,10 +46,13 @@ class edit extends \moodleform { $mform->setType('id', PARAM_INT); $mform->addElement('duration', 'warningduration', get_string('warningduration', 'auth_outage')); + $mform->addHelpButton('warningduration', 'warningduration', 'auth_outage'); $mform->addElement('date_time_selector', 'starttime', get_string('starttime', 'auth_outage')); + $mform->addHelpButton('starttime', 'starttime', 'auth_outage'); $mform->addElement('duration', 'outageduration', get_string('outageduration', 'auth_outage')); + $mform->addHelpButton('outageduration', 'outageduration', 'auth_outage'); $mform->addElement( 'text', @@ -58,8 +61,10 @@ class edit extends \moodleform { 'maxlength="' . self::TITLE_MAX_CHARS . '" size="60"' ); $mform->setType('title', PARAM_TEXT); + $mform->addHelpButton('title', 'title', 'auth_outage'); $mform->addElement('editor', 'description', get_string('description', 'auth_outage')); + $mform->addHelpButton('description', 'description', 'auth_outage'); $mform->addElement('static', 'usagehints', '', get_string('textplaceholdershint', 'auth_outage')); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 0a25870..403adce 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -38,6 +38,7 @@ $string['defaultwarningdescription'] = 'Description'; $string['defaultwarningdescriptiondescription'] = 'Default warning message for outages. Use {{start}} and {{stop}} placeholders as required.'; $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; +$string['description_help'] = 'A full description of the outage, publicly visible by all users.'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; @@ -49,18 +50,22 @@ $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; $string['outageduration'] = 'Outage Duration'; $string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; +$string['outageduration_help'] = 'How long the outage lasts after it starts.'; $string['outageslistfuture'] = 'Planned outages'; $string['outageslistpast'] = 'Outage history'; $string['pluginname'] = 'Outage manager'; $string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; +$string['starttime_help'] = 'At which date and time the outage starts, preventing general access to the system.'; $string['tableheaderstarttime'] = 'Starts on'; $string['tableheaderstopsafter'] = 'Stops after'; $string['tableheaderwarnbefore'] = 'Warns before'; $string['tableheadertitle'] = 'Title'; -$string['textplaceholdershint'] = 'You can use {{start}} and {{stop}} as placeholders on the title/description for the actual start/stop time.'; +$string['textplaceholdershint'] = 'You can use {{start}}, {{stop}} and {{duration}} as placeholders on the title and description.'; $string['titleerrorinvalid'] = 'Title cannot be left blank.'; $string['titleerrortoolong'] = 'Title cannot have more than {$a} characters.'; $string['title'] = 'Title'; +$string['title_help'] = 'A short title to for this outage. It will be displayed on the warning bar and on the calendar.'; $string['warningdurationerrorinvalid'] = 'Warning duration must be positive.'; $string['warningduration'] = 'Warning duration'; +$string['warningduration_help'] = 'How long before the start of the outage should the warning be displayed.'; From 46a503e38310674787f469bb555e217b39f2eb0b Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 12 Sep 2016 18:32:16 +1000 Subject: [PATCH 45/72] Issue #28 - Inverted warning bar texts -- focus on countdown. --- renderer.php | 4 ++-- views/warningbar.css | 2 +- views/warningbar.php | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/renderer.php b/renderer.php index b6d023a..ebb84d7 100644 --- a/renderer.php +++ b/renderer.php @@ -168,7 +168,7 @@ class auth_outage_renderer extends plugin_renderer_base { * Renders the warning bar. * @param outage $outage The outage to show in the warning bar. * @return string HTML of the warning bar. - * @SuppressWarnings("unused") because $message is used inside require(...) + * @SuppressWarnings("unused") because $countdown is used inside require(...) */ public function renderoutagebar(outage $outage) { global $CFG; @@ -176,7 +176,7 @@ class auth_outage_renderer extends plugin_renderer_base { $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); - $message = get_string( + $countdown = get_string( $outage->is_ongoing() ? 'messageoutageongoing' : 'messageoutagewarning', 'auth_outage', ['start' => $start, 'stop' => $stop] diff --git a/views/warningbar.css b/views/warningbar.css index 67a68b6..8258bdd 100644 --- a/views/warningbar.css +++ b/views/warningbar.css @@ -22,7 +22,7 @@ If you need to make chances here, remember to update your settings inside Moodle color: #a00000; } -.auth_outage_warningbar_box_title { +.auth_outage_warningbar_box_countdown { font-size: 200%; font-weight: bold; margin: 10px 0; diff --git a/views/warningbar.php b/views/warningbar.php index 3638823..e41f1d8 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -36,9 +36,9 @@ echo html_writer::tag('style',
-
get_title(); ?>
+
- + get_title(); ?> [ Date: Tue, 13 Sep 2016 12:25:01 +1000 Subject: [PATCH 46/72] Fixed phpunit test for outage object. --- tests/outage_test.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/outage_test.php b/tests/outage_test.php index 0c0bd87..79e4535 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -46,7 +46,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (-3 * 60 * 60), 'stoptime' => $now + (-2 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); @@ -56,7 +56,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (-1 * 60 * 60), 'stoptime' => $now + (1 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); @@ -66,7 +66,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (1 * 60 * 60), 'stoptime' => $now + (2 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); From 4451240807352bbcac02ba3002f8381c1cc53553 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 13 Sep 2016 12:27:40 +1000 Subject: [PATCH 47/72] Fixed warningbar.php formatting. --- views/warningbar.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/views/warningbar.php b/views/warningbar.php index e41f1d8..1dfb3d1 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -36,12 +36,13 @@ echo html_writer::tag('style',
-
+
get_title(); ?> [ $outage->id]), get_string('readmore', 'auth_outage'), ['target' => 'outage'] ); ?>] From feaca1d78e25ba806f5cd4f7ae5f84f612492102 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 13 Sep 2016 11:30:35 +1000 Subject: [PATCH 48/72] Issue #26 - Preview warning bar with parameters auth_outage_preview and auth_outage_delta --- classes/outagelib.php | 21 ++++++++++++++++++--- renderer.php | 12 ++++++++++-- tests/outage_test.php | 6 +++--- views/warningbar.php | 7 ++++--- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/classes/outagelib.php b/classes/outagelib.php index bded0da..f84f560 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -64,11 +64,26 @@ class outagelib { } self::$initialized = true; - if (($active = outagedb::get_active()) == null) { - return; + // Check for a previewing outage, then for an active outage. + $previewid = optional_param('auth_outage_preview', null, PARAM_INT); + $time = time(); + if (is_null($previewid)) { + if (($active = outagedb::get_active()) == null) { + return; + } + } else { + if (($active = outagedb::get_by_id($previewid)) == null) { + return; + } + $delta = optional_param('auth_outage_delta', null, PARAM_FLOAT); + if ($delta) { + // Delta is float in minutes, allowing to check the redirect in a few seconds. + $time = $active->starttime + (int)($delta * 60); + } } - $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active) + // There is a previewing or active outage. + $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active, $time) . $CFG->additionalhtmltopofbody; } diff --git a/renderer.php b/renderer.php index ebb84d7..5cccc52 100644 --- a/renderer.php +++ b/renderer.php @@ -167,17 +167,25 @@ class auth_outage_renderer extends plugin_renderer_base { /** * Renders the warning bar. * @param outage $outage The outage to show in the warning bar. + * @param int|null $time Timestamp to send to the outage bar in order to render the outage. Null for current time. * @return string HTML of the warning bar. * @SuppressWarnings("unused") because $countdown is used inside require(...) */ - public function renderoutagebar(outage $outage) { + public function renderoutagebar(outage $outage, $time = null) { global $CFG; + if (is_null($time)) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time is not an int or null.'); + } + $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); $countdown = get_string( - $outage->is_ongoing() ? 'messageoutageongoing' : 'messageoutagewarning', + $outage->is_ongoing($time) ? 'messageoutageongoing' : 'messageoutagewarning', 'auth_outage', ['start' => $start, 'stop' => $stop] ); diff --git a/tests/outage_test.php b/tests/outage_test.php index 0c0bd87..79e4535 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -46,7 +46,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (-3 * 60 * 60), 'stoptime' => $now + (-2 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); @@ -56,7 +56,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (-1 * 60 * 60), 'stoptime' => $now + (1 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); @@ -66,7 +66,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'starttime' => $now + (1 * 60 * 60), 'stoptime' => $now + (2 * 60 * 60), - 'warningduration' => 2 * 60 * 60, + 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' ]); diff --git a/views/warningbar.php b/views/warningbar.php index e41f1d8..23e0e21 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -36,12 +36,13 @@ echo html_writer::tag('style',
-
+
get_title(); ?> [ $outage->id]), get_string('readmore', 'auth_outage'), ['target' => 'outage'] ); ?>] @@ -61,7 +62,7 @@ echo html_writer::tag('style', // Define outage object. var auth_outage_countdown = { timer: null - , countdown: starttime - time()); ?> + , countdown: starttime - $time); ?> , clienttime: Date.now() , init: function () { this.span = document.getElementById('auth_outage_warningbar_countdown'); From 92681c7ec4558141894e071fed39d79b48ea6608 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 13 Sep 2016 16:00:44 +1000 Subject: [PATCH 49/72] Issue #26 - Added the option to preview how a warning bar will look before and during an outage. --- classes/models/outage.php | 19 +++++++++++ classes/outagelib.php | 12 +++---- info.php | 3 ++ lang/en/auth_outage.php | 6 ++++ renderer.php | 66 ++++++++++++++++++++++++++------------- tests/outage_test.php | 50 +++++++++++++++++++++++++++-- views/infopage.php | 50 +++++++++++++++++++++++++++++ views/warningbar.php | 2 +- 8 files changed, 176 insertions(+), 32 deletions(-) create mode 100644 views/infopage.php diff --git a/classes/models/outage.php b/classes/models/outage.php index 5b0b8d9..d258806 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -97,6 +97,25 @@ class outage { throw new \InvalidArgumentException('$data must be null (default), an array or an object.'); } + /** + * Checks if the outage is active (in warning period or ongoing). + * @param int|null $time Null to check if the outage is active now or another time to use as reference. + * @return bool True if outage is ongoing or during the warning period. + */ + public function is_active($time = null) { + if ($time === null) { + $time = time(); + } + if (!is_int($time) || ($time <= 0)) { + throw new \InvalidArgumentException('$time must be an positive int.'); + } + if (is_null($this->warntime) || is_null($this->stoptime)) { + return false; + } + + return (($this->warntime <= $time) && ($time < $this->stoptime)); + } + /** * Checks if the outage is happening. * @param int|null $time Null to check if the outage is happening now or another time to use as reference. diff --git a/classes/outagelib.php b/classes/outagelib.php index f84f560..d04f79c 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -68,17 +68,17 @@ class outagelib { $previewid = optional_param('auth_outage_preview', null, PARAM_INT); $time = time(); if (is_null($previewid)) { - if (($active = outagedb::get_active()) == null) { + if (!$active = outagedb::get_active()) { return; } } else { - if (($active = outagedb::get_by_id($previewid)) == null) { + if (!$active = outagedb::get_by_id($previewid)) { return; } - $delta = optional_param('auth_outage_delta', null, PARAM_FLOAT); - if ($delta) { - // Delta is float in minutes, allowing to check the redirect in a few seconds. - $time = $active->starttime + (int)($delta * 60); + // Delta is in seconds, setting the time our warning bar will consider relative to the outage start time. + $time = $active->starttime + optional_param('auth_outage_delta', 0, PARAM_INT); + if (!$active->is_active($time)) { + return; } } diff --git a/info.php b/info.php index 1ac03cb..046c0bc 100644 --- a/info.php +++ b/info.php @@ -39,6 +39,9 @@ $PAGE->set_title($outage->get_title()); $PAGE->set_heading($outage->get_title()); $PAGE->set_url(new \moodle_url('/auth/outage/info.php')); +// No hooks injecting into this page, do it manually. +outagelib::inject(); + echo $OUTPUT->header(); echo outagelib::get_renderer()->renderoutagepage($outage); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 403adce..db1ec24 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -39,6 +39,12 @@ $string['defaultwarningdescriptiondescription'] = 'Default warning message for o $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; $string['description_help'] = 'A full description of the outage, publicly visible by all users.'; +$string['info1minutebefore'] = '1 minute before'; +$string['infoendofoutage'] = 'end of outage'; +$string['infofrom'] = 'From:'; +$string['infountil'] = 'Until:'; +$string['infostart'] = 'start'; +$string['infostartofwarning'] = 'start of warning'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; diff --git a/renderer.php b/renderer.php index 5cccc52..2a91e40 100644 --- a/renderer.php +++ b/renderer.php @@ -138,30 +138,52 @@ class auth_outage_renderer extends plugin_renderer_base { ); } - public function renderoutagepage(outage $outage) { - $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); - $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); + /** + * @param outage $outage + * @param null $time + * @return string + * @SuppressWarnings("unused") because $admineditlink is used inside require(...) + */ + public function renderoutagepage(outage $outage, $time = null) { + global $CFG; - $admin = ''; - if (is_siteadmin()) { - $admin = html_writer::tag('div', - '[' . html_writer::link( - new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), - get_string('outageedit', 'auth_outage') - ) . ']' + if (is_null($time)) { + $time = time(); + } + if (!is_int($time)) { + throw new \InvalidArgumentException('$time is not an int or null.'); + } + + $adminlinks = []; + foreach ([ + 'startofwarning' => -$outage->get_warning_duration(), + '1minutebefore' => -60, + 'start' => 0, + 'endofoutage' => $outage->get_duration(), + ] as $title => $delta) { + $adminlinks[] = html_writer::link( + new moodle_url( + '/auth/outage/info.php', + [ + 'id' => $outage->id, + 'auth_outage_preview' => $outage->id, + 'auth_outage_delta' => $delta, + ] + ), + get_string('info' . $title, 'auth_outage') ); } - return html_writer::div( - html_writer::tag('p', - html_writer::tag('b', 'From: ') - . $start - . html_writer::tag('b', ' Until: ') - . $stop - ) - . html_writer::div($outage->get_description()) - . $admin + $admineditlink = html_writer::link( + new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + get_string('outageedit', 'auth_outage') ); + + ob_start(); + require($CFG->dirroot . '/auth/outage/views/infopage.php'); + $html = ob_get_contents(); + ob_end_clean(); + return $html; } /** @@ -169,7 +191,6 @@ class auth_outage_renderer extends plugin_renderer_base { * @param outage $outage The outage to show in the warning bar. * @param int|null $time Timestamp to send to the outage bar in order to render the outage. Null for current time. * @return string HTML of the warning bar. - * @SuppressWarnings("unused") because $countdown is used inside require(...) */ public function renderoutagebar(outage $outage, $time = null) { global $CFG; @@ -181,8 +202,8 @@ class auth_outage_renderer extends plugin_renderer_base { throw new \InvalidArgumentException('$time is not an int or null.'); } - $start = userdate($outage->starttime, get_string('strftimedatetimeshort')); - $stop = userdate($outage->stoptime, get_string('strftimedatetimeshort')); + $start = userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')); + $stop = userdate($outage->stoptime, get_string('datetimeformat', 'auth_outage')); $countdown = get_string( $outage->is_ongoing($time) ? 'messageoutageongoing' : 'messageoutagewarning', @@ -190,6 +211,7 @@ class auth_outage_renderer extends plugin_renderer_base { ['start' => $start, 'stop' => $stop] ); + ob_start(); require($CFG->dirroot . '/auth/outage/views/warningbar.php'); $html = ob_get_contents(); diff --git a/tests/outage_test.php b/tests/outage_test.php index 79e4535..38c21dc 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -44,8 +44,8 @@ class outage_test extends basic_testcase { // In the past. $outage = new outage([ - 'starttime' => $now + (-3 * 60 * 60), - 'stoptime' => $now + (-2 * 60 * 60), + 'starttime' => $now - (3 * 60 * 60), + 'stoptime' => $now - (2 * 60 * 60), 'warntime' => $now - (2 * 60 * 60), 'title' => '', 'description' => '' @@ -54,7 +54,7 @@ class outage_test extends basic_testcase { // In the present (ongoing). $outage = new outage([ - 'starttime' => $now + (-1 * 60 * 60), + 'starttime' => $now - (1 * 60 * 60), 'stoptime' => $now + (1 * 60 * 60), 'warntime' => $now - (2 * 60 * 60), 'title' => '', @@ -72,4 +72,48 @@ class outage_test extends basic_testcase { ]); self::assertFalse($outage->is_ongoing($now)); } + + public function test_isactive() { + $now = time(); + + // In the past. + $outage = new outage([ + 'starttime' => $now - (3 * 60 * 60), + 'stoptime' => $now - (2 * 60 * 60), + 'warntime' => $now - (2 * 60 * 60), + 'title' => '', + 'description' => '' + ]); + self::assertFalse($outage->is_active($now)); + + // In the present (ongoing). + $outage = new outage([ + 'starttime' => $now - (1 * 60 * 60), + 'stoptime' => $now + (1 * 60 * 60), + 'warntime' => $now - (2 * 60 * 60), + 'title' => '', + 'description' => '' + ]); + self::assertTrue($outage->is_active($now)); + + // In the future (warning). + $outage = new outage([ + 'starttime' => $now + (1 * 60 * 60), + 'stoptime' => $now + (2 * 60 * 60), + 'warntime' => $now - (2 * 60 * 60), + 'title' => '', + 'description' => '' + ]); + self::assertTrue($outage->is_active($now)); + + // In the future (not warning). + $outage = new outage([ + 'starttime' => $now + (2 * 60 * 60), + 'stoptime' => $now + (3 * 60 * 60), + 'warntime' => $now + (1 * 60 * 60), + 'title' => '', + 'description' => '' + ]); + self::assertFalse($outage->is_active($now)); + } } diff --git a/views/infopage.php b/views/infopage.php new file mode 100644 index 0000000..e5e1084 --- /dev/null +++ b/views/infopage.php @@ -0,0 +1,50 @@ +. + +/** + * View included by the renderer to output the outage information page. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. +} +?> + +
+
+ + starttime, get_string('datetimeformat', 'auth_outage')); ?> +
+
+ + stoptime, get_string('datetimeformat', 'auth_outage')); ?> +
+
get_description(); ?>
+ + + + + +
diff --git a/views/warningbar.php b/views/warningbar.php index 23e0e21..d418417 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -67,11 +67,11 @@ echo html_writer::tag('style', , init: function () { this.span = document.getElementById('auth_outage_warningbar_countdown'); this.text = this.span.innerHTML; - this.tick(); var $this = this; this.timer = setInterval(function () { $this.tick(); }, 1000); + this.tick(); } , tick: function () { var elapsed = Math.round((Date.now() - this.clienttime) / 1000); From da39a78ca85298ea3cc0ab313b03dc6f6b9f2fd9 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 13 Sep 2016 14:50:11 +1000 Subject: [PATCH 50/72] Issue #23 - Added option to clone an outage. --- classes/tables/manage.php | 95 +++++++++++++++++++++++++-------------- clone.php | 59 ++++++++++++++++++++++++ lang/en/auth_outage.php | 2 + 3 files changed, 122 insertions(+), 34 deletions(-) create mode 100644 clone.php diff --git a/classes/tables/manage.php b/classes/tables/manage.php index 418fba8..54678db 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -52,46 +52,13 @@ class manage extends \flexible_table { } public function set_data(array $outages, $editdelete) { - global $OUTPUT; if (!is_bool($editdelete)) { throw new \InvalidArgumentException('$editdelete must be a bool.'); } foreach ($outages as $outage) { - $buttons = \html_writer::link( - new \moodle_url('/auth/outage/info.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ - 'src' => $OUTPUT->pix_url('t/preview'), - 'alt' => get_string('view'), - 'class' => 'iconsmall', - - ]), - [ - 'title' => get_string('view'), - 'target' => '_blank', - ] - ); $title = $outage->get_title(); if ($editdelete) { - $buttons .= \html_writer::link( - new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ - 'src' => $OUTPUT->pix_url('t/edit'), - 'alt' => get_string('edit'), - 'class' => 'iconsmall' - ]), - ['title' => get_string('edit')] - ) - . \html_writer::link( - new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ - 'src' => $OUTPUT->pix_url('t/delete'), - 'alt' => get_string('delete'), - 'class' => 'iconsmall' - ]), - ['title' => get_string('delete')] - ); - $title = \html_writer::link( new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), $title, @@ -104,8 +71,68 @@ class manage extends \flexible_table { userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), format_time($outage->get_duration()), $title, - $buttons, + $this->set_data_buttons($outage, $editdelete), ]); } } + + private function set_data_buttons($outage, $editdelete) { + global $OUTPUT; + $buttons = ''; + + // View button. + $buttons .= \html_writer::link( + new \moodle_url('/auth/outage/info.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/preview'), + 'alt' => get_string('view'), + 'class' => 'iconsmall', + + ]), + [ + 'title' => get_string('view'), + 'target' => '_blank', + ] + ); + + // Edit button. + if ($editdelete) { + $buttons .= \html_writer::link( + new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/edit'), + 'alt' => get_string('edit'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('edit')] + ); + } + + // Clone button. + $buttons .= \html_writer::link( + new \moodle_url('/auth/outage/clone.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/copy'), + 'alt' => get_string('clone', 'auth_outage'), + 'class' => 'iconsmall', + + ]), + ['title' => get_string('clone', 'auth_outage')] + ); + + // Delete button. + if ($editdelete) { + $buttons .= \html_writer::link( + new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), + \html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/delete'), + 'alt' => get_string('delete'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('delete')] + ); + } + + return $buttons; + } } \ No newline at end of file diff --git a/clone.php b/clone.php new file mode 100644 index 0000000..2adbcd4 --- /dev/null +++ b/clone.php @@ -0,0 +1,59 @@ +. + +/** + * Clone outage. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\models\outage; +use auth_outage\outagedb; +use auth_outage\outagelib; + +require_once('../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/formslib.php'); + +$renderer = outagelib::pagesetup(); + +$mform = new \auth_outage\forms\outage\edit(); + +if ($mform->is_cancelled()) { + redirect('/auth/outage/manage.php'); +} else if ($outage = $mform->get_data()) { + $id = outagedb::save($outage); + redirect('/auth/outage/manage.php#auth_outage_id_' . $id); +} + +$id = required_param('id', PARAM_INT); +$outage = outagedb::get_by_id($id); +if ($outage == null) { + throw new invalid_parameter_exception('Outage #' . $id . ' not found.'); +} + +// Remove outage id to force creating a new one. +$outage->id = null; +$mform->set_data($outage); + +$PAGE->navbar->add($outage->get_title()); +echo $OUTPUT->header(); +echo $renderer->rendersubtitle('outageclone'); +$mform->display(); +echo $OUTPUT->footer(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index db1ec24..0ed94cc 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -24,6 +24,7 @@ */ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; +$string['clone'] = 'Clone'; $string['datetimeformat'] = '%d %h %Y at %I:%M%P'; $string['defaultlayoutcss'] = 'Layout CSS'; $string['defaultlayoutcssdescription'] = 'This CSS code will be used to display the Outage Warning Bar.'; @@ -51,6 +52,7 @@ $string['messageoutageongoing'] = 'Our system will be under maintenance until {$ $string['messageoutagewarning'] = 'Shutting down in {{countdown}} ...'; $string['notfound'] = 'No outages found.'; $string['outageedit'] = 'Edit Outage'; +$string['outageclone'] = 'Clone Outage'; $string['outagecreate'] = 'Create Outage'; $string['outagedelete'] = 'Delete Outage'; $string['outagedeletewarning'] = 'You are about to permanently delete the outage below. This cannot be undone.'; From aa378633f9726ac878d0540c1c86729c6b555509 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 14 Sep 2016 10:21:23 +1000 Subject: [PATCH 51/72] Issue #29 - UI improvements such as warning bar styling and texts. --- lang/en/auth_outage.php | 9 ++--- renderer.php | 4 +- views/warningbar.css | 28 +++++++------ views/warningbar.js | 50 +++++++++++++++++++++++ views/warningbar.php | 90 +++++++++-------------------------------- 5 files changed, 90 insertions(+), 91 deletions(-) create mode 100644 views/warningbar.js diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index db1ec24..68a7e43 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -24,7 +24,7 @@ */ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; -$string['datetimeformat'] = '%d %h %Y at %I:%M%P'; +$string['datetimeformat'] = '%a %d %h %Y at %I:%M%P %Z'; $string['defaultlayoutcss'] = 'Layout CSS'; $string['defaultlayoutcssdescription'] = 'This CSS code will be used to display the Outage Warning Bar.'; $string['defaultoutageduration'] = 'Outage Duration'; @@ -39,7 +39,7 @@ $string['defaultwarningdescriptiondescription'] = 'Default warning message for o $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; $string['description_help'] = 'A full description of the outage, publicly visible by all users.'; -$string['info1minutebefore'] = '1 minute before'; +$string['info15secondsbefore'] = '15 seconds before'; $string['infoendofoutage'] = 'end of outage'; $string['infofrom'] = 'From:'; $string['infountil'] = 'Until:'; @@ -47,8 +47,8 @@ $string['infostart'] = 'start'; $string['infostartofwarning'] = 'start of warning'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; -$string['messageoutageongoing'] = 'Our system will be under maintenance until {$a->stop}.'; -$string['messageoutagewarning'] = 'Shutting down in {{countdown}} ...'; +$string['messageoutageongoing'] = 'Back online at {$a->stop}.'; +$string['messageoutagewarning'] = 'Shutting down in {{countdown}}'; $string['notfound'] = 'No outages found.'; $string['outageedit'] = 'Edit Outage'; $string['outagecreate'] = 'Create Outage'; @@ -60,7 +60,6 @@ $string['outageduration_help'] = 'How long the outage lasts after it starts.'; $string['outageslistfuture'] = 'Planned outages'; $string['outageslistpast'] = 'Outage history'; $string['pluginname'] = 'Outage manager'; -$string['readmore'] = 'Read More'; $string['starttime'] = 'Start date and time'; $string['starttime_help'] = 'At which date and time the outage starts, preventing general access to the system.'; $string['tableheaderstarttime'] = 'Starts on'; diff --git a/renderer.php b/renderer.php index 2a91e40..9264096 100644 --- a/renderer.php +++ b/renderer.php @@ -157,7 +157,7 @@ class auth_outage_renderer extends plugin_renderer_base { $adminlinks = []; foreach ([ 'startofwarning' => -$outage->get_warning_duration(), - '1minutebefore' => -60, + '15secondsbefore' => -15, 'start' => 0, 'endofoutage' => $outage->get_duration(), ] as $title => $delta) { @@ -191,6 +191,7 @@ class auth_outage_renderer extends plugin_renderer_base { * @param outage $outage The outage to show in the warning bar. * @param int|null $time Timestamp to send to the outage bar in order to render the outage. Null for current time. * @return string HTML of the warning bar. + * @SuppressWarnings("unused") because $countdown is used inside require(...) */ public function renderoutagebar(outage $outage, $time = null) { global $CFG; @@ -211,7 +212,6 @@ class auth_outage_renderer extends plugin_renderer_base { ['start' => $start, 'stop' => $stop] ); - ob_start(); require($CFG->dirroot . '/auth/outage/views/warningbar.php'); $html = ob_get_contents(); diff --git a/views/warningbar.css b/views/warningbar.css index 8258bdd..475fa9e 100644 --- a/views/warningbar.css +++ b/views/warningbar.css @@ -1,35 +1,39 @@ /* This file is used as default value for the 'auth_outage_css' settings. -If you need to make chances here, remember to update your settings inside Moodle. +If you need to make changes here, remember to update your settings inside Moodle. */ -.auth_outage_warningbar { - background-color: white; +#auth_outage_warningbar_box { + background-color: red; box-sizing: content-box; + color: white; height: 90px; left: 0; + padding: 0; position: fixed; text-align: center; top: 0; + transition: height 10s linear; width: 100%; z-index: 9999; } - -.auth_outage_warningbar_box { - background-color: #ffcccc; - border-bottom: 2px dashed #a00000; - border-top: 2px dashed #a00000; - color: #a00000; +#auth_outage_warningbar_box.imminent { + background: purple; +} +.auth_outage_warningbar_center { + position: relative; + top: 50%; + margin-top: -45px; } -.auth_outage_warningbar_box_countdown { +#auth_outage_warningbar_countdown { font-size: 200%; font-weight: bold; margin: 10px 0; } -.auth_outage_warningbar_box_message { - margin-bottom: 5px; +.auth_outage_warningbar_box_message A { + color: white; } .navbar.navbar-fixed-top { diff --git a/views/warningbar.js b/views/warningbar.js new file mode 100644 index 0000000..a509d47 --- /dev/null +++ b/views/warningbar.js @@ -0,0 +1,50 @@ +var auth_outage_countdown = { + timer: null, + clienttime: Date.now(), + siteadmin: false, + init: function (countdown, siteadmin) { + this.countdown = countdown; + this.siteadmin = siteadmin; + this.divtext = document.getElementById('auth_outage_warningbar_countdown'); + this.divblock = document.getElementById('auth_outage_warningbar_box'); + this.text = this.divtext.innerHTML; + var $this = this; + this.timer = setInterval(function () { + $this.tick(); + }, 1000); + this.tick(); + }, + tick: function () { + var elapsed = Math.round((Date.now() - this.clienttime) / 1000); + var missing = this.countdown - elapsed; + if (!this.siteadmin && (missing == 10)) { + this.divblock.className += ' imminent'; + this.divblock.style.height = window.innerHeight + 'px'; + } + if (missing <= 0) { + missing = 0; + clearInterval(this.timer); + if (!this.siteadmin) { + location = '/auth/outage/info.php'; + } + } + this.divtext.innerHTML = this.text.replace('{{countdown}}', this.seconds2hms(missing)); + }, + seconds2hms: function (seconds) { + var minutes = Math.floor(seconds / 60); + var hours = Math.floor(minutes / 60); + seconds %= 60; + minutes %= 60; + // Cross-browser simple solution for padding zeroes. + if (minutes < 10) { + minutes = "0" + minutes; + } + if (seconds < 10) { + seconds = "0" + seconds; + } + return hours + ':' + minutes + ':' + seconds; + } +}; + +// auth_outage_countdown is used outside this js file. +/* jshint unused:false */ diff --git a/views/warningbar.php b/views/warningbar.php index d418417..f76061b 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -27,84 +27,30 @@ if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } -// If debugging include directly from file, otherwise use plugin settings. -echo html_writer::tag('style', - debugging() ? file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css') : get_config('auth_outage', 'css') -); - +echo html_writer::tag('style', get_config('auth_outage', 'css')); ?> -
-
-
+
+
+
- get_title(); ?> - - [ $outage->id]), - get_string('readmore', 'auth_outage'), - ['target' => 'outage'] - ); ?>] - + $outage->id]), + $outage->get_title(), + ['target' => 'outage'] + ); ?>
- +is_ongoing($time)): ?> + +
 
From 5e873d766c04a5f1115b2f0e5901caaffe510ee3 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 14 Sep 2016 13:41:45 +1000 Subject: [PATCH 52/72] Issue #29 - Fixed javascript redirection. --- views/warningbar.css | 4 +++- views/warningbar.js | 5 +++-- views/warningbar.php | 13 ++++++------- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/views/warningbar.css b/views/warningbar.css index 475fa9e..5f1e410 100644 --- a/views/warningbar.css +++ b/views/warningbar.css @@ -17,13 +17,15 @@ If you need to make changes here, remember to update your settings inside Moodle width: 100%; z-index: 9999; } + #auth_outage_warningbar_box.imminent { background: purple; } + .auth_outage_warningbar_center { + margin-top: -45px; position: relative; top: 50%; - margin-top: -45px; } #auth_outage_warningbar_countdown { diff --git a/views/warningbar.js b/views/warningbar.js index a509d47..cba2047 100644 --- a/views/warningbar.js +++ b/views/warningbar.js @@ -2,9 +2,10 @@ var auth_outage_countdown = { timer: null, clienttime: Date.now(), siteadmin: false, - init: function (countdown, siteadmin) { + init: function (countdown, siteadmin, redirectto) { this.countdown = countdown; this.siteadmin = siteadmin; + this.redirectto = redirectto; this.divtext = document.getElementById('auth_outage_warningbar_countdown'); this.divblock = document.getElementById('auth_outage_warningbar_box'); this.text = this.divtext.innerHTML; @@ -25,7 +26,7 @@ var auth_outage_countdown = { missing = 0; clearInterval(this.timer); if (!this.siteadmin) { - location = '/auth/outage/info.php'; + window.location = this.redirectto; } } this.divtext.innerHTML = this.text.replace('{{countdown}}', this.seconds2hms(missing)); diff --git a/views/warningbar.php b/views/warningbar.php index f76061b..67fc604 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -27,6 +27,8 @@ if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } +$infolink = new moodle_url('/auth/outage/info.php', ['id' => $outage->id]); + echo html_writer::tag('style', get_config('auth_outage', 'css')); ?> @@ -34,11 +36,7 @@ echo html_writer::tag('style', get_config('auth_outage', 'css'));
- $outage->id]), - $outage->get_title(), - ['target' => 'outage'] - ); ?> + get_title(), ['target' => '_blank']); ?>
@@ -47,8 +45,9 @@ echo html_writer::tag('style', get_config('auth_outage', 'css')); From 6041d945e11f8057a1a2afdea998dd5b0c2b8664 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Wed, 14 Sep 2016 13:57:32 +1000 Subject: [PATCH 53/72] Issue #29 - Fixed javascript error in jshint for Moodle 27 and 28. --- views/warningbar.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/views/warningbar.js b/views/warningbar.js index cba2047..def3363 100644 --- a/views/warningbar.js +++ b/views/warningbar.js @@ -18,7 +18,7 @@ var auth_outage_countdown = { tick: function () { var elapsed = Math.round((Date.now() - this.clienttime) / 1000); var missing = this.countdown - elapsed; - if (!this.siteadmin && (missing == 10)) { + if (!this.siteadmin && (missing === 10)) { this.divblock.className += ' imminent'; this.divblock.style.height = window.innerHeight + 'px'; } From 7ca44c74abac1c953ef7e3dfc4003509aa99e7e3 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 15 Sep 2016 14:01:45 +1000 Subject: [PATCH 54/72] Fixed code inspections, missing phpdocs and code standards. --- classes/event/outage_created.php | 13 +++++++- classes/event/outage_deleted.php | 13 +++++++- classes/event/outage_updated.php | 14 ++++++-- classes/forms/outage/delete.php | 3 +- classes/forms/outage/edit.php | 2 +- classes/models/outage.php | 27 ++++++++-------- classes/outagedb.php | 55 ++++++++++++++++++++++++-------- classes/outagelib.php | 7 ++-- classes/tables/manage.php | 24 ++++++++++++-- clone.php | 1 - delete.php | 1 - edit.php | 1 - renderer.php | 10 ++++++ tests/outage_test.php | 10 +++--- tests/outagedb_test.php | 15 ++++----- tests/outagelib_test.php | 10 +++--- views/warningbar.js | 2 +- 17 files changed, 143 insertions(+), 65 deletions(-) diff --git a/classes/event/outage_created.php b/classes/event/outage_created.php index 28ef9d1..0339818 100644 --- a/classes/event/outage_created.php +++ b/classes/event/outage_created.php @@ -16,6 +16,8 @@ namespace auth_outage\event; +use core\event\base; + defined('MOODLE_INTERNAL') || die(); /** @@ -26,7 +28,7 @@ defined('MOODLE_INTERNAL') || die(); * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class outage_created extends \core\event\base { +class outage_created extends base { /** * Init method. */ @@ -37,11 +39,20 @@ class outage_created extends \core\event\base { $this->context = \context_system::instance(); } + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ public function get_description() { return "The user with the id '{$this->userid}' created a new outage title '{$this->other['title']}' " . " with id '{$this->other['id']}'."; } + /** + * Returns relevant URL, override in subclasses. + * @return \moodle_url + */ public function get_url() { return new \moodle_url('/auth/outage/list.php#auth_outage_id_' . $this->other['id']); } diff --git a/classes/event/outage_deleted.php b/classes/event/outage_deleted.php index f73a516..e1844a7 100644 --- a/classes/event/outage_deleted.php +++ b/classes/event/outage_deleted.php @@ -16,6 +16,8 @@ namespace auth_outage\event; +use core\event\base; + defined('MOODLE_INTERNAL') || die(); /** @@ -26,7 +28,7 @@ defined('MOODLE_INTERNAL') || die(); * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class outage_deleted extends \core\event\base { +class outage_deleted extends base { /** * Init method. */ @@ -37,11 +39,20 @@ class outage_deleted extends \core\event\base { $this->context = \context_system::instance(); } + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ public function get_description() { return "The user with the id '{$this->userid}' deleted the outage titled '{$this->other['title']}' " . "with id '{$this->other['id']}'."; } + /** + * Returns relevant URL, override in subclasses. + * @return \moodle_url + */ public function get_url() { return new \moodle_url('/auth/outage/list.php#auth_outage_id_' . $this->other['id']); } diff --git a/classes/event/outage_updated.php b/classes/event/outage_updated.php index 59a846e..797b680 100644 --- a/classes/event/outage_updated.php +++ b/classes/event/outage_updated.php @@ -16,6 +16,8 @@ namespace auth_outage\event; +use core\event\base; + defined('MOODLE_INTERNAL') || die(); /** @@ -26,7 +28,7 @@ defined('MOODLE_INTERNAL') || die(); * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class outage_updated extends \core\event\base { +class outage_updated extends base { /** * Init method. */ @@ -37,12 +39,20 @@ class outage_updated extends \core\event\base { $this->context = \context_system::instance(); } - + /** + * Returns non-localised event description with id's for admin use only. + * + * @return string + */ public function get_description() { return "The user with the id '{$this->userid}' updated the outage title '{$this->other['title']}' " . "with id '{$this->other['id']}'."; } + /** + * Returns relevant URL, override in subclasses. + * @return \moodle_url + */ public function get_url() { return new \moodle_url('/auth/outage/list.php'); } diff --git a/classes/forms/outage/delete.php b/classes/forms/outage/delete.php index 23cbcd9..2928912 100644 --- a/classes/forms/outage/delete.php +++ b/classes/forms/outage/delete.php @@ -56,5 +56,4 @@ class delete extends \moodleform { return $errors; } - -} \ No newline at end of file +} diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index 6ad9628..f4986a8 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -16,7 +16,7 @@ namespace auth_outage\forms\outage; -use \auth_outage\models\outage; +use auth_outage\models\outage; if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. diff --git a/classes/models/outage.php b/classes/models/outage.php index d258806..4fec627 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -14,19 +14,18 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +namespace auth_outage\models; + +use auth_outage\outagelib; + /** - * An Outage object with all information about one specific outage. + * Outage class with all information about one specific outage. * * @package auth_outage * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -namespace auth_outage\models; - -use auth_outage\outagelib; - class outage { /** * @var int Outage ID (auto generated by the DB). @@ -143,14 +142,6 @@ class outage { return $this->replace_placeholders($this->title); } - /** - * Get the description with properly replaced placeholders such as {{start}} and {{stop}}. - * @return string Description. - */ - public function get_description() { - return $this->replace_placeholders($this->description); - } - /** * Returns the input string with all placeholders replaced. * @param $str string Input string. @@ -180,6 +171,14 @@ class outage { return $this->stoptime - $this->starttime; } + /** + * Get the description with properly replaced placeholders such as {{start}} and {{stop}}. + * @return string Description. + */ + public function get_description() { + return $this->replace_placeholders($this->description); + } + /** * Gets the warning duration from the outage (from warning time to start time). * @return int Warning duration in seconds. diff --git a/classes/outagedb.php b/classes/outagedb.php index 260f4a6..81557b1 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -22,7 +22,12 @@ if (!defined('MOODLE_INTERNAL')) { require_once($CFG->dirroot . '/calendar/lib.php'); +use auth_outage\event\outage_created; +use auth_outage\event\outage_deleted; +use auth_outage\event\outage_updated; use auth_outage\models\outage; +use calendar_event; +use InvalidArgumentException; /** * The DB Context to manipulate Outages. @@ -65,10 +70,10 @@ class outagedb { global $DB; if (!is_int($id)) { - throw new \InvalidArgumentException('$id must be an int.'); + throw new InvalidArgumentException('$id must be an int.'); } if ($id <= 0) { - throw new \InvalidArgumentException('$id must be positive.'); + throw new InvalidArgumentException('$id must be positive.'); } $outage = $DB->get_record('auth_outage', ['id' => $id]); @@ -100,7 +105,7 @@ class outagedb { $outage->createdby = $USER->id; // Then create it, log it and adjust its id. $outage->id = $DB->insert_record('auth_outage', $outage, true); - \auth_outage\event\outage_created::create( + outage_created::create( ['objectid' => $outage->id, 'other' => (array)$outage] )->trigger(); // Create calendar entry. @@ -110,7 +115,7 @@ class outagedb { unset($outage->createdby); $DB->update_record('auth_outage', $outage); // Log it. - \auth_outage\event\outage_updated::create( + outage_updated::create( ['objectid' => $outage->id, 'other' => (array)$outage] )->trigger(); // Update calendar entry. @@ -124,22 +129,22 @@ class outagedb { /** * Deletes an outage from the database. * - * @param $id outage Outage ID to delete + * @param int $id Outage ID to delete * @throws InvalidArgumentException If ID is not valid. */ public static function delete($id) { global $DB; if (!is_int($id)) { - throw new \InvalidArgumentException('$id must be an int.'); + throw new InvalidArgumentException('$id must be an int.'); } if ($id <= 0) { - throw new \InvalidArgumentException('$id must be positive.'); + throw new InvalidArgumentException('$id must be positive.'); } // Log it. $previous = $DB->get_record('auth_outage', ['id' => $id], '*', MUST_EXIST); - $event = \auth_outage\event\outage_deleted::create(['objectid' => $id, 'other' => (array)$previous]); + $event = outage_deleted::create(['objectid' => $id, 'other' => (array)$previous]); $event->add_record_snapshot('auth_outage', $previous); $event->trigger(); @@ -163,7 +168,7 @@ class outagedb { $time = time(); } if (!is_int($time)) { - throw new \InvalidArgumentException('$time must be null or an int.'); + throw new InvalidArgumentException('$time must be null or an int.'); } $data = $DB->get_records_select( @@ -178,7 +183,7 @@ class outagedb { // Not using $DB->get_record_select instead because there is no 'limit' parameter. // Allowing multiple records still raises an internal error. - return (count($data) == 0) ? null : new \auth_outage\models\outage(array_shift($data)); + return (count($data) == 0) ? null : new outage(array_shift($data)); } /** @@ -193,7 +198,7 @@ class outagedb { $time = time(); } if (!is_int($time)) { - throw new \InvalidArgumentException('$time must be null or an int.'); + throw new InvalidArgumentException('$time must be null or an int.'); } $outages = []; @@ -224,7 +229,7 @@ class outagedb { $time = time(); } if (!is_int($time)) { - throw new \InvalidArgumentException('$time must be null or an int.'); + throw new InvalidArgumentException('$time must be null or an int.'); } $outages = []; @@ -243,10 +248,18 @@ class outagedb { return $outages; } + /** + * Create an event on the calendar for this outage. + * @param outage $outage Outage to be added to the calendar. + */ private static function calendar_create(outage $outage) { - \calendar_event::create(self::calendar_data($outage)); + calendar_event::create(self::calendar_data($outage)); } + /** + * Updates an event on the calendar based on this outage. + * @param outage $outage Outage to be updated in the calendar. + */ private static function calendar_update(outage $outage) { $event = self::calendar_load($outage->id); @@ -258,6 +271,10 @@ class outagedb { } } + /** + * Removes an event from the calendar related to this outage. + * @param int $outageid Id of outage to be deleted from the calendar. + */ private static function calendar_delete($outageid) { $event = self::calendar_load($outageid); @@ -269,6 +286,11 @@ class outagedb { } } + /** + * Generates an array with the calendar event data based on an outage object. + * @param outage $outage Outage to use as reference for the calendar event. + * @return array Calendar event data. + */ private static function calendar_data(outage $outage) { return [ 'name' => $outage->get_title(), @@ -285,6 +307,11 @@ class outagedb { ]; } + /** + * Finds the calendar event for an specific outage. + * @param int $outageid The outage id to find in the calendar. + * @return calendar_event|null The calendar event or null if not found. + */ private static function calendar_load($outageid) { global $DB; @@ -296,6 +323,6 @@ class outagedb { IGNORE_MISSING ); - return ($event === false) ? null : \calendar_event::load($event->id); + return ($event === false) ? null : calendar_event::load($event->id); } } diff --git a/classes/outagelib.php b/classes/outagelib.php index d04f79c..3d00819 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -16,6 +16,8 @@ namespace auth_outage; +use auth_outage_renderer; + if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } @@ -33,8 +35,7 @@ class outagelib { /** * Initializes admin pages for outage. - * - * @return \renderer_base + * @return auth_outage_renderer The outage renderer for the page. */ public static function pagesetup() { global $PAGE; @@ -45,7 +46,7 @@ class outagelib { /** * Returns the outage renderer. - * @return \renderer_base + * @return auth_outage_renderer The outage renderer. */ public static function get_renderer() { global $PAGE; diff --git a/classes/tables/manage.php b/classes/tables/manage.php index 54678db..8b1ac37 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -16,6 +16,9 @@ namespace auth_outage\tables; +use auth_outage\models\outage; +use flexible_table; + require_once($CFG->libdir . '/tablelib.php'); /** @@ -26,9 +29,13 @@ require_once($CFG->libdir . '/tablelib.php'); * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class manage extends \flexible_table { +class manage extends flexible_table { private static $autoid = 0; + /** + * Constructor + * @param string|null $id to be used by the table, autogenerated if null. + */ public function __construct($id = null) { global $PAGE; @@ -51,6 +58,11 @@ class manage extends \flexible_table { $this->setup(); } + /** + * Sets the data of the table. + * @param array $outages An array with outage objects. + * @param bool $editdelete If it should display the edit and delete button. + */ public function set_data(array $outages, $editdelete) { if (!is_bool($editdelete)) { throw new \InvalidArgumentException('$editdelete must be a bool.'); @@ -76,7 +88,13 @@ class manage extends \flexible_table { } } - private function set_data_buttons($outage, $editdelete) { + /** + * Create the action buttons HTML code for a specific outage. + * @param outage $outage The outage to generate the buttons. + * @param bool $editdelete If it should display the edit and delete button. + * @return string The HTML code of the action buttons. + */ + private function set_data_buttons(outage $outage, $editdelete) { global $OUTPUT; $buttons = ''; @@ -135,4 +153,4 @@ class manage extends \flexible_table { return $buttons; } -} \ No newline at end of file +} diff --git a/clone.php b/clone.php index 2adbcd4..7bb9554 100644 --- a/clone.php +++ b/clone.php @@ -23,7 +23,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use auth_outage\models\outage; use auth_outage\outagedb; use auth_outage\outagelib; diff --git a/delete.php b/delete.php index f5ca3cb..7579ccc 100644 --- a/delete.php +++ b/delete.php @@ -23,7 +23,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use auth_outage\models\outage; use auth_outage\outagedb; use auth_outage\outagelib; diff --git a/edit.php b/edit.php index 1d5461e..0dca59a 100644 --- a/edit.php +++ b/edit.php @@ -23,7 +23,6 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use auth_outage\models\outage; use auth_outage\outagedb; use auth_outage\outagelib; diff --git a/renderer.php b/renderer.php index 9264096..dc9552f 100644 --- a/renderer.php +++ b/renderer.php @@ -30,6 +30,11 @@ if (!defined('MOODLE_INTERNAL')) { * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class auth_outage_renderer extends plugin_renderer_base { + /** + * Renders the subtitle of the page. + * @param string $subtitlekey Key to be used and localized. + * @return string HTML for the subtitle. + */ public function rendersubtitle($subtitlekey) { if (!is_string($subtitlekey)) { throw new \InvalidArgumentException('$subtitle is not a string.'); @@ -37,6 +42,11 @@ class auth_outage_renderer extends plugin_renderer_base { return html_writer::tag('h2', get_string($subtitlekey, 'auth_outage')); } + /** + * Renders a confirmation to delete an outage. + * @param outage $outage Outage to be deleted. + * @return string HTML for the page. + */ public function renderdeleteconfirmation(outage $outage) { return $this->rendersubtitle('outagedelete') . html_writer::tag('p', get_string('outagedeletewarning', 'auth_outage')) diff --git a/tests/outage_test.php b/tests/outage_test.php index 38c21dc..ff2b471 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -14,6 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use auth_outage\models\outage; + +defined('MOODLE_INTERNAL') || die(); + /** * Tests performed on outage class. * @@ -22,12 +26,6 @@ * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -use auth_outage\models\outage; - -defined('MOODLE_INTERNAL') || die(); - - class outage_test extends basic_testcase { public function test_constructor() { $outage = new outage(); diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index adb83b3..2eec538 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -14,6 +14,11 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); + /** * Tests performed on outage class. * @@ -22,13 +27,6 @@ * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -use auth_outage\models\outage; -use auth_outage\outagedb; - -defined('MOODLE_INTERNAL') || die(); - - class outagedb_test extends advanced_testcase { /** * Ensure DB tests run as admin. @@ -40,7 +38,8 @@ class outagedb_test extends advanced_testcase { /** * Creates an array of ids in from the given outages array. - * @param $outages + * @param array $outages An array of outages. + * @return array An array with the keys of the outages as values. */ private static function createidarray(array $outages) { $ids = []; diff --git a/tests/outagelib_test.php b/tests/outagelib_test.php index ce4c5a1..4a08b5f 100644 --- a/tests/outagelib_test.php +++ b/tests/outagelib_test.php @@ -14,6 +14,10 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . +use auth_outage\outagelib; + +defined('MOODLE_INTERNAL') || die(); + /** * Tests performed on outageutils class. * @@ -22,12 +26,6 @@ * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ - -use auth_outage\outagelib; - -defined('MOODLE_INTERNAL') || die(); - - class outagelib_test extends basic_testcase { public function test_data2object() { diff --git a/views/warningbar.js b/views/warningbar.js index def3363..31dd73d 100644 --- a/views/warningbar.js +++ b/views/warningbar.js @@ -1,5 +1,5 @@ var auth_outage_countdown = { - timer: null, + timer: 0, clienttime: Date.now(), siteadmin: false, init: function (countdown, siteadmin, redirectto) { From 2cfda63922c8b30fadbafbe0f9a753ca201799e4 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Thu, 15 Sep 2016 18:24:36 +1000 Subject: [PATCH 55/72] Issue #30 - Add finished flag to mark the real time that an outage was finished. --- classes/forms/outage/delete.php | 13 ---- classes/forms/outage/finish.php | 47 ++++++++++++ classes/models/outage.php | 131 ++++++++++++++++++++++++-------- classes/outagedb.php | 54 ++++++++++--- classes/outagelib.php | 30 +------- classes/tables/manage.php | 56 +++++++++----- db/install.xml | 3 +- finish.php | 59 ++++++++++++++ lang/en/auth_outage.php | 4 + manage.php | 2 +- renderer.php | 72 ++++++++++++------ tests/outage_test.php | 66 ++++++++++++++++ tests/outagedb_test.php | 84 +++++++++++++------- tests/outagelib_test.php | 50 ------------ version.php | 2 +- views/warningbar.css | 19 ++++- views/warningbar.php | 26 ++++++- 17 files changed, 508 insertions(+), 210 deletions(-) create mode 100644 classes/forms/outage/finish.php create mode 100644 finish.php delete mode 100644 tests/outagelib_test.php diff --git a/classes/forms/outage/delete.php b/classes/forms/outage/delete.php index 2928912..fdaf5ef 100644 --- a/classes/forms/outage/delete.php +++ b/classes/forms/outage/delete.php @@ -43,17 +43,4 @@ class delete extends \moodleform { $this->add_action_buttons(true, get_string('delete')); } - - /** - * Validate the parts of the request form for this module - * - * @param array $data An array of form data - * @param array $files An array of form files - * @return array of error messages - */ - public function validation($data, $files) { - $errors = parent::validation($data, $files); - - return $errors; - } } diff --git a/classes/forms/outage/finish.php b/classes/forms/outage/finish.php new file mode 100644 index 0000000..8ea525f --- /dev/null +++ b/classes/forms/outage/finish.php @@ -0,0 +1,47 @@ +. + +namespace auth_outage\forms\outage; + +use moodleform; + +if (!defined('MOODLE_INTERNAL')) { + die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. +} + +require_once($CFG->libdir . '/formslib.php'); + +/** + * Outage finish confirmation form. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class finish extends moodleform { + /** + * Defines the form elements. + */ + public function definition() { + $mform = $this->_form; + + $mform->addElement('hidden', 'id'); + $mform->setType('id', PARAM_INT); + + $this->add_action_buttons(true, get_string('finish', 'auth_outage')); + } +} diff --git a/classes/models/outage.php b/classes/models/outage.php index 4fec627..9ebd2ba 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -16,7 +16,7 @@ namespace auth_outage\models; -use auth_outage\outagelib; +use InvalidArgumentException; /** * Outage class with all information about one specific outage. @@ -27,6 +27,31 @@ use auth_outage\outagelib; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class outage { + /** + * Outage is before warning period. + */ + const STAGE_WAITING = 1; + + /** + * Outage not started but in warning period. + */ + const STAGE_WARNING = 2; + + /** + * Outage ongoing, it has passed the warning period. + */ + const STAGE_ONGOING = 3; + + /** + * Outage finished, it is after the marked finished time. + */ + const STAGE_FINISHED = 4; + + /** + * Outage stopped, it is after the stop time and not marked as finished. + */ + const STAGE_STOPPED = 4; + /** * @var int Outage ID (auto generated by the DB). */ @@ -47,6 +72,11 @@ class outage { */ public $warntime = null; + /** + * @var int|null Finished timestamp, null if not marked as finished yet. + */ + public $finished = null; + /** * @var string Short description of the outage (no HTML). */ @@ -75,25 +105,59 @@ class outage { /** * outage constructor. * @param object|array|null The data for the outage. + * @throws InvalidArgumentException */ public function __construct($data = null) { if (is_null($data)) { return; } - - if (is_object($data) || is_array($data)) { - outagelib::data2object($data, $this); - - // Adjust field types as needed. - $fields = ['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warntime']; - foreach ($fields as $f) { - $this->$f = ($this->$f === null) ? null : (int)$this->$f; - } - - return; + if (is_object($data)) { + $data = (array)$data; + } + if (!is_array($data)) { + throw new InvalidArgumentException('$data is not an object, an array or null.'); } - throw new \InvalidArgumentException('$data must be null (default), an array or an object.'); + // Load data from array. + foreach ($data as $k => $v) { + if (property_exists(self::class, $k)) { + $this->$k = $v; + } + } + + // Adjust int fields. + $fs = ['createdby', 'id', 'lastmodified', 'modifiedby', 'starttime', 'stoptime', 'warntime', 'finished']; + foreach ($fs as $f) { + $this->$f = ($this->$f === null) ? null : (int)$this->$f; + } + } + + /** + * Gets at which stage is this outage. + * @param int|null $time Null to check the current stage or a timestamp to check for another time. + * @return int Stage, compare with STAGE_* constants. + */ + public function get_stage($time = null) { + if ($time === null) { + $time = time(); + } + if (!is_int($time) || ($time <= 0)) { + throw new InvalidArgumentException('$time must be an positive int.'); + } + + if (!is_null($this->finished) && ($time >= $this->finished)) { + return self::STAGE_FINISHED; + } + if ($time >= $this->stoptime) { + return self::STAGE_STOPPED; + } + if ($time < $this->warntime) { + return self::STAGE_WAITING; + } + if ($time < $this->starttime) { + return self::STAGE_WARNING; + } + return self::STAGE_ONGOING; } /** @@ -102,17 +166,13 @@ class outage { * @return bool True if outage is ongoing or during the warning period. */ public function is_active($time = null) { - if ($time === null) { - $time = time(); + switch ($this->get_stage($time)) { + case self::STAGE_WARNING: + case self::STAGE_ONGOING: + return true; + default: + return false; } - if (!is_int($time) || ($time <= 0)) { - throw new \InvalidArgumentException('$time must be an positive int.'); - } - if (is_null($this->warntime) || is_null($this->stoptime)) { - return false; - } - - return (($this->warntime <= $time) && ($time < $this->stoptime)); } /** @@ -121,17 +181,22 @@ class outage { * @return bool True if outage has started but not yet stopped. False otherwise including if in warning period. */ public function is_ongoing($time = null) { - if ($time === null) { - $time = time(); - } - if (!is_int($time) || ($time <= 0)) { - throw new \InvalidArgumentException('$time must be an positive int.'); - } - if (is_null($this->starttime) || is_null($this->stoptime)) { - return false; - } + return ($this->get_stage($time) == self::STAGE_ONGOING); + } - return (($this->starttime <= $time) && ($time < $this->stoptime)); + /** + * Checks if the outage has ended (either marked as finished or after stop time). + * @param int|null $time Null to check if the outage has already ended or another time to use as reference. + * @return bool True if outage has been marked as finished after the provided time or it has already stopped. + */ + public function has_ended($time = null) { + switch ($this->get_stage($time)) { + case self::STAGE_FINISHED: + case self::STAGE_STOPPED: + return true; + default: + return false; + } } /** diff --git a/classes/outagedb.php b/classes/outagedb.php index 81557b1..f15e505 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -171,10 +171,12 @@ class outagedb { throw new InvalidArgumentException('$time must be null or an int.'); } + $select = ':datetime2 <= stoptime AND (finished IS NULL OR :datetime3 <= finished)'; // End condition. + $select = "(warntime <= :datetime1 AND (${select}))"; // Full select part. $data = $DB->get_records_select( 'auth_outage', - '(warntime <= :datetime1 AND stoptime >= :datetime2)', - ['datetime1' => $time, 'datetime2' => $time], + $select, + ['datetime1' => $time, 'datetime2' => $time, 'datetime3' => $time], 'starttime ASC, stoptime DESC, title ASC', '*', 0, @@ -187,11 +189,11 @@ class outagedb { } /** - * Gets all future outages not in warning period. + * Gets all outages that have not ended yet. * @param int|null $time Timestamp considered to check for outages, null for current date/time. - * @return array An array of outages or an empty array if no future outage found. + * @return array An array of outages or an empty array if no unded outages were found. */ - public static function get_all_future($time = null) { + public static function get_all_unended($time = null) { global $DB; if ($time === null) { @@ -205,8 +207,8 @@ class outagedb { $rs = $DB->get_recordset_select( 'auth_outage', - 'stoptime >= :datetime', - ['datetime' => $time], + ':datetime1 < stoptime AND (finished IS NULL OR :datetime2 < finished)', + ['datetime1' => $time, 'datetime2' => $time], 'starttime ASC, stoptime DESC, title ASC', '*'); foreach ($rs as $r) { @@ -218,11 +220,11 @@ class outagedb { } /** - * Gets all past outages. + * Gets all ended outages. * @param int|null $time Timestamp considered to check for outages, null for current date/time. - * @return array An array of outages or an empty array if no past outage found. + * @return array An array of outages or an empty array if no ended outages found. */ - public static function get_all_past($time = null) { + public static function get_all_ended($time = null) { global $DB; if ($time === null) { @@ -236,8 +238,8 @@ class outagedb { $rs = $DB->get_recordset_select( 'auth_outage', - 'stoptime < :datetime', - ['datetime' => $time], + ':datetime1 >= stoptime OR (finished IS NOT NULL AND :datetime2 >= finished)', + ['datetime1' => $time, 'datetime2' => $time], 'stoptime DESC, starttime DESC, title ASC', '*'); foreach ($rs as $r) { @@ -248,6 +250,34 @@ class outagedb { return $outages; } + /** + * Marks an outage as finished. + * @param int $id Outage id. + * @param int|null $time Timestamp to consider as finished date or null for current time. + */ + public static function finish($id, $time = null) { + if (is_null($time)) { + $time = time(); + } + if (!is_int($time)) { + throw new InvalidArgumentException('$time must be an int or null.'); + } + + $outage = self::get_by_id($id); + if (is_null($outage)) { + debugging('Cannot finish outage #' . $id . ': outage not found.'); + return; + } + + if (!$outage->is_ongoing($time)) { + debugging('Cannot finish outage #' . $id . ': outage not ongoing.'); + return; + } + + $outage->finished = $time; + self::save($outage); + } + /** * Create an event on the calendar for this outage. * @param outage $outage Outage to be added to the calendar. diff --git a/classes/outagelib.php b/classes/outagelib.php index 3d00819..1dce55c 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -17,6 +17,7 @@ namespace auth_outage; use auth_outage_renderer; +use moodle_url; if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. @@ -40,7 +41,7 @@ class outagelib { public static function pagesetup() { global $PAGE; admin_externalpage_setup('auth_outage_manage'); - $PAGE->set_url(new \moodle_url('/auth/outage/manage.php')); + $PAGE->set_url(new moodle_url('/auth/outage/manage.php')); return self::get_renderer(); } @@ -87,31 +88,4 @@ class outagelib { $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active, $time) . $CFG->additionalhtmltopofbody; } - - /** - * Loads data from an object or array into another object. It ensures no new fields are created in the $obj. - * - * @param $data mixed An object or array. - * @param $obj object Destination object to write the properties. - */ - public static function data2object($data, $obj) { - if (is_object($data)) { - $data = get_object_vars($data); - } - if (!is_array($data)) { - throw new \InvalidArgumentException('$data must be an array or an object.'); - } - if (!is_object($obj)) { - throw new \InvalidArgumentException('$obj must be an object.'); - } - - foreach ($data as $k => $v) { - if (property_exists($obj, $k)) { - if (method_exists($obj, $k)) { - throw new \InvalidArgumentException('$obj has a method called ' . $k); - } - $obj->$k = $v; - } - } - } } diff --git a/classes/tables/manage.php b/classes/tables/manage.php index 8b1ac37..6126cb0 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage.php @@ -18,6 +18,8 @@ namespace auth_outage\tables; use auth_outage\models\outage; use flexible_table; +use html_writer; +use moodle_url; require_once($CFG->libdir . '/tablelib.php'); @@ -42,12 +44,13 @@ class manage extends flexible_table { $id = (is_null($id) ? self::$autoid++ : $id); parent::__construct('auth_outage_manage_' . $id); - $this->define_columns(['starttime', 'stopsafter', 'warnbefore', 'title', '']); + $this->define_columns(['starttime', 'stopsafter', 'warnbefore', 'finished', 'title', '']); $this->define_headers([ get_string('tableheaderwarnbefore', 'auth_outage'), get_string('tableheaderstarttime', 'auth_outage'), get_string('tableheaderstopsafter', 'auth_outage'), + get_string('tableheaderfinishedat', 'auth_outage'), get_string('tableheadertitle', 'auth_outage'), get_string('actions'), ] @@ -60,7 +63,7 @@ class manage extends flexible_table { /** * Sets the data of the table. - * @param array $outages An array with outage objects. + * @param outage[] $outages An array with outage objects. * @param bool $editdelete If it should display the edit and delete button. */ public function set_data(array $outages, $editdelete) { @@ -71,17 +74,21 @@ class manage extends flexible_table { foreach ($outages as $outage) { $title = $outage->get_title(); if ($editdelete) { - $title = \html_writer::link( - new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + $title = html_writer::link( + new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), $title, ['title' => get_string('edit')] ); } + $finished = $outage->finished; + $finished = is_null($finished) ? '-' : userdate($finished, get_string('datetimeformat', 'auth_outage')); + $this->add_data([ format_time($outage->get_warning_duration()), userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), format_time($outage->get_duration()), + $finished, $title, $this->set_data_buttons($outage, $editdelete), ]); @@ -99,9 +106,9 @@ class manage extends flexible_table { $buttons = ''; // View button. - $buttons .= \html_writer::link( - new \moodle_url('/auth/outage/info.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ + $buttons .= html_writer::link( + new moodle_url('/auth/outage/info.php', ['id' => $outage->id]), + html_writer::empty_tag('img', [ 'src' => $OUTPUT->pix_url('t/preview'), 'alt' => get_string('view'), 'class' => 'iconsmall', @@ -113,11 +120,11 @@ class manage extends flexible_table { ] ); - // Edit button. + // Edit button if required. if ($editdelete) { - $buttons .= \html_writer::link( - new \moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ + $buttons .= html_writer::link( + new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + html_writer::empty_tag('img', [ 'src' => $OUTPUT->pix_url('t/edit'), 'alt' => get_string('edit'), 'class' => 'iconsmall' @@ -127,9 +134,9 @@ class manage extends flexible_table { } // Clone button. - $buttons .= \html_writer::link( - new \moodle_url('/auth/outage/clone.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ + $buttons .= html_writer::link( + new moodle_url('/auth/outage/clone.php', ['id' => $outage->id]), + html_writer::empty_tag('img', [ 'src' => $OUTPUT->pix_url('t/copy'), 'alt' => get_string('clone', 'auth_outage'), 'class' => 'iconsmall', @@ -138,11 +145,24 @@ class manage extends flexible_table { ['title' => get_string('clone', 'auth_outage')] ); - // Delete button. + // Finish button if ongoing. + if ($outage->is_ongoing()) { + $buttons .= html_writer::link( + new moodle_url('/auth/outage/finish.php', ['id' => $outage->id]), + html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/check'), + 'alt' => get_string('finish', 'auth_outage'), + 'class' => 'iconsmall' + ]), + ['title' => get_string('finish', 'auth_outage')] + ); + } + + // Delete button if required. if ($editdelete) { - $buttons .= \html_writer::link( - new \moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), - \html_writer::empty_tag('img', [ + $buttons .= html_writer::link( + new moodle_url('/auth/outage/delete.php', ['id' => $outage->id]), + html_writer::empty_tag('img', [ 'src' => $OUTPUT->pix_url('t/delete'), 'alt' => get_string('delete'), 'class' => 'iconsmall' diff --git a/db/install.xml b/db/install.xml index 7f6326b..3391324 100644 --- a/db/install.xml +++ b/db/install.xml @@ -1,5 +1,5 @@ - @@ -15,6 +15,7 @@ + diff --git a/finish.php b/finish.php new file mode 100644 index 0000000..9826e80 --- /dev/null +++ b/finish.php @@ -0,0 +1,59 @@ +. + +/** + * Mark an outage as finished. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\outagedb; +use auth_outage\outagelib; + +require_once('../../config.php'); +require_once($CFG->libdir . '/adminlib.php'); +require_once($CFG->libdir . '/formslib.php'); + +$renderer = outagelib::pagesetup(); + +$mform = new \auth_outage\forms\outage\finish(); +if ($mform->is_cancelled()) { + redirect('/auth/outage/manage.php'); +} else if ($fromform = $mform->get_data()) { + outagedb::finish($fromform->id); + redirect('/auth/outage/manage.php'); +} + +$id = required_param('id', PARAM_INT); +$outage = outagedb::get_by_id($id); +if ($outage == null) { + throw new invalid_parameter_exception('Outage #' . $id . ' not found.'); +} + +$dataid = new stdClass(); +$dataid->id = $outage->id; +$mform->set_data($dataid); + +echo $OUTPUT->header(); + +echo $renderer->renderfinishconfirmation($outage); + +$mform->display(); + +echo $OUTPUT->footer(); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 058b564..76277d3 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -40,6 +40,7 @@ $string['defaultwarningdescriptiondescription'] = 'Default warning message for o $string['defaultwarningdescriptionvalue'] = 'There is an scheduled maintenance from {{start}} to {{stop}} and our system will not be available during that time.'; $string['description'] = 'Public Description'; $string['description_help'] = 'A full description of the outage, publicly visible by all users.'; +$string['finish'] = 'Finish'; $string['info15secondsbefore'] = '15 seconds before'; $string['infoendofoutage'] = 'end of outage'; $string['infofrom'] = 'From:'; @@ -59,11 +60,14 @@ $string['outagedeletewarning'] = 'You are about to permanently delete the outage $string['outageduration'] = 'Outage Duration'; $string['outagedurationerrorinvalid'] = 'Outage duration must be positive.'; $string['outageduration_help'] = 'How long the outage lasts after it starts.'; +$string['outagefinish'] = 'Finish Outage'; +$string['outagefinishwarning'] = 'You are about to mark this outage as finished. The system will be immediately back online.'; $string['outageslistfuture'] = 'Planned outages'; $string['outageslistpast'] = 'Outage history'; $string['pluginname'] = 'Outage manager'; $string['starttime'] = 'Start date and time'; $string['starttime_help'] = 'At which date and time the outage starts, preventing general access to the system.'; +$string['tableheaderfinishedat'] = 'Finished at'; $string['tableheaderstarttime'] = 'Starts on'; $string['tableheaderstopsafter'] = 'Stops after'; $string['tableheaderwarnbefore'] = 'Warns before'; diff --git a/manage.php b/manage.php index ba6bab1..3a05899 100644 --- a/manage.php +++ b/manage.php @@ -33,6 +33,6 @@ $renderer = outagelib::pagesetup(); echo $OUTPUT->header(); -$renderer->renderoutagelist(outagedb::get_all_future(), outagedb::get_all_past()); +$renderer->renderoutagelist(outagedb::get_all_unended(), outagedb::get_all_ended()); echo $OUTPUT->footer(); diff --git a/renderer.php b/renderer.php index dc9552f..0adcf4c 100644 --- a/renderer.php +++ b/renderer.php @@ -15,7 +15,6 @@ // along with Moodle. If not, see . use auth_outage\models\outage; -use auth_outage\models\outageform; if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. @@ -53,9 +52,21 @@ class auth_outage_renderer extends plugin_renderer_base { . $this->renderoutage($outage, false); } + /** + * Renders a confirmation to finish an outage. + * @param outage $outage Outage to be finished. + * @return string HTML for the page. + */ + public function renderfinishconfirmation(outage $outage) { + return $this->rendersubtitle('outagefinish') + . html_writer::tag('p', get_string('outagefinishwarning', 'auth_outage')) + . $this->renderoutage($outage, false); + } + /** * Outputs the HTML data listing all given outages. - * @param array $outages Outages to list. + * @param array $future Outages to list as planned. + * @param array $past Outages to list as history. */ public function renderoutagelist(array $future, array $past) { global $OUTPUT; @@ -91,6 +102,12 @@ class auth_outage_renderer extends plugin_renderer_base { } } + /** + * Returns the HTML for displaying and outage information. + * @param outage $outage Outage to display. + * @param bool $buttons If should display management buttons (edit, delete, etc). + * @return string The formatted HTML. + */ private function renderoutage(outage $outage, $buttons) { global $OUTPUT; @@ -120,30 +137,37 @@ class auth_outage_renderer extends plugin_renderer_base { ); $linkdelete = html_writer::link($url, $img, ['title' => get_string('delete')]); - // TODO use language pack below, solve together with Issue #12. + $finished = $outage->finished; + $finished = is_null($finished) ? '-' : userdate($finished, get_string('datetimeformat', 'auth_outage')); + return html_writer::div( - html_writer::span( - html_writer::tag('b', $outage->title, ['data-id' => $outage->id]) - . html_writer::empty_tag('br') - . html_writer::tag('i', $outage->description) - . html_writer::empty_tag('br') - . html_writer::tag('b', 'Warning: ') - . userdate($outage->warntime, '%d %h %Y %l:%M%P') - . html_writer::empty_tag('br') - . html_writer::tag('b', 'Starts: ') - . userdate($outage->starttime, '%d %h %Y %l:%M%P') - . html_writer::empty_tag('br') - . html_writer::tag('b', 'Stops: ') - . userdate($outage->stoptime, '%d %h %Y %l:%M%P') - . html_writer::empty_tag('br') - . html_writer::tag('small', - 'Created by ' . $created - . ', modified by ' . $modified . ' on ' - . userdate($outage->lastmodified, '%d %h %Y %l:%M%P') + html_writer::tag('blockquote', + html_writer::div(html_writer::tag('b', $outage->get_title(), ['data-id' => $outage->id])) + . html_writer::div(html_writer::tag('i', $outage->get_description())) + . html_writer::div( + html_writer::tag('b', get_string('tableheaderwarnbefore', 'auth_outage') . ': ') + . format_time($outage->get_warning_duration()) ) - . html_writer::empty_tag('br') - . ($buttons ? $linkedit . $linkdelete . html_writer::empty_tag('br') : '') - . html_writer::empty_tag('br') + . html_writer::div( + html_writer::tag('b', get_string('tableheaderstarttime', 'auth_outage') . ': ') + . userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')) + ) + . html_writer::div( + html_writer::tag('b', get_string('tableheaderstopsafter', 'auth_outage') . ': ') + . format_time($outage->get_duration()) + ) + . html_writer::div( + html_writer::tag('b', get_string('tableheaderfinishedat', 'auth_outage') . ': ') + . $finished + ) + . html_writer::div( + html_writer::tag('small', + 'Created by ' . $created + . ', modified by ' . $modified . ' on ' + . userdate($outage->lastmodified, get_string('datetimeformat', 'auth_outage')) + ) + ) + . ($buttons ? html_writer::div($linkedit . $linkdelete) : '') ) ); } diff --git a/tests/outage_test.php b/tests/outage_test.php index ff2b471..bc0de2c 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -114,4 +114,70 @@ class outage_test extends basic_testcase { ]); self::assertFalse($outage->is_active($now)); } + + public function test_stages() { + $now = time(); + + $outage = new outage([ + 'warntime' => $now + 10, + 'starttime' => $now + 20, + 'stoptime' => $now + 30, + 'title' => 'Outage Waiting', + ]); + self::assertSame(outage::STAGE_WAITING, $outage->get_stage($now)); + self::assertFalse($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + + $outage = new outage([ + 'warntime' => $now - 10, + 'starttime' => $now + 20, + 'stoptime' => $now + 30, + 'title' => 'Outage Warning', + ]); + self::assertSame(outage::STAGE_WARNING, $outage->get_stage($now)); + self::assertTrue($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + + $outage = new outage([ + 'warntime' => $now - 20, + 'starttime' => $now - 10, + 'stoptime' => $now + 30, + 'title' => 'Outage Ongoing', + ]); + self::assertSame(outage::STAGE_ONGOING, $outage->get_stage($now)); + self::assertTrue($outage->is_active($now)); + self::assertTrue($outage->is_ongoing($now)); + + $outage = new outage([ + 'warntime' => $now - 50, + 'starttime' => $now - 40, + 'stoptime' => $now - 30, + 'title' => 'Outage Stopped', + ]); + self::assertSame(outage::STAGE_STOPPED, $outage->get_stage($now)); + self::assertFalse($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + + $outage = new outage([ + 'warntime' => $now - 50, + 'starttime' => $now - 40, + 'finishtime' => $now - 30, + 'stoptime' => $now - 20, + 'title' => 'Outage Finished before Stop', + ]); + self::assertSame(outage::STAGE_FINISHED, $outage->get_stage($now)); + self::assertFalse($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + + $outage = new outage([ + 'warntime' => $now - 50, + 'starttime' => $now - 40, + 'stoptime' => $now - 30, + 'finishtime' => $now - 20, + 'title' => 'Outage Finished after Stop', + ]); + self::assertSame(outage::STAGE_FINISHED, $outage->get_stage($now)); + self::assertFalse($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + } } diff --git a/tests/outagedb_test.php b/tests/outagedb_test.php index 2eec538..bd35ee1 100644 --- a/tests/outagedb_test.php +++ b/tests/outagedb_test.php @@ -92,6 +92,26 @@ class outagedb_test extends advanced_testcase { self::assertNull(outagedb::get_by_id($id)); } + /** + * Make sure we can finish outages. + */ + public function test_finish() { + $now = time(); + $this->resetAfterTest(true); + // Create it. + $id = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); + $outage = outagedb::get_by_id($id); + self::assertTrue($outage->is_active($now)); + self::assertTrue($outage->is_ongoing($now)); + self::assertSame(null, $outage->finished); + // Finish it. + outagedb::finish($id, $now); + $outage = outagedb::get_by_id($id); + self::assertSame($now, $outage->finished); + self::assertFalse($outage->is_active($now)); + self::assertFalse($outage->is_ongoing($now)); + } + /** * Make sure getall brings all entries. */ @@ -176,6 +196,9 @@ class outagedb_test extends advanced_testcase { 'Another outage in warning period, but ignored as it starts after the previous one.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); + self::saveoutage($now, -3, -2, 2, 'An finished outage.', -1); + self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); + $activeid = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); @@ -189,97 +212,106 @@ class outagedb_test extends advanced_testcase { self::assertSame($activeid, outagedb::get_active($now)->id, 'Wrong active outage picked.'); } - public function test_getallfuture() { + public function test_getallunended() { $this->resetAfterTest(true); // Have a consistent time for now (no seconds variation), helps debugging. $now = time(); self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); - self::assertEquals([], outagedb::get_all_future($now), 'There should be no future outages at this point.'); + self::assertEquals([], outagedb::get_all_unended($now), 'There should be no future outages at this point.'); self::saveoutage($now, -3, -2, -1, 'A past outage.'); - self::assertEquals([], outagedb::get_all_future($now), 'No future outages yet.'); + self::assertEquals([], outagedb::get_all_unended($now), 'No future outages yet.'); + + self::saveoutage($now, -3, -2, 2, 'A finished outage.', -1); + self::assertEquals([], outagedb::get_all_unended($now), 'No future outages yet.'); $id1 = self::saveoutage($now, 2, 3, 4, 'A future outage.'); self::assertEquals([$id1], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id2 = self::saveoutage($now, 1, 4, 5, 'Another future outage.'); self::assertEquals([$id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id3 = self::saveoutage($now, 1, 3, 5, 'Yet another future outage.'); self::assertEquals([$id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id4 = self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); self::assertEquals([$id4, $id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id5 = self::saveoutage($now, -1, 2, 3, 'Another outage in warning period.'); self::assertEquals([$id4, $id5, $id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id6 = self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); self::assertEquals([$id6, $id4, $id5, $id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id7 = self::saveoutage($now, -3, -1, 1, 'Another ongoing outage.'); self::assertEquals([$id6, $id7, $id4, $id5, $id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); $id8 = self::saveoutage($now, -3, -2, 1, 'Yet another ongoing outage.'); self::assertEquals([$id6, $id8, $id7, $id4, $id5, $id3, $id1, $id2], - self::createidarray(outagedb::get_all_future($now)), 'Wrong future data.'); + self::createidarray(outagedb::get_all_unended($now)), 'Wrong future data.'); } - public function test_getallpast() { + public function test_getallended() { $this->resetAfterTest(true); // Have a consistent time for now (no seconds variation), helps debugging. $now = time(); self::assertEquals([], outagedb::get_all(), 'Ensure there are no other outages that can affect the test.'); - self::assertEquals([], outagedb::get_all_past($now), 'There should be no future outages at this point.'); + self::assertEquals([], outagedb::get_all_ended($now), 'There should be no future outages at this point.'); self::saveoutage($now, -2, 1, 2, 'An outage in warning period.'); - self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + self::assertEquals([], outagedb::get_all_ended($now), 'No past outages yet.'); self::saveoutage($now, -3, -2, 2, 'An ongoing outage.'); - self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + self::assertEquals([], outagedb::get_all_ended($now), 'No past outages yet.'); self::saveoutage($now, 2, 3, 4, 'A future outage.'); - self::assertEquals([], outagedb::get_all_past($now), 'No past outages yet.'); + self::assertEquals([], outagedb::get_all_ended($now), 'No past outages yet.'); $id1 = self::saveoutage($now, -8, -6, -4, 'A past outage.'); self::assertEquals([$id1], - self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + self::createidarray(outagedb::get_all_ended($now)), 'Wrong past data.'); $id2 = self::saveoutage($now, -8, -7, -5, 'Another past outage.'); self::assertEquals([$id1, $id2], - self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + self::createidarray(outagedb::get_all_ended($now)), 'Wrong past data.'); $id3 = self::saveoutage($now, -8, -5, -3, 'Yet another past outage.'); self::assertEquals([$id3, $id1, $id2], - self::createidarray(outagedb::get_all_past($now)), 'Wrong past data.'); + self::createidarray(outagedb::get_all_ended($now)), 'Wrong past data.'); + + $id4 = self::saveoutage($now, -3, -2, 2, 'A finished outage.', -1); + self::assertEquals([$id4, $id3, $id1, $id2], + self::createidarray(outagedb::get_all_ended($now)), 'Wrong past data.'); } /** * Helper function to create an outage then save it to the database. * - * @param $now int Timestamp for now, such as time(). - * @param $warning int In how many hours the warning starts. Can be negative. - * @param $start int In how many hours this outage starts. Can be negative. - * @param $stop int In how many hours this outage finishes. Can be negative. - * @param $title string Title for the outage. + * @param int $now Timestamp for now, such as time(). + * @param int $warning In how many hours the warning starts. Can be negative. + * @param int $start In how many hours this outage starts. Can be negative. + * @param int $stop In how many hours this outage finishes. Can be negative. + * @param string $title Title for the outage. + * @param int|null $finished In how many hours this outage is marked as finished. Can be negative or null. * @return int Id the of created outage. */ - private static function saveoutage($now, $warning, $start, $stop, $title) { + private static function saveoutage($now, $warning, $start, $stop, $title, $finished = null) { return outagedb::save(new outage([ + 'warntime' => $now + ($warning * 60 * 60), 'starttime' => $now + ($start * 60 * 60), 'stoptime' => $now + ($stop * 60 * 60), - 'warntime' => $now + ($warning * 60 * 60), + 'finished' => is_null($finished) ? null : ($now + ($finished * 60 * 60)), 'title' => $title, 'description' => 'Test Outage Description.' ])); diff --git a/tests/outagelib_test.php b/tests/outagelib_test.php deleted file mode 100644 index 4a08b5f..0000000 --- a/tests/outagelib_test.php +++ /dev/null @@ -1,50 +0,0 @@ -. - -use auth_outage\outagelib; - -defined('MOODLE_INTERNAL') || die(); - -/** - * Tests performed on outageutils class. - * - * @package auth_outage - * @author Daniel Thee Roperto - * @copyright Catalyst IT - * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */ -class outagelib_test extends basic_testcase -{ - public function test_data2object() { - // Using object data, no new fields. - $obj = new stdClass(); - $obj->foo = 'bar'; - $obj->number = 42; - $data = new stdClass(); - $data->foo = 'not bar'; - outagelib::data2object($data, $obj); - self::assertEquals(get_object_vars($obj), ['foo' => 'not bar', 'number' => 42], 'Invalid result.'); - self::assertEquals(get_object_vars($data), ['foo' => 'not bar'], 'Data should not change.'); - - // Using array data, with new fields. - $obj = new stdClass(); - $obj->foo = 'bar'; - $obj->number = 42; - $data = ['foo' => 'foobar', 'flag' => false]; - outagelib::data2object($data, $obj); - self::assertEquals(get_object_vars($obj), ['foo' => 'foobar', 'number' => 42], 'Invalid result.'); - } -} diff --git a/version.php b/version.php index c7db839..44662e6 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } -$plugin->version = 2016090900; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2016091500; // The current plugin version (Date: YYYYMMDDXX). $plugin->release = $plugin->version; // Same as version $plugin->requires = 2014051200; // Requires Moodle 2.7 or later. $plugin->component = "auth_outage"; diff --git a/views/warningbar.css b/views/warningbar.css index 5f1e410..acb7c53 100644 --- a/views/warningbar.css +++ b/views/warningbar.css @@ -34,10 +34,27 @@ If you need to make changes here, remember to update your settings inside Moodle margin: 10px 0; } -.auth_outage_warningbar_box_message A { +A.auth_outage_warningbar_box_title { color: white; } +A.auth_outage_warningbar_box_finish { + background-color: white; + border: 1px solid black; + border-radius: 5px; + color: darkgray; + font-weight: bold; + margin-left: 10px; + padding-left: 5px; + padding-right: 10px; + text-decoration: none; + transition: background-color 200ms linear; +} + +A.auth_outage_warningbar_box_finish:hover { + background-color: black; +} + .navbar.navbar-fixed-top { top: 90px; } diff --git a/views/warningbar.php b/views/warningbar.php index 67fc604..45c0a23 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -27,6 +27,8 @@ if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } +global $OUTPUT; + $infolink = new moodle_url('/auth/outage/info.php', ['id' => $outage->id]); echo html_writer::tag('style', get_config('auth_outage', 'css')); @@ -35,8 +37,28 @@ echo html_writer::tag('style', get_config('auth_outage', 'css'));
-
- get_title(), ['target' => '_blank']); ?> +
+ get_title(), + ['target' => '_blank', 'class' => 'auth_outage_warningbar_box_title'] + ); + + if (is_siteadmin() && $outage->is_ongoing()) { + $url = new moodle_url('/auth/outage/finish.php', ['id' => $outage->id]); + $text = html_writer::empty_tag('img', [ + 'src' => $OUTPUT->pix_url('t/check'), + 'alt' => get_string('finish', 'auth_outage'), + 'class' => 'iconsmall' + ]) . ' ' . get_string('finish', 'auth_outage'); + $attr = [ + 'title' => get_string('finish', 'auth_outage'), + 'class' => 'auth_outage_warningbar_box_finish' + ]; + echo html_writer::link($url, $text, $attr); + } + ?>
From f569157368b0208eafe4a59c2722a3e0383d677b Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Fri, 16 Sep 2016 17:26:48 +1000 Subject: [PATCH 56/72] Issue #27 - CLI implemented. Added full test coverage to CLI classes. --- auth.php | 7 +- classes/cli/clibase.php | 133 +++++++++++++++++ classes/cli/cliexception.php | 30 ++++ classes/cli/create.php | 208 +++++++++++++++++++++++++++ classes/cli/finish.php | 106 ++++++++++++++ classes/cli/waitforit.php | 178 +++++++++++++++++++++++ cli/create.php | 48 +++++++ cli/finish.php | 39 +++++ cli/waitforit.php | 39 +++++ lang/en/auth_outage.php | 31 ++++ renderer.php | 28 ++-- tests/cli/cli_testcase.php | 61 ++++++++ tests/cli/create_test.php | 270 +++++++++++++++++++++++++++++++++++ tests/cli/finish_test.php | 64 +++++++++ tests/cli/waitforit_test.php | 171 ++++++++++++++++++++++ tests/phpunit.xml | 31 ++++ 16 files changed, 1429 insertions(+), 15 deletions(-) create mode 100644 classes/cli/clibase.php create mode 100644 classes/cli/cliexception.php create mode 100644 classes/cli/create.php create mode 100644 classes/cli/finish.php create mode 100644 classes/cli/waitforit.php create mode 100644 cli/create.php create mode 100644 cli/finish.php create mode 100644 cli/waitforit.php create mode 100644 tests/cli/cli_testcase.php create mode 100644 tests/cli/create_test.php create mode 100644 tests/cli/finish_test.php create mode 100644 tests/cli/waitforit_test.php create mode 100644 tests/phpunit.xml diff --git a/auth.php b/auth.php index 6a12f9c..b04abe6 100644 --- a/auth.php +++ b/auth.php @@ -24,17 +24,14 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License */ -if (!defined('MOODLE_INTERNAL')) { - die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. -} +defined('MOODLE_INTERNAL') || die(); require_once($CFG->libdir . '/authlib.php'); /** * Class auth_plugin_outage */ -class auth_plugin_outage extends auth_plugin_base -{ +class auth_plugin_outage extends auth_plugin_base { /** * Constructor. */ diff --git a/classes/cli/clibase.php b/classes/cli/clibase.php new file mode 100644 index 0000000..d0eb62c --- /dev/null +++ b/classes/cli/clibase.php @@ -0,0 +1,133 @@ +. + +namespace auth_outage\cli; + +use core\session\manager; +use InvalidArgumentException; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir . '/clilib.php'); + +/** + * Outage CLI base class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +abstract class clibase { + /** + * @var array Options passed as parameters to the CLI. + */ + protected $options; + + /** + * @var int The reference time to use when creating an outage. + */ + protected $time; + + /** + * clibase constructor. + * @param array|null $options The parameters to use or null to read from the command line. + * @throws cliexception + */ + public function __construct(array $options = null) { + $this->becomeadmin(); + + if (is_null($options)) { + // Using Moodle CLI API to read the parameters. + list($options, $unrecognized) = cli_get_params($this->generateoptions(), $this->generateshortcuts()); + if ($unrecognized) { + $unrecognized = implode("\n ", $unrecognized); + throw new cliexception(get_string('cliunknowoption', 'admin', $unrecognized)); + } + } else { + // If not using Moodle CLI API to read parameters, ensure all keys exist. + $default = $this->generateoptions(); + foreach ($options as $k => $v) { + if (!array_key_exists($k, $default)) { + throw new cliexception(get_string('cliunknowoption', 'admin', $k)); + } + $default[$k] = $v; + } + $options = $default; + } + + $this->options = $options; + $this->time = time(); + } + + /** + * Sets the reference time for creating outages. + * @param int $time Timestamp for the reference time. + */ + public function set_referencetime($time) { + if (!is_int($time) || ($time <= 0)) { + throw new InvalidArgumentException('$time must be a positive int.'); + } + $this->time = $time; + } + + /** + * Generates all options (parameters) available for the CLI command. + * @return array Options. + */ + public abstract function generateoptions(); + + /** + * Generate all short forms for the available options. + * @return array Short form options. + */ + public abstract function generateshortcuts(); + + /** + * Executes the CLI script. + */ + public abstract function execute(); + + /** + * Change session to admin user. + */ + protected function becomeadmin() { + global $DB; + $user = $DB->get_record('user', array('id' => 2)); + unset($user->description); + unset($user->access); + unset($user->preference); + manager::init_empty_session(); + manager::set_user($user); + } + + /** + * Outputs a help message. + * @param string $cliname Name of CLI used in the language file. + */ + protected function showhelp($cliname) { + $options = $this->generateoptions(); + $shorts = array_flip($this->generateshortcuts()); + + printf("%s\n\n", get_string('cli' . $cliname . 'help', 'auth_outage')); + foreach (array_keys($options) as $long) { + $text = get_string('cli' . $cliname . 'param' . $long, 'auth_outage'); + $short = isset($shorts[$long]) ? ('-' . $shorts[$long] . ',') : ''; + $long = '--' . $long; + printf(" %-4s %-20s %s\n", $short, $long, $text); + } + } +} diff --git a/classes/cli/cliexception.php b/classes/cli/cliexception.php new file mode 100644 index 0000000..2ae4b1f --- /dev/null +++ b/classes/cli/cliexception.php @@ -0,0 +1,30 @@ +. + +namespace auth_outage\cli; + +use Exception; + +/** + * Exception executing CLI. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cliexception extends Exception { +} diff --git a/classes/cli/create.php b/classes/cli/create.php new file mode 100644 index 0000000..0275c07 --- /dev/null +++ b/classes/cli/create.php @@ -0,0 +1,208 @@ +. + +namespace auth_outage\cli; + +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Outage CLI to create outage. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class create extends clibase { + /** + * @var array Defaults to use if given option is null. + */ + private $defaults; + + /** + * Generates all options (parameters) available for the CLI command. + * @return array Options. + */ + public function generateoptions() { + // Do not provide some defaults, if cloning an outage we need to know which parameters were provided. + $options = [ + 'help' => false, + 'clone' => null, + 'warn' => null, + 'start' => null, + 'duration' => null, + 'title' => null, + 'description' => null, + 'onlyid' => false, + 'block' => false, + ]; + return $options; + } + + /** + * Generate all short forms for the available options. + * @return array Short form options. + */ + public function generateshortcuts() { + return [ + 'b' => 'block', + 'c' => 'clone', + 'd' => 'duration', + 'e' => 'description', + 'h' => 'help', + 's' => 'start', + 't' => 'title', + 'w' => 'warn', + ]; + } + + /** + * Sets the default values for options. + * @param array $defaults Defaults. + */ + public function set_defaults(array $defaults) { + $this->defaults = $defaults; + } + + /** + * Executes the CLI. + */ + public function execute() { + // Help always overrides any other parameter. + if ($this->options['help']) { + $this->showhelp('create'); + return; + } + + // If not help mode, 'start' is required and cannot use default. + if (is_null($this->options['start'])) { + throw new cliexception(get_string('clierrormissingparamaters', 'auth_outage')); + } + + // If cloning, set defaults to outage being cloned. + if (!is_null($this->options['clone'])) { + $this->clonedefaults(); + } + + // Merge provided parameters with defaults then create outage. + $options = $this->mergeoptions(); + $id = $this->createoutage($options); + + if ($options['block']) { + $block = new waitforit(['outageid' => $id]); + $block->execute(); + } + } + + /** + * Merges provided options with defaults, checking and converting types as needed. + * @return array Parameters to use. + * @throws cliexception + */ + private function mergeoptions() { + $options = $this->options; + // Merge with defaults. + if (!is_null($this->defaults)) { + foreach ($options as $k => $v) { + if (is_null($v) && array_key_exists($k, $this->defaults)) { + $options[$k] = $this->defaults[$k]; + } + } + } + + return $this->mergeoptions_checkparameters($options); + } + + /** + * Creates an outages based on the provided options. + * @param array $options Options used to create the outage. + * @return int Id of the new outage. + */ + private function createoutage(array $options) { + // We need to become an admin to avoid permission problems. + $this->becomeadmin(); + + // Create the outage. + $start = $this->time + ($options['start'] * 60); + $outage = new outage([ + 'warntime' => $start - ($options['warn'] * 60), + 'starttime' => $start, + 'stoptime' => $start + ($options['duration'] * 60), + 'title' => $options['title'], + 'description' => $options['description'], + ]); + $id = outagedb::save($outage); + + // All done! + if ($options['onlyid']) { + printf("%d\n", $id); + } else { + printf("%s\n", get_string('clioutagecreated', 'auth_outage', ['id' => $id])); + } + + return $id; + } + + private function clonedefaults() { + $id = $this->options['clone']; + if (!is_number($id) || ($id <= 0)) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => 'clone'])); + } + + $outage = outagedb::get_by_id((int)$id); + $this->set_defaults([ + 'warn' => (int)($outage->get_warning_duration() / 60), + 'duration' => (int)($outage->get_duration() / 60), + 'title' => $outage->title, + 'description' => $outage->description, + ]); + } + + /** + * Check parameters converting their type as needed. + * @param array $options Input options. + * @return array Output options. + * @throws cliexception + */ + private function mergeoptions_checkparameters(array $options) { + // Check parameters that must be a non-negative int while converting their type to int. + foreach (['start', 'warn', 'duration'] as $param) { + if (!is_number($options[$param])) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => $param])); + } + $options[$param] = (int)$options[$param]; + if ($options[$param] < 0) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => $param])); + } + } + + // Check parameters that must be a non empty string. + foreach (['title', 'description'] as $param) { + if (!is_string($options[$param])) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => $param])); + } + $options[$param] = trim($options[$param]); + if (strlen($options[$param]) == 0) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => $param])); + } + } + + return $options; + } +} diff --git a/classes/cli/finish.php b/classes/cli/finish.php new file mode 100644 index 0000000..a1d56e3 --- /dev/null +++ b/classes/cli/finish.php @@ -0,0 +1,106 @@ +. + +namespace auth_outage\cli; + +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Outage CLI to finish an outage. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class finish extends clibase { + /** + * Generates all options (parameters) available for the CLI command. + * @return array Options. + */ + public function generateoptions() { + // Do not provide some defaults, if cloning an outage we need to know which parameters were provided. + $options = [ + 'help' => false, + 'outageid' => null, + 'active' => false, + ]; + return $options; + } + + /** + * Generate all short forms for the available options. + * @return array Short form options. + */ + public function generateshortcuts() { + return [ + 'h' => 'help', + 'id' => 'outageid', + 'a' => 'active', + ]; + } + + /** + * Executes the CLI. + */ + public function execute() { + // Help always overrides any other parameter. + if ($this->options['help']) { + $this->showhelp('finish'); + return; + } + + // Requires outageid or active but not both at the same time. + $byid = !is_null($this->options['outageid']); + $byactive = $this->options['active']; + if ($byid == $byactive) { + throw new cliexception(get_string('cliwaitforiterroridxoractive', 'auth_outage')); + } + + $outage = $this->get_outage(); + if (!$outage->is_ongoing()) { + throw new cliexception(get_string('clifinishnotongoing', 'auth_outage')); + } + + outagedb::finish($outage->id, $this->time); + } + + /** + * Gets the outage to finish. + * @return outage|null The outage to wait for. + * @throws cliexception + */ + private function get_outage() { + if ($this->options['active']) { + $outage = outagedb::get_active(); + } else { + $id = $this->options['outageid']; + if (!is_number($id) || ($id <= 0)) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => 'outageid'])); + } + $outage = outagedb::get_by_id((int)$id); + } + + if (is_null($outage)) { + throw new cliexception(get_string('clierroroutagenotfound', 'auth_outage')); + } + + return $outage; + } +} diff --git a/classes/cli/waitforit.php b/classes/cli/waitforit.php new file mode 100644 index 0000000..8ec4ed7 --- /dev/null +++ b/classes/cli/waitforit.php @@ -0,0 +1,178 @@ +. + +namespace auth_outage\cli; + +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Outage CLI to wait for an outage to start. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class waitforit extends clibase { + /** + * Default value if --sleep no provided. + */ + const DEFAULT_SLEEP_SECONDS = 300; + + /** + * @var callable Alternative callback for sleeping thread, must return the new reference timestamp. + */ + private $sleepcallback = null; + + /** + * Generates all options (parameters) available for the CLI command. + * @return array Options. + */ + public function generateoptions() { + // Do not provide some defaults, if cloning an outage we need to know which parameters were provided. + $options = [ + 'help' => false, + 'outageid' => null, + 'active' => false, + 'verbose' => false, + 'sleep' => self::DEFAULT_SLEEP_SECONDS, + ]; + return $options; + } + + /** + * Generate all short forms for the available options. + * @return array Short form options. + */ + public function generateshortcuts() { + return [ + 'h' => 'help', + 'id' => 'outageid', + 'a' => 'active', + 'v' => 'verbose', + 's' => 'sleep', + ]; + } + + /** + * Sets a callback to be used instead of the sleep method. + * @param callable $callback Callback function. + */ + public function set_sleepcallback(callable $callback) { + $this->sleepcallback = $callback; + } + + /** + * Executes the CLI. + */ + public function execute() { + // Help always overrides any other parameter. + if ($this->options['help']) { + $this->showhelp('waitforit'); + return; + } + + // Requires outageid or active but not both at the same time. + $byid = !is_null($this->options['outageid']); + $byactive = $this->options['active']; + if ($byid == $byactive) { + throw new cliexception(get_string('cliwaitforiterroridxoractive', 'auth_outage')); + } + + $this->verbose('Verbose mode activated.'); + + $outage = $this->get_outage(); + + while ($sleep = $this->waitforoutagestart($outage)) { + if (is_null($this->sleepcallback)) { + $this->verbose('Sleeping for ' . $sleep . ' second(s).'); + sleep($sleep); + $this->time = time(); + } else { + $this->verbose('Calling callback to sleep ' . $sleep . ' second(s).'); + $callback = $this->sleepcallback; + $this->time = $callback($sleep); + } + } + } + + /** + * Shows a message if in verbose mode. + * @param string $message Message. + */ + private function verbose($message) { + if (!$this->options['verbose']) { + return; + } + + $time = strftime('%F %T %Z'); + printf("[%s] %s\n", $time, $message); + } + + /** + * Gets the outage to wait for. + * @return outage|null The outage to wait for. + * @throws cliexception + */ + private function get_outage() { + if ($this->options['active']) { + $this->verbose('Querying database for active outage...'); + $outage = outagedb::get_active(); + } else { + $id = $this->options['outageid']; + if (!is_number($id) || ($id <= 0)) { + throw new cliexception(get_string('clierrorinvalidvalue', 'auth_outage', ['param' => 'outageid'])); + } + $this->verbose('Querying database for outage #' . $id . '...'); + $outage = outagedb::get_by_id((int)$id); + } + + if (is_null($outage)) { + throw new cliexception(get_string('clierroroutagenotfound', 'auth_outage')); + } + + $this->verbose('Found outage #' . $outage->id . ': ' . $outage->get_title()); + return $outage; + } + + private function waitforoutagestart(outage $outage) { + $this->verbose('Checking outage status...'); + // Outage should not change while waiting to start. + if (outagedb::get_by_id($outage->id) != $outage) { + throw new cliexception(get_string('clierroroutagechanged', 'auth_outage')); + } + // Outage cannot have already ended. + if ($outage->has_ended($this->time)) { + throw new cliexception(get_string('clierroroutageended', 'auth_outage')); + } + // If outage has started, do not wait. + if ($outage->is_ongoing($this->time)) { + printf("%s\n", get_string('cliwaitforitoutagestarted', 'auth_outage')); + return 0; + } + // Outage nas not started yet. + $countdown = $outage->starttime - $this->time; + printf("%s\n", get_string( + 'cliwaitforitoutagestartingin', + 'auth_outage', + ['countdown' => format_time($countdown)] + )); + return min($countdown, $this->options['sleep']); + } +} diff --git a/cli/create.php b/cli/create.php new file mode 100644 index 0000000..4e94124 --- /dev/null +++ b/cli/create.php @@ -0,0 +1,48 @@ +. + +/** + * CLI for creating outages. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\cli\cliexception; +use auth_outage\cli\create; + +define('CLI_SCRIPT', true); +require('../../config.php'); + +$cli = new create(); + +$config = get_config('auth_outage'); +$cli->set_defaults([ + 'help' => false, + 'warn' => (int)($config->warning_duration), + 'start' => null, + 'duration' => (int)($config->default_duration), + 'title' => $config->warning_title, + 'description' => $config->warning_description, +]); + +try { + $cli->execute(); +} catch (cliexception $e) { + cli_error($e->getMessage()); +} diff --git a/cli/finish.php b/cli/finish.php new file mode 100644 index 0000000..00daa0d --- /dev/null +++ b/cli/finish.php @@ -0,0 +1,39 @@ +. + +/** + * CLI for finishing an outage. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\cli\cliexception; +use auth_outage\cli\finish; + +define('CLI_SCRIPT', true); +require('../../config.php'); + +$cli = new finish(); + +try { + $cli->execute(); +} catch (cliexception $e) { + cli_error($e->getMessage()); +} + diff --git a/cli/waitforit.php b/cli/waitforit.php new file mode 100644 index 0000000..fd87d8f --- /dev/null +++ b/cli/waitforit.php @@ -0,0 +1,39 @@ +. + +/** + * CLI for waiting (blocking) until an outage starts. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use auth_outage\cli\cliexception; +use auth_outage\cli\waitforit; + +define('CLI_SCRIPT', true); +require('../../config.php'); + +$cli = new waitforit(); + +try { + $cli->execute(); +} catch (cliexception $e) { + cli_error($e->getMessage()); +} + diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 76277d3..4e08833 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -24,6 +24,36 @@ */ $string['auth_outagedescription'] = 'Auxiliary plugin that warns users about a future outage and prevents them from logging in once the outage starts.'; +$string['clicreatehelp'] = 'Creates a new outage.'; +$string['clicreateparamblock'] = 'blocks until outage starts.'; +$string['clicreateparamclone'] = 'clone another outage except for the start time.'; +$string['clicreateparamdescription'] = 'the description of the outage.'; +$string['clicreateparamduration'] = 'how many minutes should the outage last.'; +$string['clicreateparamhelp'] = 'shows parameters help.'; +$string['clicreateparamonlyid'] = 'only outputs the new outage id, useful for scripts.'; +$string['clicreateparamstart'] = 'in how many minutes should this outage start. Required.'; +$string['clicreateparamtitle'] = 'the title of the outage.'; +$string['clicreateparamwarn'] = 'how many minutes before it starts to display a warning.'; +$string['clifinishhelp'] = 'Finishes an ongoing outage.'; +$string['clifinishnotongoing'] = 'Outage is not ongoing.'; +$string['clifinishparamhelp'] = 'shows parameters help.'; +$string['clifinishparamactive'] = 'finishes the currently active outage.'; +$string['clifinishparamoutageid'] = 'the id of the outage to finish.'; +$string['cliwaitforiterroridxoractive'] = 'You must use --outageid=# or --active parameter but not both.'; +$string['cliwaitforithelp'] = 'Waits until an outage starts.'; +$string['cliwaitforitoutagestarted'] = 'Outage started!'; +$string['cliwaitforitoutagestartingin'] = 'Outage starting in {$a->countdown}.'; +$string['cliwaitforitparamactive'] = 'wait for the currently active outage.'; +$string['cliwaitforitparamhelp'] = 'shows parameters help.'; +$string['cliwaitforitparamoutageid'] = 'the id of the outage to wait until it starts.'; +$string['cliwaitforitparamsleep'] = 'maximum amount of seconds before status output.'; +$string['cliwaitforitparamverbose'] = 'enable verbose mode.'; +$string['clierrorinvalidvalue'] = 'Invalid value for parameter: {$a->param}'; +$string['clierrormissingparamaters'] = 'You must specify the start time, use --help for more information.'; +$string['clierroroutagechanged'] = 'Outage was changed while waiting.'; +$string['clierroroutageended'] = 'Outage has already ended.'; +$string['clierroroutagenotfound'] = 'Outage not found.'; +$string['clioutagecreated'] = 'Outage created, id: {$a->id}'; $string['clone'] = 'Clone'; $string['datetimeformat'] = '%a %d %h %Y at %I:%M%P %Z'; $string['defaultlayoutcss'] = 'Layout CSS'; @@ -51,6 +81,7 @@ $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Back online at {$a->stop}.'; $string['messageoutagewarning'] = 'Shutting down in {{countdown}}'; +$string['na'] = 'n/a'; $string['notfound'] = 'No outages found.'; $string['outageedit'] = 'Edit Outage'; $string['outageclone'] = 'Clone Outage'; diff --git a/renderer.php b/renderer.php index 0adcf4c..f09eb2b 100644 --- a/renderer.php +++ b/renderer.php @@ -111,17 +111,25 @@ class auth_outage_renderer extends plugin_renderer_base { private function renderoutage(outage $outage, $buttons) { global $OUTPUT; - $created = core_user::get_user($outage->createdby, 'firstname,lastname', MUST_EXIST); - $created = html_writer::link( - new moodle_url('/user/profile.php', ['id' => $outage->createdby]), - trim($created->firstname . ' ' . $created->lastname) - ); + if ($outage->createdby == 0) { + $created = get_string('na', 'auth_outage'); + } else { + $created = core_user::get_user($outage->createdby, 'firstname,lastname', MUST_EXIST); + $created = html_writer::link( + new moodle_url('/user/profile.php', ['id' => $outage->createdby]), + trim($created->firstname . ' ' . $created->lastname) + ); + } - $modified = core_user::get_user($outage->modifiedby, 'firstname,lastname', MUST_EXIST); - $modified = html_writer::link( - new moodle_url('/user/profile.php', ['id' => $outage->modifiedby]), - trim($modified->firstname . ' ' . $modified->lastname) - ); + if ($outage->modifiedby == 0) { + $modified = get_string('na', 'auth_outage'); + } else { + $modified = core_user::get_user($outage->modifiedby, 'firstname,lastname', MUST_EXIST); + $modified = html_writer::link( + new moodle_url('/user/profile.php', ['id' => $outage->modifiedby]), + trim($modified->firstname . ' ' . $modified->lastname) + ); + } $url = new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]); $img = html_writer::empty_tag( diff --git a/tests/cli/cli_testcase.php b/tests/cli/cli_testcase.php new file mode 100644 index 0000000..74268bf --- /dev/null +++ b/tests/cli/cli_testcase.php @@ -0,0 +1,61 @@ +. + +use auth_outage\cli\clibase; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests performed on CLIs. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class cli_testcase extends advanced_testcase { + public function setUp() { + $this->resetAfterTest(true); + $this->set_parameters([]); + parent::setUp(); + } + + /** + * Mocks the command line parameters. + * @param array $options Options to use as parameters. + */ + protected function set_parameters(array $options) { + array_unshift($options, 'cli.php'); + $_SERVER['argv'] = $options; + $_SERVER['argc'] = count($options); + } + + /** + * Executes the CLI. + * @param clibase $cli CLI to execute. + * @return string The output text. + */ + protected function execute(clibase $cli) { + ob_start(); + try { + $cli->execute(); + $text = ob_get_contents(); + return $text; + } finally { + ob_end_clean(); + } + } +} diff --git a/tests/cli/create_test.php b/tests/cli/create_test.php new file mode 100644 index 0000000..9ba720a --- /dev/null +++ b/tests/cli/create_test.php @@ -0,0 +1,270 @@ +. + +use auth_outage\cli\cliexception; +use auth_outage\cli\create; +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); +require_once('cli_testcase.php'); + +/** + * Tests performed on CLI create class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @SuppressWarnings("public") + */ +class create_test extends cli_testcase { + public function test_noarguments() { + $cli = new create(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_invalidargumentparam() { + $this->set_parameters(['--aninvalidparameter']); + $this->setExpectedException(cliexception::class); + new create(); + } + + public function test_invalidargumentgiven() { + $this->setExpectedException(cliexception::class); + new create(['anotherinvalidparameter']); + } + + public function test_invalidparam_notanumber() { + $cli = new create(['start' => 'some day']); + $cli->set_defaults([ + 'warn' => 50, + 'start' => 200, + 'duration' => 300, + 'title' => 'Default Title', + 'description' => 'Default Description', + ]); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_invalidparam_negative() { + $cli = new create(['start' => -1]); + $cli->set_defaults([ + 'warn' => 50, + 'start' => 200, + 'duration' => 300, + 'title' => 'Default Title', + 'description' => 'Default Description', + ]); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_invalidparam_emptystring() { + $cli = new create(['start' => 0, 'title' => '']); + $cli->set_defaults([ + 'warn' => 50, + 'start' => 200, + 'duration' => 300, + 'title' => 'Default Title', + 'description' => 'Default Description', + ]); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_invalidparam_notastring() { + $cli = new create(['start' => 0, 'title' => true]); + $cli->set_defaults([ + 'warn' => 50, + 'start' => 200, + 'duration' => 300, + 'title' => 'Default Title', + 'description' => 'Default Description', + ]); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_setreferencetime_invalid() { + $cli = new create(['start' => 0]); + $this->setExpectedException(InvalidArgumentException::class); + $cli->set_referencetime(-1); + } + + public function test_help() { + $this->set_parameters(['--help']); + $cli = new create(); + $output = $this->execute($cli); + self::assertContains('Creates', $output); + self::assertContains('--help', $output); + } + + public function test_options() { + $cli = new create(); + + $options = $cli->generateoptions(); + foreach (array_keys($options) as $k) { + self::assertTrue(is_string($k)); + } + + $shorts = $cli->generateshortcuts(); + foreach ($shorts as $s) { + self::assertArrayHasKey($s, $options); + } + } + + public function test_create_withoptions() { + $this->set_parameters([ + '--warn=10', + '--start=0', + '--duration=30', + '--title=A Title', + '--description=A Description', + ]); + $now = time(); + $cli = new create(); + $cli->set_referencetime($now); + $text = $this->execute($cli); + self::assertContains('created', $text); + // Check creted outage. + list(, $id) = explode(':', $text); + $id = (int)$id; + $outage = outagedb::get_by_id($id); + self::assertSame($now, $outage->starttime); + self::assertSame(10 * 60, $outage->get_warning_duration()); + self::assertSame(30 * 60, $outage->get_duration()); + self::assertNull($outage->finished); + self::assertSame('A Title', $outage->title); + self::assertSame('A Description', $outage->description); + } + + public function test_create_onlyid() { + $this->set_parameters([ + '--onlyid', + '--warn=10', + '--start=0', + '--duration=30', + '--title=Title', + '--description=Description', + ]); + $now = time(); + $cli = new create(); + $cli->set_referencetime($now); + $id = $this->execute($cli); + // Check if the id contains is only a number (parameter onlyid). + $id = trim($id); + self::assertTrue(is_number($id)); + $id = (int)$id; + // Check creted outage. + $outage = outagedb::get_by_id($id); + self::assertSame($now, $outage->starttime); + self::assertSame($outage->starttime - (10 * 60), $outage->warntime); + self::assertSame($outage->starttime + (30 * 60), $outage->stoptime); + self::assertNull($outage->finished); + self::assertSame('Title', $outage->title); + self::assertSame('Description', $outage->description); + } + + public function test_create_withdefaults() { + $this->set_parameters([ + '--warn=100', + '--start=50', + ]); + $now = time(); + $cli = new create(); + $cli->set_referencetime($now); + $cli->set_defaults([ + 'warn' => 50, + 'start' => 200, + 'duration' => 300, + 'title' => 'Default Title', + 'description' => 'Default Description', + ]); + $text = $this->execute($cli); + self::assertContains('created', $text); + // Check creted outage. + list(, $id) = explode(':', $text); + $id = (int)$id; + $outage = outagedb::get_by_id($id); + self::assertSame($now + (50 * 60), $outage->starttime, 'Wrong starttime.'); + self::assertSame($outage->starttime - (100 * 60), $outage->warntime, 'Wrong warntime.'); + self::assertSame($outage->starttime + (300 * 60), $outage->stoptime, 'Wrong stoptime.'); + self::assertNull($outage->finished); + self::assertSame('Default Title', $outage->title); + self::assertSame('Default Description', $outage->description); + } + + public function test_create_withclone() { + $this->setAdminUser(); + $now = time(); + // Create the outage to clone. + $original = new outage([ + 'warntime' => $now - 120, + 'starttime' => $now, + 'stoptime' => $now + 120, + 'title' => 'Title', + 'description' => 'Description', + ]); + $id = outagedb::save($original); + // Clone it using CLI. + $this->set_parameters([ + '--onlyid', + '--start=60', + '--clone=' . $id, + ]); + $cli = new create(); + $cli->set_referencetime($now); + $id = trim($this->execute($cli)); + // Check cloned data. + $cloned = outagedb::get_by_id((int)$id); + self::assertSame($now + (60 * 60), $cloned->starttime); + self::assertSame($original->get_warning_duration(), $cloned->get_warning_duration()); + self::assertSame($original->get_duration(), $cloned->get_duration()); + self::assertSame($original->title, $cloned->title); + self::assertSame($original->description, $cloned->description); + } + + public function test_create_withclone_invalid() { + $this->setExpectedException(cliexception::class); + $this->set_parameters([ + '--start=60', + '--clone=-1', + ]); + $cli = new create(); + $this->execute($cli); + } + + public function test_create_withblock() { + // Not an extensive test in the blocking API, cliwaitforit tests should cover them deeper. + $this->set_parameters([ + '--block', + '--warn=10', + '--start=0', + '--duration=30', + '--title=Title', + '--description=Description', + ]); + $now = time(); + $cli = new create(); + $cli->set_referencetime($now); + $text = $this->execute($cli); + self::assertContains('created', $text); + self::assertContains('started', $text); + } +} diff --git a/tests/cli/finish_test.php b/tests/cli/finish_test.php new file mode 100644 index 0000000..d2f9095 --- /dev/null +++ b/tests/cli/finish_test.php @@ -0,0 +1,64 @@ +. + +use auth_outage\cli\cliexception; +use auth_outage\cli\finish; + +defined('MOODLE_INTERNAL') || die(); +require_once('cli_testcase.php'); + +/** + * Tests performed on CLI finish class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class finish_test extends cli_testcase { + public function test_constructor() { + $cli = new finish(); + self::assertNotNull($cli); + } + + public function test_options() { + $cli = new finish(); + + $options = $cli->generateoptions(); + foreach (array_keys($options) as $k) { + self::assertTrue(is_string($k)); + } + + $shorts = $cli->generateshortcuts(); + foreach ($shorts as $s) { + self::assertArrayHasKey($s, $options); + } + } + + public function test_help() { + $this->set_parameters(['--help']); + $cli = new finish(); + $text = $this->execute($cli); + self::assertContains('Finishes', $text); + self::assertContains('--help', $text); + } + + public function test_noarguments() { + $cli = new finish(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } +} diff --git a/tests/cli/waitforit_test.php b/tests/cli/waitforit_test.php new file mode 100644 index 0000000..5be7881 --- /dev/null +++ b/tests/cli/waitforit_test.php @@ -0,0 +1,171 @@ +. + +use auth_outage\cli\cliexception; +use auth_outage\cli\waitforit; +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); +require_once('cli_testcase.php'); + +/** + * Tests performed on CLI waitforit class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @SuppressWarnings("public") + */ +class waitforit_test extends cli_testcase { + public function test_constructor() { + $cli = new waitforit(); + self::assertNotNull($cli); + } + + public function test_generateoptions() { + $cli = new waitforit(); + $options = $cli->generateoptions(); + foreach (array_keys($options) as $k) { + self::assertTrue(is_string($k)); + } + } + + public function test_generateshortcuts() { + $cli = new waitforit(); + $options = $cli->generateoptions(); + $shorts = $cli->generateshortcuts(); + foreach ($shorts as $s) { + self::assertArrayHasKey($s, $options); + } + } + + public function test_help() { + $this->set_parameters(['--help']); + $cli = new waitforit(); + $text = $this->execute($cli); + self::assertContains('Waits', $text); + self::assertContains('--help', $text); + } + + public function test_bothparams() { + $this->set_parameters(['--outageid=1', '--active']); + $cli = new waitforit(); + $this->setExpectedException(cliexception::class); + $cli->execute(); + } + + public function test_invalidoutageid() { + $this->set_parameters(['-id=-1']); + $cli = new waitforit(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_outagenotfound() { + $this->set_parameters(['-a']); + $cli = new waitforit(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_endedoutage() { + $this->setAdminUser(); + $now = time(); + $id = outagedb::save(new outage([ + 'warntime' => $now - 200, + 'starttime' => $now - 100, + 'stoptime' => $now - 50, + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-id=' . $id]); + $cli = new waitforit(); + $cli->set_referencetime($now); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_activeverbose() { + $this->setAdminUser(); + $now = time(); + outagedb::save(new outage([ + 'warntime' => $now - 10, + 'starttime' => $now + 1, + 'stoptime' => $now + 10, + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-v', '--active']); + $cli = new waitforit(); + $cli->set_referencetime($now); + $output = $this->execute($cli); + self::assertContains('Verbose mode', $output); + self::assertContains('starting in 1 sec', $output); + self::assertContains('started', $output); + } + + public function test_countdown() { + $this->setAdminUser(); + $now = time(); + outagedb::save(new outage([ + 'warntime' => $now, + 'starttime' => $now + 45, + 'stoptime' => $now + (60 * 60), + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-v', '--active', '--sleep=30']); + $cli = new waitforit(); + $cli->set_referencetime($now); + $cli->set_sleepcallback(function ($sleep) use (&$now) { + $now += $sleep; + return $now; + }); + $output = $this->execute($cli); + self::assertContains("starting in 45", $output); + self::assertContains("sleep 30 second", $output); + self::assertContains("starting in 15", $output); + self::assertContains("sleep 15 second", $output); + self::assertContains("started!", $output); + } + + public function test_outagechanged() { + $this->setAdminUser(); + $now = time(); + $id = outagedb::save(new outage([ + 'warntime' => $now, + 'starttime' => $now + (2 * 60 * 60), + 'stoptime' => $now + (60 * 60), + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-v', '--active', '--sleep=30']); + $cli = new waitforit(); + $cli->set_referencetime($now); + $cli->set_sleepcallback(function () use ($id) { + // Change outage when not expected to. + $outage = outagedb::get_by_id($id); + $outage->title = 'New title!'; + outagedb::save($outage); + // Pretend it is time to start, but it should get an error instead. + return $outage->starttime; + }); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } +} diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 0000000..7466503 --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,31 @@ + + + + + + ./ + + + + + + ../classes + + + From f0ae7b841000ddc30d1aad3b9c27a55f2e87e645 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 13:13:03 +1000 Subject: [PATCH 57/72] Issue #30 - Fixed stage values and broken tests. --- classes/models/outage.php | 10 +++++----- tests/outage_test.php | 4 ++-- tests/phpunit.xml | 31 +++++++++++++++++++++++++++++++ views/warningbar.css | 6 +++--- 4 files changed, 41 insertions(+), 10 deletions(-) create mode 100644 tests/phpunit.xml diff --git a/classes/models/outage.php b/classes/models/outage.php index 9ebd2ba..6f51efa 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -30,27 +30,27 @@ class outage { /** * Outage is before warning period. */ - const STAGE_WAITING = 1; + const STAGE_WAITING = 'waiting'; /** * Outage not started but in warning period. */ - const STAGE_WARNING = 2; + const STAGE_WARNING = 'warning'; /** * Outage ongoing, it has passed the warning period. */ - const STAGE_ONGOING = 3; + const STAGE_ONGOING = 'ongoing'; /** * Outage finished, it is after the marked finished time. */ - const STAGE_FINISHED = 4; + const STAGE_FINISHED = 'finished'; /** * Outage stopped, it is after the stop time and not marked as finished. */ - const STAGE_STOPPED = 4; + const STAGE_STOPPED = 'stopped'; /** * @var int Outage ID (auto generated by the DB). diff --git a/tests/outage_test.php b/tests/outage_test.php index bc0de2c..d0c88d5 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -161,7 +161,7 @@ class outage_test extends basic_testcase { $outage = new outage([ 'warntime' => $now - 50, 'starttime' => $now - 40, - 'finishtime' => $now - 30, + 'finished' => $now - 30, 'stoptime' => $now - 20, 'title' => 'Outage Finished before Stop', ]); @@ -173,7 +173,7 @@ class outage_test extends basic_testcase { 'warntime' => $now - 50, 'starttime' => $now - 40, 'stoptime' => $now - 30, - 'finishtime' => $now - 20, + 'finished' => $now - 20, 'title' => 'Outage Finished after Stop', ]); self::assertSame(outage::STAGE_FINISHED, $outage->get_stage($now)); diff --git a/tests/phpunit.xml b/tests/phpunit.xml new file mode 100644 index 0000000..7466503 --- /dev/null +++ b/tests/phpunit.xml @@ -0,0 +1,31 @@ + + + + + + ./ + + + + + + ../classes + + + diff --git a/views/warningbar.css b/views/warningbar.css index acb7c53..23fa95e 100644 --- a/views/warningbar.css +++ b/views/warningbar.css @@ -34,11 +34,11 @@ If you need to make changes here, remember to update your settings inside Moodle margin: 10px 0; } -A.auth_outage_warningbar_box_title { +a.auth_outage_warningbar_box_title { color: white; } -A.auth_outage_warningbar_box_finish { +a.auth_outage_warningbar_box_finish { background-color: white; border: 1px solid black; border-radius: 5px; @@ -51,7 +51,7 @@ A.auth_outage_warningbar_box_finish { transition: background-color 200ms linear; } -A.auth_outage_warningbar_box_finish:hover { +a.auth_outage_warningbar_box_finish:hover { background-color: black; } From 671e7d5f674f6d664f1d567f4668ec2054bd8ff7 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 15:05:20 +1000 Subject: [PATCH 58/72] Issue #38 - Ensuring we always have default values for plugin config. --- classes/outagelib.php | 16 ++++++++++ cli/create.php | 3 +- new.php | 2 +- settings.php | 69 ++++++++++++++++++++++++------------------- views/warningbar.php | 4 ++- 5 files changed, 60 insertions(+), 34 deletions(-) diff --git a/classes/outagelib.php b/classes/outagelib.php index 1dce55c..b9f3625 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -88,4 +88,20 @@ class outagelib { $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active, $time) . $CFG->additionalhtmltopofbody; } + + public static function get_config() { + return (object)array_merge(self::get_config_defaults(), (array)get_config('auth_outage')); + } + + public static function get_config_defaults() { + global $CFG; + + return [ + 'default_duration' => 60, + 'warning_duration' => 60, + 'warning_title' => get_string('defaultwarningtitlevalue', 'auth_outage'), + 'warning_description' => get_string('defaultwarningdescriptionvalue', 'auth_outage'), + 'css' => file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css'), + ]; + } } diff --git a/cli/create.php b/cli/create.php index 4e94124..18f2c54 100644 --- a/cli/create.php +++ b/cli/create.php @@ -25,13 +25,14 @@ use auth_outage\cli\cliexception; use auth_outage\cli\create; +use auth_outage\outagelib; define('CLI_SCRIPT', true); require('../../config.php'); $cli = new create(); -$config = get_config('auth_outage'); +$config = outagelib::get_config(); $cli->set_defaults([ 'help' => false, 'warn' => (int)($config->warning_duration), diff --git a/new.php b/new.php index f9f3305..4d03ce1 100644 --- a/new.php +++ b/new.php @@ -41,7 +41,7 @@ if ($mform->is_cancelled()) { redirect('/auth/outage/manage.php#auth_outage_id_' . $id); } -$config = get_config('auth_outage'); +$config = outagelib::get_config(); $defaults = new outage([ 'starttime' => time(), 'stoptime' => time() + ($config->default_duration * 60), diff --git a/settings.php b/settings.php index 3df7fc2..f1f3836 100644 --- a/settings.php +++ b/settings.php @@ -22,42 +22,49 @@ * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use auth_outage\outagelib; + defined('MOODLE_INTERNAL') || die; if ($hassiteconfig && is_enabled_auth('outage')) { + $defaults = outagelib::get_config_defaults(); // Configure default settings page. $settings->visiblename = get_string('menudefaults', 'auth_outage'); - $settings->add( - new admin_setting_configtext('auth_outage/default_duration', - get_string('defaultoutageduration', 'auth_outage'), - get_string('defaultoutagedurationdescription', 'auth_outage'), - 60, PARAM_INT)); - $settings->add( - new admin_setting_configtext('auth_outage/warning_duration', - get_string('defaultwarningduration', 'auth_outage'), - get_string('defaultwarningdurationdescription', 'auth_outage'), - 60, PARAM_INT)); - $settings->add( - new admin_setting_configtext('auth_outage/warning_title', - get_string('defaultwarningtitle', 'auth_outage'), - get_string('defaultwarningtitledescription', 'auth_outage'), - get_string('defaultwarningtitlevalue', 'auth_outage'), - PARAM_TEXT) - ); - $settings->add( - new admin_setting_configtextarea('auth_outage/warning_description', - get_string('defaultwarningdescription', 'auth_outage'), - get_string('defaultwarningdescriptiondescription', 'auth_outage'), - get_string('defaultwarningdescriptionvalue', 'auth_outage'), - PARAM_TEXT) - ); - $settings->add( - new admin_setting_configtextarea('auth_outage/css', - get_string('defaultlayoutcss', 'auth_outage'), - get_string('defaultlayoutcssdescription', 'auth_outage'), - file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css'), - PARAM_TEXT) - ); + $settings->add(new admin_setting_configtext( + 'auth_outage/default_duration', + get_string('defaultoutageduration', 'auth_outage'), + get_string('defaultoutagedurationdescription', 'auth_outage'), + $defaults['default_duration'], + PARAM_INT + )); + $settings->add(new admin_setting_configtext( + 'auth_outage/warning_duration', + get_string('defaultwarningduration', 'auth_outage'), + get_string('defaultwarningdurationdescription', 'auth_outage'), + $defaults['warning_duration'], + PARAM_INT + )); + $settings->add(new admin_setting_configtext( + 'auth_outage/warning_title', + get_string('defaultwarningtitle', 'auth_outage'), + get_string('defaultwarningtitledescription', 'auth_outage'), + $defaults['warning_title'], + PARAM_TEXT + )); + $settings->add(new admin_setting_configtextarea( + 'auth_outage/warning_description', + get_string('defaultwarningdescription', 'auth_outage'), + get_string('defaultwarningdescriptiondescription', 'auth_outage'), + $defaults['warning_description'], + PARAM_TEXT + )); + $settings->add(new admin_setting_configtextarea( + 'auth_outage/css', + get_string('defaultlayoutcss', 'auth_outage'), + get_string('defaultlayoutcssdescription', 'auth_outage'), + $defaults['css'], + PARAM_TEXT + )); // Create category for Outage. $ADMIN->add('authsettings', new admin_category('auth_outage', get_string('pluginname', 'auth_outage'))); // Add settings page toconfigure defaults. diff --git a/views/warningbar.php b/views/warningbar.php index 45c0a23..d62390e 100644 --- a/views/warningbar.php +++ b/views/warningbar.php @@ -23,6 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use auth_outage\outagelib; + if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. } @@ -31,7 +33,7 @@ global $OUTPUT; $infolink = new moodle_url('/auth/outage/info.php', ['id' => $outage->id]); -echo html_writer::tag('style', get_config('auth_outage', 'css')); +echo html_writer::tag('style', outagelib::get_config()->css); ?>
From 267048534102cbcfdce2faee148c385c237551d3 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 15:55:57 +1000 Subject: [PATCH 59/72] Issue #27 - Changed deltas for CLI from minutes to seconds. --- classes/cli/create.php | 10 +++++----- lang/en/auth_outage.php | 6 +++--- tests/cli/create_test.php | 20 ++++++++++---------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/classes/cli/create.php b/classes/cli/create.php index 0275c07..58d88b9 100644 --- a/classes/cli/create.php +++ b/classes/cli/create.php @@ -139,11 +139,11 @@ class create extends clibase { $this->becomeadmin(); // Create the outage. - $start = $this->time + ($options['start'] * 60); + $start = $this->time + $options['start']; $outage = new outage([ - 'warntime' => $start - ($options['warn'] * 60), + 'warntime' => $start - $options['warn'], 'starttime' => $start, - 'stoptime' => $start + ($options['duration'] * 60), + 'stoptime' => $start + $options['duration'], 'title' => $options['title'], 'description' => $options['description'], ]); @@ -167,8 +167,8 @@ class create extends clibase { $outage = outagedb::get_by_id((int)$id); $this->set_defaults([ - 'warn' => (int)($outage->get_warning_duration() / 60), - 'duration' => (int)($outage->get_duration() / 60), + 'warn' => $outage->get_warning_duration(), + 'duration' => $outage->get_duration(), 'title' => $outage->title, 'description' => $outage->description, ]); diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index 4e08833..c339171 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -28,12 +28,12 @@ $string['clicreatehelp'] = 'Creates a new outage.'; $string['clicreateparamblock'] = 'blocks until outage starts.'; $string['clicreateparamclone'] = 'clone another outage except for the start time.'; $string['clicreateparamdescription'] = 'the description of the outage.'; -$string['clicreateparamduration'] = 'how many minutes should the outage last.'; +$string['clicreateparamduration'] = 'how many seconds should the outage last.'; $string['clicreateparamhelp'] = 'shows parameters help.'; $string['clicreateparamonlyid'] = 'only outputs the new outage id, useful for scripts.'; -$string['clicreateparamstart'] = 'in how many minutes should this outage start. Required.'; +$string['clicreateparamstart'] = 'in how many seconds should this outage start. Required.'; $string['clicreateparamtitle'] = 'the title of the outage.'; -$string['clicreateparamwarn'] = 'how many minutes before it starts to display a warning.'; +$string['clicreateparamwarn'] = 'how many seconds before it starts to display a warning.'; $string['clifinishhelp'] = 'Finishes an ongoing outage.'; $string['clifinishnotongoing'] = 'Outage is not ongoing.'; $string['clifinishparamhelp'] = 'shows parameters help.'; diff --git a/tests/cli/create_test.php b/tests/cli/create_test.php index 9ba720a..0701e2e 100644 --- a/tests/cli/create_test.php +++ b/tests/cli/create_test.php @@ -147,8 +147,8 @@ class create_test extends cli_testcase { $id = (int)$id; $outage = outagedb::get_by_id($id); self::assertSame($now, $outage->starttime); - self::assertSame(10 * 60, $outage->get_warning_duration()); - self::assertSame(30 * 60, $outage->get_duration()); + self::assertSame(10, $outage->get_warning_duration()); + self::assertSame(30, $outage->get_duration()); self::assertNull($outage->finished); self::assertSame('A Title', $outage->title); self::assertSame('A Description', $outage->description); @@ -174,8 +174,8 @@ class create_test extends cli_testcase { // Check creted outage. $outage = outagedb::get_by_id($id); self::assertSame($now, $outage->starttime); - self::assertSame($outage->starttime - (10 * 60), $outage->warntime); - self::assertSame($outage->starttime + (30 * 60), $outage->stoptime); + self::assertSame($outage->starttime - 10, $outage->warntime); + self::assertSame($outage->starttime + 30, $outage->stoptime); self::assertNull($outage->finished); self::assertSame('Title', $outage->title); self::assertSame('Description', $outage->description); @@ -202,9 +202,9 @@ class create_test extends cli_testcase { list(, $id) = explode(':', $text); $id = (int)$id; $outage = outagedb::get_by_id($id); - self::assertSame($now + (50 * 60), $outage->starttime, 'Wrong starttime.'); - self::assertSame($outage->starttime - (100 * 60), $outage->warntime, 'Wrong warntime.'); - self::assertSame($outage->starttime + (300 * 60), $outage->stoptime, 'Wrong stoptime.'); + self::assertSame($now + 50, $outage->starttime, 'Wrong starttime.'); + self::assertSame($outage->starttime - 100, $outage->warntime, 'Wrong warntime.'); + self::assertSame($outage->starttime + 300, $outage->stoptime, 'Wrong stoptime.'); self::assertNull($outage->finished); self::assertSame('Default Title', $outage->title); self::assertSame('Default Description', $outage->description); @@ -233,7 +233,7 @@ class create_test extends cli_testcase { $id = trim($this->execute($cli)); // Check cloned data. $cloned = outagedb::get_by_id((int)$id); - self::assertSame($now + (60 * 60), $cloned->starttime); + self::assertSame($now + 60, $cloned->starttime); self::assertSame($original->get_warning_duration(), $cloned->get_warning_duration()); self::assertSame($original->get_duration(), $cloned->get_duration()); self::assertSame($original->title, $cloned->title); @@ -254,9 +254,9 @@ class create_test extends cli_testcase { // Not an extensive test in the blocking API, cliwaitforit tests should cover them deeper. $this->set_parameters([ '--block', - '--warn=10', + '--warn=60', '--start=0', - '--duration=30', + '--duration=600', '--title=Title', '--description=Description', ]); From 54b72de472bd9d1dfd86b1c3106e28e5e8b6be02 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 17:06:16 +1000 Subject: [PATCH 60/72] Issue #39 - Minor UI changes. --- classes/cli/create.php | 2 +- classes/forms/outage/edit.php | 2 +- classes/models/outage.php | 17 +++- classes/outagedb.php | 2 +- classes/tables/manage/history.php | 80 +++++++++++++++++++ .../{manage.php => manage/managebase.php} | 57 ++----------- classes/tables/manage/planned.php | 67 ++++++++++++++++ lang/en/auth_outage.php | 5 +- renderer.php | 23 +++--- tests/cli/create_test.php | 4 +- 10 files changed, 188 insertions(+), 71 deletions(-) create mode 100644 classes/tables/manage/history.php rename classes/tables/{manage.php => manage/managebase.php} (67%) create mode 100644 classes/tables/manage/planned.php diff --git a/classes/cli/create.php b/classes/cli/create.php index 58d88b9..3c245aa 100644 --- a/classes/cli/create.php +++ b/classes/cli/create.php @@ -168,7 +168,7 @@ class create extends clibase { $outage = outagedb::get_by_id((int)$id); $this->set_defaults([ 'warn' => $outage->get_warning_duration(), - 'duration' => $outage->get_duration(), + 'duration' => $outage->get_duration_planned(), 'title' => $outage->title, 'description' => $outage->description, ]); diff --git a/classes/forms/outage/edit.php b/classes/forms/outage/edit.php index f4986a8..e724a73 100644 --- a/classes/forms/outage/edit.php +++ b/classes/forms/outage/edit.php @@ -134,7 +134,7 @@ class edit extends \moodleform { $this->_form->setDefaults([ 'id' => $outage->id, 'starttime' => $outage->starttime, - 'outageduration' => $outage->get_duration(), + 'outageduration' => $outage->get_duration_planned(), 'warningduration' => $outage->get_warning_duration(), 'title' => $outage->title, 'description' => ['text' => $outage->description, 'format' => '1'] diff --git a/classes/models/outage.php b/classes/models/outage.php index 6f51efa..21f5311 100644 --- a/classes/models/outage.php +++ b/classes/models/outage.php @@ -207,6 +207,17 @@ class outage { return $this->replace_placeholders($this->title); } + /** + * Gets the duration of the outage (start to actual finish, warning not included). + * @return int|null Duration in seconds or null if not finished. + */ + public function get_duration_actual() { + if (is_null($this->finished)) { + return null; + } + return $this->finished - $this->starttime; + } + /** * Returns the input string with all placeholders replaced. * @param $str string Input string. @@ -222,17 +233,17 @@ class outage { [ userdate($this->starttime, get_string('datetimeformat', 'auth_outage')), userdate($this->stoptime, get_string('datetimeformat', 'auth_outage')), - format_time($this->get_duration()), + format_time($this->get_duration_planned()), ], $str ); } /** - * Gets the duration of the outage (start to stop, warning not included). + * Gets the planned duration of the outage (start to planned stop, warning not included). * @return int Duration in seconds. */ - public function get_duration() { + public function get_duration_planned() { return $this->stoptime - $this->starttime; } diff --git a/classes/outagedb.php b/classes/outagedb.php index f15e505..4fa6d85 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -333,7 +333,7 @@ class outagedb { 'eventtype' => 'auth_outage', 'timestart' => $outage->starttime, 'visible' => true, - 'timeduration' => $outage->get_duration(), + 'timeduration' => $outage->get_duration_planned(), ]; } diff --git a/classes/tables/manage/history.php b/classes/tables/manage/history.php new file mode 100644 index 0000000..a13a0cc --- /dev/null +++ b/classes/tables/manage/history.php @@ -0,0 +1,80 @@ +. + +namespace auth_outage\tables\manage; + +use auth_outage\models\outage; +use html_writer; +use moodle_url; + +require_once($CFG->libdir . '/tablelib.php'); + +/** + * Manage outages table. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class history extends managebase { + /** + * Constructor + */ + public function __construct() { + parent::__construct(); + + $this->define_columns(['warning', 'starts', 'durationplanned', 'durationactual', 'title', 'actions']); + + $this->define_headers([ + get_string('tableheaderwarnbefore', 'auth_outage'), + get_string('tableheaderstarttime', 'auth_outage'), + get_string('tableheaderdurationplanned', 'auth_outage'), + get_string('tableheaderdurationactual', 'auth_outage'), + get_string('tableheadertitle', 'auth_outage'), + get_string('actions'), + ] + ); + + $this->setup(); + } + + /** + * Sets the data of the table. + * @param outage[] $outages An array with outage objects. + */ + public function set_data(array $outages) { + foreach ($outages as $outage) { + $title = html_writer::link( + new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), + $outage->get_title(), + ['title' => get_string('edit')] + ); + + $finished = $outage->get_duration_actual(); + $finished = is_null($finished) ? '-' : format_time($finished); + + $this->add_data([ + format_time($outage->get_warning_duration()), + userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), + format_time($outage->get_duration_planned()), + $finished, + $title, + $this->set_data_buttons($outage, false), + ]); + } + } +} diff --git a/classes/tables/manage.php b/classes/tables/manage/managebase.php similarity index 67% rename from classes/tables/manage.php rename to classes/tables/manage/managebase.php index 6126cb0..5101d93 100644 --- a/classes/tables/manage.php +++ b/classes/tables/manage/managebase.php @@ -14,7 +14,7 @@ // You should have received a copy of the GNU General Public License // along with Moodle. If not, see . -namespace auth_outage\tables; +namespace auth_outage\tables\manage; use auth_outage\models\outage; use flexible_table; @@ -24,14 +24,14 @@ use moodle_url; require_once($CFG->libdir . '/tablelib.php'); /** - * Manage outages table. + * Manage outages table base. * * @package auth_outage * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -class manage extends flexible_table { +class managebase extends flexible_table { private static $autoid = 0; /** @@ -44,55 +44,8 @@ class manage extends flexible_table { $id = (is_null($id) ? self::$autoid++ : $id); parent::__construct('auth_outage_manage_' . $id); - $this->define_columns(['starttime', 'stopsafter', 'warnbefore', 'finished', 'title', '']); - - $this->define_headers([ - get_string('tableheaderwarnbefore', 'auth_outage'), - get_string('tableheaderstarttime', 'auth_outage'), - get_string('tableheaderstopsafter', 'auth_outage'), - get_string('tableheaderfinishedat', 'auth_outage'), - get_string('tableheadertitle', 'auth_outage'), - get_string('actions'), - ] - ); - $this->define_baseurl($PAGE->url); $this->set_attribute('class', 'generaltable admintable'); - $this->setup(); - } - - /** - * Sets the data of the table. - * @param outage[] $outages An array with outage objects. - * @param bool $editdelete If it should display the edit and delete button. - */ - public function set_data(array $outages, $editdelete) { - if (!is_bool($editdelete)) { - throw new \InvalidArgumentException('$editdelete must be a bool.'); - } - - foreach ($outages as $outage) { - $title = $outage->get_title(); - if ($editdelete) { - $title = html_writer::link( - new moodle_url('/auth/outage/edit.php', ['id' => $outage->id]), - $title, - ['title' => get_string('edit')] - ); - } - - $finished = $outage->finished; - $finished = is_null($finished) ? '-' : userdate($finished, get_string('datetimeformat', 'auth_outage')); - - $this->add_data([ - format_time($outage->get_warning_duration()), - userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), - format_time($outage->get_duration()), - $finished, - $title, - $this->set_data_buttons($outage, $editdelete), - ]); - } } /** @@ -101,7 +54,7 @@ class manage extends flexible_table { * @param bool $editdelete If it should display the edit and delete button. * @return string The HTML code of the action buttons. */ - private function set_data_buttons(outage $outage, $editdelete) { + protected function set_data_buttons(outage $outage, $editdelete) { global $OUTPUT; $buttons = ''; @@ -171,6 +124,6 @@ class manage extends flexible_table { ); } - return $buttons; + return '' . $buttons . ''; } } diff --git a/classes/tables/manage/planned.php b/classes/tables/manage/planned.php new file mode 100644 index 0000000..9128c5e --- /dev/null +++ b/classes/tables/manage/planned.php @@ -0,0 +1,67 @@ +. + +namespace auth_outage\tables\manage; + +use auth_outage\models\outage; + +require_once($CFG->libdir . '/tablelib.php'); + +/** + * Manage outages table. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class planned extends managebase { + /** + * Constructor + */ + public function __construct() { + parent::__construct(); + + $this->define_columns(['warning', 'starts', 'duration', 'title', 'actions']); + + $this->define_headers([ + get_string('tableheaderwarnbefore', 'auth_outage'), + get_string('tableheaderstarttime', 'auth_outage'), + get_string('tableheaderduration', 'auth_outage'), + get_string('tableheadertitle', 'auth_outage'), + get_string('actions'), + ] + ); + + $this->setup(); + } + + /** + * Sets the data of the table. + * @param outage[] $outages An array with outage objects. + */ + public function set_data(array $outages) { + foreach ($outages as $outage) { + $this->add_data([ + format_time($outage->get_warning_duration()), + userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')), + format_time($outage->get_duration_planned()), + $outage->get_title(), + $this->set_data_buttons($outage, true), + ]); + } + } +} diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index c339171..e937d52 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -98,9 +98,10 @@ $string['outageslistpast'] = 'Outage history'; $string['pluginname'] = 'Outage manager'; $string['starttime'] = 'Start date and time'; $string['starttime_help'] = 'At which date and time the outage starts, preventing general access to the system.'; -$string['tableheaderfinishedat'] = 'Finished at'; +$string['tableheaderduration'] = 'Duration'; +$string['tableheaderdurationplanned'] = 'Planned Duration'; +$string['tableheaderdurationactual'] = 'Actual Duration'; $string['tableheaderstarttime'] = 'Starts on'; -$string['tableheaderstopsafter'] = 'Stops after'; $string['tableheaderwarnbefore'] = 'Warns before'; $string['tableheadertitle'] = 'Title'; $string['textplaceholdershint'] = 'You can use {{start}}, {{stop}} and {{duration}} as placeholders on the title and description.'; diff --git a/renderer.php b/renderer.php index f09eb2b..1978587 100644 --- a/renderer.php +++ b/renderer.php @@ -15,6 +15,7 @@ // along with Moodle. If not, see . use auth_outage\models\outage; +use auth_outage\tables\manage\planned; if (!defined('MOODLE_INTERNAL')) { die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. @@ -87,8 +88,8 @@ class auth_outage_renderer extends plugin_renderer_base { if (empty($future)) { echo html_writer::tag('p', html_writer::tag('small', get_string('notfound', 'auth_outage'))); } else { - $table = new \auth_outage\tables\manage(); - $table->set_data($future, true); + $table = new planned(); + $table->set_data($future); $table->finish_output(); } @@ -96,8 +97,8 @@ class auth_outage_renderer extends plugin_renderer_base { if (empty($past)) { echo html_writer::tag('p', html_writer::tag('small', get_string('notfound', 'auth_outage'))); } else { - $table = new \auth_outage\tables\manage(); - $table->set_data($past, false); + $table = new \auth_outage\tables\manage\history(); + $table->set_data($past); $table->finish_output(); } } @@ -146,7 +147,11 @@ class auth_outage_renderer extends plugin_renderer_base { $linkdelete = html_writer::link($url, $img, ['title' => get_string('delete')]); $finished = $outage->finished; - $finished = is_null($finished) ? '-' : userdate($finished, get_string('datetimeformat', 'auth_outage')); + if (is_null($finished)) { + $finished = get_string('na', 'auth_outage'); + } else { + $finished = userdate($finished, get_string('datetimeformat', 'auth_outage')); + } return html_writer::div( html_writer::tag('blockquote', @@ -161,11 +166,11 @@ class auth_outage_renderer extends plugin_renderer_base { . userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')) ) . html_writer::div( - html_writer::tag('b', get_string('tableheaderstopsafter', 'auth_outage') . ': ') - . format_time($outage->get_duration()) + html_writer::tag('b', get_string('tableheaderdurationplanned', 'auth_outage') . ': ') + . format_time($outage->get_duration_planned()) ) . html_writer::div( - html_writer::tag('b', get_string('tableheaderfinishedat', 'auth_outage') . ': ') + html_writer::tag('b', get_string('tableheaderdurationactual', 'auth_outage') . ': ') . $finished ) . html_writer::div( @@ -201,7 +206,7 @@ class auth_outage_renderer extends plugin_renderer_base { 'startofwarning' => -$outage->get_warning_duration(), '15secondsbefore' => -15, 'start' => 0, - 'endofoutage' => $outage->get_duration(), + 'endofoutage' => $outage->get_duration_planned(), ] as $title => $delta) { $adminlinks[] = html_writer::link( new moodle_url( diff --git a/tests/cli/create_test.php b/tests/cli/create_test.php index 0701e2e..4fb0abc 100644 --- a/tests/cli/create_test.php +++ b/tests/cli/create_test.php @@ -148,7 +148,7 @@ class create_test extends cli_testcase { $outage = outagedb::get_by_id($id); self::assertSame($now, $outage->starttime); self::assertSame(10, $outage->get_warning_duration()); - self::assertSame(30, $outage->get_duration()); + self::assertSame(30, $outage->get_duration_planned()); self::assertNull($outage->finished); self::assertSame('A Title', $outage->title); self::assertSame('A Description', $outage->description); @@ -235,7 +235,7 @@ class create_test extends cli_testcase { $cloned = outagedb::get_by_id((int)$id); self::assertSame($now + 60, $cloned->starttime); self::assertSame($original->get_warning_duration(), $cloned->get_warning_duration()); - self::assertSame($original->get_duration(), $cloned->get_duration()); + self::assertSame($original->get_duration_planned(), $cloned->get_duration_planned()); self::assertSame($original->title, $cloned->title); self::assertSame($original->description, $cloned->description); } From 6a81fd6304d4260652d80cab4aef12de2adceb1b Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 18:17:44 +1000 Subject: [PATCH 61/72] Issue #37 - Ensure exceptions in the inject code, executed in virtually all pages, does not put the whole site down. --- classes/outagelib.php | 45 +++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/classes/outagelib.php b/classes/outagelib.php index b9f3625..77b6854 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -17,6 +17,7 @@ namespace auth_outage; use auth_outage_renderer; +use Exception; use moodle_url; if (!defined('MOODLE_INTERNAL')) { @@ -66,27 +67,33 @@ class outagelib { } self::$initialized = true; - // Check for a previewing outage, then for an active outage. - $previewid = optional_param('auth_outage_preview', null, PARAM_INT); - $time = time(); - if (is_null($previewid)) { - if (!$active = outagedb::get_active()) { - return; + // Ensure we do not kill the whole website in case of an error. + try { + // Check for a previewing outage, then for an active outage. + $previewid = optional_param('auth_outage_preview', null, PARAM_INT); + $time = time(); + if (is_null($previewid)) { + if (!$active = outagedb::get_active()) { + return; + } + } else { + if (!$active = outagedb::get_by_id($previewid)) { + return; + } + // Delta is in seconds, setting the time our warning bar will consider relative to the outage start time. + $time = $active->starttime + optional_param('auth_outage_delta', 0, PARAM_INT); + if (!$active->is_active($time)) { + return; + } } - } else { - if (!$active = outagedb::get_by_id($previewid)) { - return; - } - // Delta is in seconds, setting the time our warning bar will consider relative to the outage start time. - $time = $active->starttime + optional_param('auth_outage_delta', 0, PARAM_INT); - if (!$active->is_active($time)) { - return; - } - } - // There is a previewing or active outage. - $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active, $time) - . $CFG->additionalhtmltopofbody; + // There is a previewing or active outage. + $CFG->additionalhtmltopofbody = self::get_renderer()->renderoutagebar($active, $time) + . $CFG->additionalhtmltopofbody; + } catch (Exception $e) { + debugging('Exception occured while injecting our code: ' . $e->getMessage()); + debugging($e->getTraceAsString(), DEBUG_DEVELOPER); + } } public static function get_config() { From 2959719c58dca4e07f83619b366fb44bda6425df Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 18:31:13 +1000 Subject: [PATCH 62/72] Minor changes in outagelib, added PhpDocs and simplified MOODLE_INTERNAL check. --- classes/outagelib.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/classes/outagelib.php b/classes/outagelib.php index 77b6854..4ee3275 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -20,9 +20,7 @@ use auth_outage_renderer; use Exception; use moodle_url; -if (!defined('MOODLE_INTERNAL')) { - die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. -} +defined('MOODLE_INTERNAL') || die(); /** * Outage related functions. @@ -96,10 +94,19 @@ class outagelib { } } + /** + * Creates a configuration object ensuring all parameters are set, + * loading defaults even if the plugin is not configured. + * @return object Configuration object with all parameters set. + */ public static function get_config() { return (object)array_merge(self::get_config_defaults(), (array)get_config('auth_outage')); } + /** + * Creates the default configurations. If the plugin is not configured we should use those defaults. + * @return array Default configuration. + */ public static function get_config_defaults() { global $CFG; From edbbc2dd83af5749f3e33af48a8d57adbf5f454f Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Mon, 19 Sep 2016 20:05:21 +1000 Subject: [PATCH 63/72] Added full phpunit coverage for classes in: cli and event --- classes/cli/clibase.php | 7 +- classes/cli/cliexception.php | 9 +++ classes/cli/create.php | 2 - classes/cli/finish.php | 2 - classes/cli/waitforit.php | 2 - classes/event/outage_created.php | 7 +- classes/event/outage_deleted.php | 2 - classes/event/outage_updated.php | 2 - classes/outagedb.php | 6 +- tests/cli/cli_test.php | 70 +++++++++++++++++++ tests/cli/create_test.php | 19 +---- tests/cli/finish_test.php | 60 ++++++++++++++++ tests/cli/waitforit_test.php | 1 + tests/events_test.php | 115 +++++++++++++++++++++++++++++++ tests/outage_test.php | 1 + 15 files changed, 265 insertions(+), 40 deletions(-) create mode 100644 tests/cli/cli_test.php create mode 100644 tests/events_test.php diff --git a/classes/cli/clibase.php b/classes/cli/clibase.php index d0eb62c..2cf2b52 100644 --- a/classes/cli/clibase.php +++ b/classes/cli/clibase.php @@ -19,10 +19,6 @@ namespace auth_outage\cli; use core\session\manager; use InvalidArgumentException; -defined('MOODLE_INTERNAL') || die(); - -require_once($CFG->libdir . '/clilib.php'); - /** * Outage CLI base class. * @@ -48,6 +44,9 @@ abstract class clibase { * @throws cliexception */ public function __construct(array $options = null) { + global $CFG; + require_once($CFG->libdir . '/clilib.php'); + $this->becomeadmin(); if (is_null($options)) { diff --git a/classes/cli/cliexception.php b/classes/cli/cliexception.php index 2ae4b1f..79ae8f4 100644 --- a/classes/cli/cliexception.php +++ b/classes/cli/cliexception.php @@ -27,4 +27,13 @@ use Exception; * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class cliexception extends Exception { + /** + * cliexception constructor. + * @param string $message An explanation of the exception. + * @param int $code Exit code to be used. + * @param Exception|null $previous Another exception as reference. + */ + public function __construct($message, $code = 1, Exception $previous = null) { + parent::__construct('*ERROR* ' . $message, $code, $previous = null); + } } diff --git a/classes/cli/create.php b/classes/cli/create.php index 3c245aa..35f7850 100644 --- a/classes/cli/create.php +++ b/classes/cli/create.php @@ -19,8 +19,6 @@ namespace auth_outage\cli; use auth_outage\models\outage; use auth_outage\outagedb; -defined('MOODLE_INTERNAL') || die(); - /** * Outage CLI to create outage. * diff --git a/classes/cli/finish.php b/classes/cli/finish.php index a1d56e3..df498b5 100644 --- a/classes/cli/finish.php +++ b/classes/cli/finish.php @@ -19,8 +19,6 @@ namespace auth_outage\cli; use auth_outage\models\outage; use auth_outage\outagedb; -defined('MOODLE_INTERNAL') || die(); - /** * Outage CLI to finish an outage. * diff --git a/classes/cli/waitforit.php b/classes/cli/waitforit.php index 8ec4ed7..9b0d060 100644 --- a/classes/cli/waitforit.php +++ b/classes/cli/waitforit.php @@ -19,8 +19,6 @@ namespace auth_outage\cli; use auth_outage\models\outage; use auth_outage\outagedb; -defined('MOODLE_INTERNAL') || die(); - /** * Outage CLI to wait for an outage to start. * diff --git a/classes/event/outage_created.php b/classes/event/outage_created.php index 0339818..7cc3cce 100644 --- a/classes/event/outage_created.php +++ b/classes/event/outage_created.php @@ -17,8 +17,7 @@ namespace auth_outage\event; use core\event\base; - -defined('MOODLE_INTERNAL') || die(); +use moodle_url; /** * The auth_outage outage created class. @@ -51,9 +50,9 @@ class outage_created extends base { /** * Returns relevant URL, override in subclasses. - * @return \moodle_url + * @return moodle_url */ public function get_url() { - return new \moodle_url('/auth/outage/list.php#auth_outage_id_' . $this->other['id']); + return new moodle_url('/auth/outage/list.php#auth_outage_id_' . $this->other['id']); } } diff --git a/classes/event/outage_deleted.php b/classes/event/outage_deleted.php index e1844a7..4dc3291 100644 --- a/classes/event/outage_deleted.php +++ b/classes/event/outage_deleted.php @@ -18,8 +18,6 @@ namespace auth_outage\event; use core\event\base; -defined('MOODLE_INTERNAL') || die(); - /** * The auth_outage outage deleted class. * diff --git a/classes/event/outage_updated.php b/classes/event/outage_updated.php index 797b680..0710bfe 100644 --- a/classes/event/outage_updated.php +++ b/classes/event/outage_updated.php @@ -18,8 +18,6 @@ namespace auth_outage\event; use core\event\base; -defined('MOODLE_INTERNAL') || die(); - /** * The auth_outage outage updated class. * diff --git a/classes/outagedb.php b/classes/outagedb.php index 4fa6d85..7d43f90 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -16,9 +16,7 @@ namespace auth_outage; -if (!defined('MOODLE_INTERNAL')) { - die('Direct access to this script is forbidden.'); // It must be included from a Moodle page. -} +defined('MOODLE_INTERNAL') || die(); require_once($CFG->dirroot . '/calendar/lib.php'); @@ -259,7 +257,7 @@ class outagedb { if (is_null($time)) { $time = time(); } - if (!is_int($time)) { + if (!is_int($time) && ($time <= 0)) { throw new InvalidArgumentException('$time must be an int or null.'); } diff --git a/tests/cli/cli_test.php b/tests/cli/cli_test.php new file mode 100644 index 0000000..bc6f485 --- /dev/null +++ b/tests/cli/cli_test.php @@ -0,0 +1,70 @@ +. + +use auth_outage\cli\cliexception; +use auth_outage\cli\create; + +defined('MOODLE_INTERNAL') || die(); +require_once('cli_testcase.php'); + +/** + * Tests performed on CLI base and exception class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\cli\clibase + * @covers \auth_outage\cli\cliexception + * @SuppressWarnings("public") + */ +class cli_test extends cli_testcase { + public function test_invalidargumentparam() { + $this->set_parameters(['--aninvalidparameter']); + $this->setExpectedException(cliexception::class); + new create(); + } + + public function test_invalidargumentgiven() { + $this->setExpectedException(cliexception::class); + new create(['anotherinvalidparameter']); + } + + public function test_setreferencetime() { + $cli = new create(['start' => 0]); + $cli->set_referencetime(1); + $cli->set_referencetime(60 * 60 * 24 * 7); + } + + public function test_setreferencetime_invalid() { + $cli = new create(['start' => 0]); + $this->setExpectedException(InvalidArgumentException::class); + $cli->set_referencetime(-1); + } + + public function test_help() { + $this->set_parameters(['-h']); + $cli = new create(); + $output = $this->execute($cli); + self::assertContains('-h', $output); + self::assertContains('--help', $output); + } + + public function test_exception() { + self::setExpectedException(cliexception::class, '*ERROR* An CLI exception.', 5); + throw new cliexception('An CLI exception.', 5); + } +} diff --git a/tests/cli/create_test.php b/tests/cli/create_test.php index 4fb0abc..e764c1d 100644 --- a/tests/cli/create_test.php +++ b/tests/cli/create_test.php @@ -29,7 +29,7 @@ require_once('cli_testcase.php'); * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - * @SuppressWarnings("public") + * @SuppressWarnings("public") Allow this test to have as many tests as necessary. */ class create_test extends cli_testcase { public function test_noarguments() { @@ -38,17 +38,6 @@ class create_test extends cli_testcase { $this->execute($cli); } - public function test_invalidargumentparam() { - $this->set_parameters(['--aninvalidparameter']); - $this->setExpectedException(cliexception::class); - new create(); - } - - public function test_invalidargumentgiven() { - $this->setExpectedException(cliexception::class); - new create(['anotherinvalidparameter']); - } - public function test_invalidparam_notanumber() { $cli = new create(['start' => 'some day']); $cli->set_defaults([ @@ -101,12 +90,6 @@ class create_test extends cli_testcase { $this->execute($cli); } - public function test_setreferencetime_invalid() { - $cli = new create(['start' => 0]); - $this->setExpectedException(InvalidArgumentException::class); - $cli->set_referencetime(-1); - } - public function test_help() { $this->set_parameters(['--help']); $cli = new create(); diff --git a/tests/cli/finish_test.php b/tests/cli/finish_test.php index d2f9095..53a9d5e 100644 --- a/tests/cli/finish_test.php +++ b/tests/cli/finish_test.php @@ -16,6 +16,8 @@ use auth_outage\cli\cliexception; use auth_outage\cli\finish; +use auth_outage\models\outage; +use auth_outage\outagedb; defined('MOODLE_INTERNAL') || die(); require_once('cli_testcase.php'); @@ -27,6 +29,7 @@ require_once('cli_testcase.php'); * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\cli\finish */ class finish_test extends cli_testcase { public function test_constructor() { @@ -61,4 +64,61 @@ class finish_test extends cli_testcase { $this->setExpectedException(cliexception::class); $this->execute($cli); } + + public function test_endedoutage() { + $this->setAdminUser(); + $now = time(); + $id = outagedb::save(new outage([ + 'warntime' => $now - 200, + 'starttime' => $now - 100, + 'stoptime' => $now - 50, + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-id=' . $id]); + $cli = new finish(); + $cli->set_referencetime($now); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_finish() { + $this->setAdminUser(); + $now = time(); + $id = outagedb::save(new outage([ + 'warntime' => $now - 200, + 'starttime' => $now - 100, + 'stoptime' => $now + 100, + 'title' => 'Title', + 'description' => 'Description', + ])); + $this->set_parameters(['-id=' . $id]); + $cli = new finish(); + $cli->set_referencetime($now); + $this->execute($cli); + } + + public function test_activenotfound() { + $this->setAdminUser(); + $this->set_parameters(['-a']); + $cli = new finish(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_invalidid() { + $this->setAdminUser(); + $this->set_parameters(['-id=theid']); + $cli = new finish(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } + + public function test_idnotfound() { + $this->setAdminUser(); + $this->set_parameters(['-id=99999']); + $cli = new finish(); + $this->setExpectedException(cliexception::class); + $this->execute($cli); + } } diff --git a/tests/cli/waitforit_test.php b/tests/cli/waitforit_test.php index 5be7881..cb7f7b3 100644 --- a/tests/cli/waitforit_test.php +++ b/tests/cli/waitforit_test.php @@ -29,6 +29,7 @@ require_once('cli_testcase.php'); * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\cli\waitforit * @SuppressWarnings("public") */ class waitforit_test extends cli_testcase { diff --git a/tests/events_test.php b/tests/events_test.php new file mode 100644 index 0000000..6563d06 --- /dev/null +++ b/tests/events_test.php @@ -0,0 +1,115 @@ +. + +use auth_outage\models\outage; +use auth_outage\outagedb; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests performed on outage class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\event\outage_created + * @covers \auth_outage\event\outage_updated + * @covers \auth_outage\event\outage_deleted + */ +class events_test extends advanced_testcase { + public function test_save() { + global $DB; + $this->setAdminUser(); + $this->resetAfterTest(false); + + // Save new outage. + $now = time(); + $id = outagedb::save(new outage([ + 'warntime' => $now - 60, + 'starttime' => 60, + 'stoptime' => 120, + 'title' => 'Title', + 'description' => 'Description', + ])); + + // Check existance. + $event = $DB->get_record_select( + 'event', + "(eventtype = 'auth_outage' AND instance = :outageid)", + ['outageid' => $id], + 'id', + IGNORE_MISSING + ); + self::assertNotFalse($event); + + // Another test will use it. + return [$id, $event->id]; + } + + /** + * @param array $ids + * @depends test_save + */ + public function test_update($ids) { + global $DB; + + $this->setAdminUser(); + $this->resetAfterTest(false); + + list($idoutage, $idevent) = $ids; + $outage = outagedb::get_by_id($idoutage); + $outage->starttime += 10; + outagedb::save($outage); + + // Should still exist. + $event = $DB->get_record_select( + 'event', + "(eventtype = 'auth_outage' AND instance = :idoutage)", + ['idoutage' => $idoutage], + 'id', + IGNORE_MISSING + ); + self::assertNotFalse($event); + self::assertSame($idevent, $event->id); + + return $ids; + } + + /** + * @param array $ids + * @depends test_update + */ + public function test_delete($ids) { + global $DB; + + $this->setAdminUser(); + $this->resetAfterTest(true); + list($idoutage, $idevent) = $ids; + + outagedb::delete($idoutage); + + // Should not exist. + $event = $DB->get_record_select( + 'event', + "(eventtype = 'auth_outage' AND instance = :idoutage) OR (id = :idevent)", + ['idoutage' => $idoutage, 'idevent' => $idevent], + 'id', + IGNORE_MISSING + ); + self::assertFalse($event); + } +} diff --git a/tests/outage_test.php b/tests/outage_test.php index d0c88d5..aeaec26 100644 --- a/tests/outage_test.php +++ b/tests/outage_test.php @@ -25,6 +25,7 @@ defined('MOODLE_INTERNAL') || die(); * @author Daniel Thee Roperto * @copyright Catalyst IT * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\models\outage */ class outage_test extends basic_testcase { public function test_constructor() { From 6824127624eae5816bdd8e736be94c807edd02b4 Mon Sep 17 00:00:00 2001 From: Daniel Thee Roperto Date: Tue, 20 Sep 2016 14:54:24 +1000 Subject: [PATCH 64/72] Issue #23 - Generate a static template page every time an outage is created, updated or deleted. --- classes/outagedb.php | 37 +++++++++++++++++ classes/outagelib.php | 79 ++++++++++++++++++++++++++++++++++++ info.php | 21 ++++++---- lang/en/auth_outage.php | 1 + renderer.php | 37 +++++++++++++---- tests/cli/outagelib_test.php | 74 +++++++++++++++++++++++++++++++++ tests/renderer_test.php | 54 ++++++++++++++++++++++++ views/infopage.php | 2 +- views/infopagestatic.php | 64 +++++++++++++++++++++++++++++ views/warningbar.php | 34 +++++++++++----- 10 files changed, 375 insertions(+), 28 deletions(-) create mode 100644 tests/cli/outagelib_test.php create mode 100644 tests/renderer_test.php create mode 100644 views/infopagestatic.php diff --git a/classes/outagedb.php b/classes/outagedb.php index 7d43f90..23fd9cc 100644 --- a/classes/outagedb.php +++ b/classes/outagedb.php @@ -120,6 +120,9 @@ class outagedb { self::calendar_update($outage); } + // Trigger static page update. + outagelib::updatestaticinfopagefile(); + // All done, return the id. return $outage->id; } @@ -149,6 +152,9 @@ class outagedb { // Delete it and remove from calendar. $DB->delete_records('auth_outage', ['id' => $id]); self::calendar_delete($id); + + // Trigger static page update. + outagelib::updatestaticinfopagefile(); } /** @@ -276,6 +282,37 @@ class outagedb { self::save($outage); } + /** + * Gets the next outage which has not started yet. + * @param null $time Timestamp reference for current time. + * @return outage|null The outage or null if not found. + */ + public static function get_next_starting($time = null) { + global $DB; + + if ($time === null) { + $time = time(); + } + if (!is_int($time) || ($time <= 0)) { + throw new InvalidArgumentException('$time must be null or an positive int.'); + } + + $select = ':datetime <= starttime'; // End condition. + $data = $DB->get_records_select( + 'auth_outage', + $select, + ['datetime' => $time], + 'starttime ASC', + '*', + 0, + 1 + ); + + // Not using $DB->get_record_select instead because there is no 'limit' parameter. + // Allowing multiple records still raises an internal error. + return (count($data) == 0) ? null : new outage(array_shift($data)); + } + /** * Create an event on the calendar for this outage. * @param outage $outage Outage to be added to the calendar. diff --git a/classes/outagelib.php b/classes/outagelib.php index 4ee3275..5a84b44 100644 --- a/classes/outagelib.php +++ b/classes/outagelib.php @@ -16,8 +16,10 @@ namespace auth_outage; +use auth_outage\models\outage; use auth_outage_renderer; use Exception; +use InvalidArgumentException; use moodle_url; defined('MOODLE_INTERNAL') || die(); @@ -118,4 +120,81 @@ class outagelib { 'css' => file_get_contents($CFG->dirroot . '/auth/outage/views/warningbar.css'), ]; } + + /** + * Saves a static info page for the given outage. + * @param outage $outage Outage to generate the info page. + * @param string $file File to save the static info page. + * @throws Exception + */ + public static function savestaticinfopage(outage $outage, $file) { + if (!is_string($file)) { + throw new InvalidArgumentException('$file is not a string.'); + } + + $html = self::get_renderer()->renderoutagepagestatic($outage); + + // Sanity check before writing/overwriting old file. + if (!is_string($html) || ($html == '')) { + throw new Exception('Sanity check failed. Invalid contents on $html.'); + } + + $dir = dirname($file); + if (!file_exists($dir) || !is_dir($dir)) { + throw new Exception('Directory must exists: ' . $dir); + } + file_put_contents($file, $html); + } + + /** + * Updates the static info page by (re)creating or deleting it as needed. + * @param null $file + * @throws Exception + */ + public static function updatestaticinfopagefile($file = null) { + if (is_null($file)) { + $file = self::get_defaulttemplatefile(); + } + if (!is_string($file)) { + throw new InvalidArgumentException('$file is not a string.'); + } + + $outage = outagedb::get_next_starting(); + if (is_null($outage)) { + if (file_exists($file)) { + if (is_file($file)) { + unlink($file); + } else { + throw new Exception('Cannot remove non-file: ' . $file); + } + } + } else { + self::savestaticinfopage($outage, $file); + } + } + + /** + * Given the HTML code for the static page, find the outage id for that page. + * @param $html Static info page HTML. + * @return int|null Outage id or null if not found. + */ + public static function get_outageidfrominfopage($html) { + if (!is_string($html)) { + throw new InvalidArgumentException('$html must be a string.'); + } + + $output = []; + if (preg_match('/data-outage-id="(?P\d+)"/', $html, $output)) { + return (int)$output['id']; + } + return null; + } + + /** + * @return string The default template file to use for static info page. + */ + public static function get_defaulttemplatefile() { + global $CFG; + return $CFG->dataroot . '/climaintenance.template.html'; + } } diff --git a/info.php b/info.php index 046c0bc..7862f79 100644 --- a/info.php +++ b/info.php @@ -34,16 +34,19 @@ if (is_null($outage)) { redirect(new moodle_url('/')); } -$PAGE->set_context(context_system::instance()); -$PAGE->set_title($outage->get_title()); -$PAGE->set_heading($outage->get_title()); -$PAGE->set_url(new \moodle_url('/auth/outage/info.php')); +if (optional_param('static', false, PARAM_BOOL)) { + echo outagelib::get_renderer()->renderoutagepagestatic($outage); +} else { + $PAGE->set_title($outage->get_title()); + $PAGE->set_heading($outage->get_title()); + $PAGE->set_url(new \moodle_url('/auth/outage/info.php')); -// No hooks injecting into this page, do it manually. -outagelib::inject(); + // No hooks injecting into this page, do it manually. + outagelib::inject(); -echo $OUTPUT->header(); + echo $OUTPUT->header(); -echo outagelib::get_renderer()->renderoutagepage($outage); + echo outagelib::get_renderer()->renderoutagepage($outage); -echo $OUTPUT->footer(); + echo $OUTPUT->footer(); +} diff --git a/lang/en/auth_outage.php b/lang/en/auth_outage.php index e937d52..77338a6 100644 --- a/lang/en/auth_outage.php +++ b/lang/en/auth_outage.php @@ -77,6 +77,7 @@ $string['infofrom'] = 'From:'; $string['infountil'] = 'Until:'; $string['infostart'] = 'start'; $string['infostartofwarning'] = 'start of warning'; +$string['infopagestaticgenerated'] = 'This warning was generated on {$a->time}.'; $string['menudefaults'] = 'Default Settings'; $string['menumanage'] = 'Manage'; $string['messageoutageongoing'] = 'Back online at {$a->stop}.'; diff --git a/renderer.php b/renderer.php index 1978587..dd319c7 100644 --- a/renderer.php +++ b/renderer.php @@ -37,7 +37,7 @@ class auth_outage_renderer extends plugin_renderer_base { */ public function rendersubtitle($subtitlekey) { if (!is_string($subtitlekey)) { - throw new \InvalidArgumentException('$subtitle is not a string.'); + throw new InvalidArgumentException('$subtitle is not a string.'); } return html_writer::tag('h2', get_string($subtitlekey, 'auth_outage')); } @@ -186,8 +186,9 @@ class auth_outage_renderer extends plugin_renderer_base { } /** - * @param outage $outage - * @param null $time + * Renders the outage page. + * @param outage $outage Outage to be rendered. + * @param null $time Time to use as refence. Null for current time. * @return string * @SuppressWarnings("unused") because $admineditlink is used inside require(...) */ @@ -197,8 +198,8 @@ class auth_outage_renderer extends plugin_renderer_base { if (is_null($time)) { $time = time(); } - if (!is_int($time)) { - throw new \InvalidArgumentException('$time is not an int or null.'); + if (!is_int($time) || ($time <= 0)) { + throw new InvalidArgumentException('$time is not an positive int or null.'); } $adminlinks = []; @@ -226,6 +227,8 @@ class auth_outage_renderer extends plugin_renderer_base { get_string('outageedit', 'auth_outage') ); + $static = false; + ob_start(); require($CFG->dirroot . '/auth/outage/views/infopage.php'); $html = ob_get_contents(); @@ -233,6 +236,26 @@ class auth_outage_renderer extends plugin_renderer_base { return $html; } + /** + * Generates the HTML for a static info page. + * @param outage $outage Outage to generate the page. + * @return string The HTML code. + * @SuppressWarnings("unused") because variables are used in require(...) + */ + public function renderoutagepagestatic(outage $outage) { + global $PAGE, $CFG; + $PAGE->set_context(context_system::instance()); + + $static = true; + $time = $outage->starttime; + + ob_start(); + require($CFG->dirroot . '/auth/outage/views/infopagestatic.php'); + $html = ob_get_contents(); + ob_end_clean(); + return $html; + } + /** * Renders the warning bar. * @param outage $outage The outage to show in the warning bar. @@ -246,8 +269,8 @@ class auth_outage_renderer extends plugin_renderer_base { if (is_null($time)) { $time = time(); } - if (!is_int($time)) { - throw new \InvalidArgumentException('$time is not an int or null.'); + if (!is_int($time) || ($time <= 0)) { + throw new InvalidArgumentException('$time is not an positive int or null.'); } $start = userdate($outage->starttime, get_string('datetimeformat', 'auth_outage')); diff --git a/tests/cli/outagelib_test.php b/tests/cli/outagelib_test.php new file mode 100644 index 0000000..2b43f19 --- /dev/null +++ b/tests/cli/outagelib_test.php @@ -0,0 +1,74 @@ +. + +use auth_outage\models\outage; +use auth_outage\outagelib; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests performed on outage class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage\outagelib + */ +class outagelib_test extends advanced_testcase { + /** + * Gets a temp file to use in the test. Deleted every time a test starts. + * @return string A temporary file name. + */ + public function get_file() { + return sys_get_temp_dir() . '/phpunit_authoutage.tmp'; + } + + public function setUp() { + if (file_exists($this->get_file())) { + if (is_file($this->get_file())) { + unlink($this->get_file()); + } else { + self::fail('Invalid temp file: ' . $this->get_file()); + } + } + } + + public function test_staticpage() { + $now = time(); + $outage = new outage([ + 'id' => 1, + 'warntime' => $now - 100, + 'starttime' => $now + 100, + 'stoptime' => $now + 200, + 'title' => 'Title', + 'description' => 'Description', + ]); + outagelib::savestaticinfopage($outage, $this->get_file()); + self::assertFileExists($this->get_file()); + + $id = outagelib::get_outageidfrominfopage(file_get_contents($this->get_file())); + self::assertSame($outage->id, $id); + + unlink($this->get_file()); + } + + public function test_getdefaulttemplatefile() { + $file = outagelib::get_defaulttemplatefile(); + self::assertTrue(is_string($file)); + self::assertContains('template', $file); + } +} diff --git a/tests/renderer_test.php b/tests/renderer_test.php new file mode 100644 index 0000000..996a5d7 --- /dev/null +++ b/tests/renderer_test.php @@ -0,0 +1,54 @@ +. + +use auth_outage\models\outage; +use auth_outage\outagelib; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests performed on outage class. + * + * @package auth_outage + * @author Daniel Thee Roperto + * @copyright Catalyst IT + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @covers \auth_outage_renderer + */ +class renderer_test extends advanced_testcase { + public function test_staticpage() { + global $PAGE; + $this->resetAfterTest(true); + + $PAGE->set_context(context_system::instance()); + $renderer = outagelib::get_renderer(); + $now = time(); + $outage = new outage([ + 'id' => 1, + 'starttime' => $now + (60 * 60), + 'warntime' => $now - (60 * 60), + 'stoptime' => $now + (2 * 60 * 60), + 'title' => 'Outage Title at {{start}}', + 'description' => 'This is an important outage, starting at {{start}}.', + ]); + $html = $renderer->renderoutagepagestatic($outage); + self::assertContains('', $html); + self::assertContains('', $html); + self::assertContains($outage->get_title(), $html); + self::assertContains($outage->get_description(), $html); + self::assertSame($outage->id, outagelib::get_outageidfrominfopage($html)); + } +} diff --git a/views/infopage.php b/views/infopage.php index e5e1084..fc2ae51 100644 --- a/views/infopage.php +++ b/views/infopage.php @@ -39,7 +39,7 @@ if (!defined('MOODLE_INTERNAL')) {
get_description(); ?>
- + -is_ongoing($time)): ?> +is_ongoing($time)): ?>