From 29d3bbb03c2e11c9df6049936d1c085f6dd35e83 Mon Sep 17 00:00:00 2001 From: Stefan Hanauska Date: Mon, 16 Dec 2024 07:54:38 +0100 Subject: [PATCH] MBS-8974: Add repeating of cards --- backup/moodle2/backup_kanban_stepslib.php | 15 +++++- classes/boardmanager.php | 63 ++++++++++++++++++---- classes/constants.php | 42 +++++++++++++++ classes/external/change_kanban_content.php | 1 - classes/form/edit_card_form.php | 29 +++++++++- db/install.xml | 4 ++ db/upgrade.php | 58 +++++++++++++++++++- lang/en/kanban.php | 9 ++++ tests/change_kanban_content_test.php | 8 ++- version.php | 2 +- 10 files changed, 214 insertions(+), 17 deletions(-) diff --git a/backup/moodle2/backup_kanban_stepslib.php b/backup/moodle2/backup_kanban_stepslib.php index cc9b951f..a7a1cd89 100644 --- a/backup/moodle2/backup_kanban_stepslib.php +++ b/backup/moodle2/backup_kanban_stepslib.php @@ -34,7 +34,20 @@ protected function define_structure(): backup_nested_element { $kanban = new backup_nested_element( 'kanban', ['id'], - ['course', 'name', 'intro', 'introformat', 'userboards', 'history', 'completioncreate', 'completioncomplete'] + [ + 'course', + 'name', + 'intro', + 'introformat', + 'userboards', + 'history', + 'completioncreate', + 'completioncomplete', + 'repeat_enable', + 'repeat_interval', + 'repeat_interval_type', + 'repeat_newduedate', + ] ); $kanban->set_source_table('kanban', ['id' => backup::VAR_ACTIVITYID]); $kanban->annotate_files('mod_kanban', 'intro', null); diff --git a/classes/boardmanager.php b/classes/boardmanager.php index 20635f51..4449ef08 100644 --- a/classes/boardmanager.php +++ b/classes/boardmanager.php @@ -18,7 +18,7 @@ * Class to handle updating the board * * @package mod_kanban - * @copyright 2023-2024 ISB Bayern + * @copyright 2023-2024 ISB Bayern * @author Stefan Hanauska * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -34,7 +34,7 @@ * Class to handle updating the board. It also sends notifications, but does not check permissions. * * @package mod_kanban - * @copyright 2023-2024 ISB Bayern + * @copyright 2023-2024 ISB Bayern * @author Stefan Hanauska * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -477,7 +477,6 @@ public function add_card(int $columnid, int $aftercard = 0, array $data = []): i // Users can always edit cards they created. $data['canedit'] = $this->can_user_manage_specific_card($data['id']); - ; $data['columnname'] = clean_param($column->title, PARAM_TEXT); $this->formatter->put('cards', $data); @@ -547,7 +546,9 @@ public function move_card(int $cardid, int $aftercard, int $columnid = 0): void // If target column has autoclose option set, update card to be completed. $options = json_decode($targetcolumn->options); if (!empty($options->autoclose)) { - $updatecard['completed'] = 1; + if ($card['completed']) { + self::set_card_complete($cardid, 1); + } } $DB->update_record('kanban_card', $updatecard); // When inplace editing the title and moving the card happens quite fast in a row, @@ -581,11 +582,7 @@ public function move_card(int $cardid, int $aftercard, int $columnid = 0): void $assignees = $this->get_card_assignees($cardid); helper::send_notification($this->cminfo, 'moved', $assignees, (object) $data); if (!empty($options->autoclose) && $card->completed == 0) { - $data['title'] = clean_param($card->title, PARAM_TEXT); - helper::send_notification($this->cminfo, 'closed', $assignees, (object) $data); - helper::remove_calendar_event($this->kanban, $card); - $this->write_history('completed', constants::MOD_KANBAN_CARD, [], $columnid, $cardid); - $this->update_completion($assignees); + self::set_card_complete($cardid, 1); } $this->write_history( 'moved', @@ -688,6 +685,30 @@ public function set_card_complete(int $cardid, int $state): void { $assignees = $this->get_card_assignees($cardid); if ($state) { helper::remove_calendar_event($this->kanban, $card, $assignees); + if (!empty($card->repeat_enable)) { + $newcard = clone $card; + if ($card->repeat_newduedate == constants::MOD_KANBAN_REPEAT_NONEWDUEDATE) { + $newcard->duedate = 0; + $newcard->reminder = 0; + } else { + $timedifference = $newcard->duedate - $newcard->reminder; + $timebase = ( + $card->repeat_newduedate == constants::MOD_KANBAN_REPEAT_NEWDUEDATE_AFTERDUE && !empty($newcard->duedate) ? + $newcard->duedate : + time() + ); + $newcard->duedate = strtotime( + '+' . + $card->repeat_interval . + ' ' . + constants::MOD_KANBAN_REPEAT_INTERVAL_TYPE[$card->repeat_interval_type], + $timebase + ); + $newcard->reminder = $newcard->duedate - $timedifference; + } + $newcard->isrepeated = 1; + $this->add_card($this->get_leftmost_column($card->kanban_board), 0, (array)$newcard); + } } else { helper::add_or_update_calendar_event($this->kanban, $card, $assignees); } @@ -813,6 +834,10 @@ public function update_card(int $cardid, array $data): void { 'kanban_column', 'kanban_board', 'completed', + 'repeat_enable', + 'repeat_interval', + 'repeat_interval_type', + 'repeat_newduedate', ]; // Do some extra sanitizing. if (isset($data['title'])) { @@ -1230,4 +1255,24 @@ public function can_user_manage_specific_card(int $cardid, int $userid = 0): boo return false; } + + /** + * Returns the leftmost column of a board, 0 if none is found. + * + * @param int $boardid Id of the board, defaults to 0 (current board) + * @return int + */ + public function get_leftmost_column(int $boardid = 0): int { + global $DB; + if (empty($boardid) || $this->board->id == $boardid) { + $sequence = $this->board->sequence; + } else { + $sequence = $DB->get_field('kanban_board', 'sequence', ['id' => $boardid]); + } + if (empty($sequence)) { + return 0; + } + $columnids = explode(',', $sequence, 2); + return $columnids[0]; + } } diff --git a/classes/constants.php b/classes/constants.php index 635863cc..d9fb199e 100644 --- a/classes/constants.php +++ b/classes/constants.php @@ -91,4 +91,46 @@ class constants { self::MOD_KANBAN_DISCUSSION => 'discussion', self::MOD_KANBAN_HISTORY => 'history', ]; + /** + * Repeat interval type: hours + */ + public const MOD_KANBAN_REPEAT_HOURS = 2; + /** + * Repeat interval type: days + */ + public const MOD_KANBAN_REPEAT_DAYS = 3; + /** + * Repeat interval type: weeks + */ + public const MOD_KANBAN_REPEAT_WEEKS = 4; + /** + * Repeat interval type: months + */ + public const MOD_KANBAN_REPEAT_MONTHS = 5; + /** + * Repeat interval type: years + */ + public const MOD_KANBAN_REPEAT_YEARS = 6; + /** + * Mapping of repeat interval types to strings + */ + public const MOD_KANBAN_REPEAT_INTERVAL_TYPE = [ + self::MOD_KANBAN_REPEAT_HOURS => 'hour', + self::MOD_KANBAN_REPEAT_DAYS => 'day', + self::MOD_KANBAN_REPEAT_WEEKS => 'week', + self::MOD_KANBAN_REPEAT_MONTHS => 'month', + self::MOD_KANBAN_REPEAT_YEARS => 'year', + ]; + /** + * Repeat new due date: no new due date + */ + public const MOD_KANBAN_REPEAT_NONEWDUEDATE = 0; + /** + * Repeat new due date: after due date + */ + public const MOD_KANBAN_REPEAT_NEWDUEDATE_AFTERDUE = 1; + /** + * Repeat new due date: after completion + */ + public const MOD_KANBAN_REPEAT_NEWDUEDATE_AFTERCOMPLETION = 2; } diff --git a/classes/external/change_kanban_content.php b/classes/external/change_kanban_content.php index 6d7eca81..7b9c193d 100644 --- a/classes/external/change_kanban_content.php +++ b/classes/external/change_kanban_content.php @@ -299,7 +299,6 @@ public static function move_card_returns(): external_single_structure { * @throws moodle_exception */ public static function move_card(int $cmid, int $boardid, array $data): array { - global $USER; $params = self::validate_parameters(self::move_card_parameters(), [ 'cmid' => $cmid, 'boardid' => $boardid, diff --git a/classes/form/edit_card_form.php b/classes/form/edit_card_form.php index df28ac5f..151b7d35 100644 --- a/classes/form/edit_card_form.php +++ b/classes/form/edit_card_form.php @@ -21,13 +21,14 @@ use core_form\dynamic_form; use mod_kanban\boardmanager; use mod_kanban\helper; +use mod_kanban\constants; use moodle_url; /** * From for editing a card. * * @package mod_kanban - * @copyright 2023-2024 ISB Bayern + * @copyright 2023-2024 ISB Bayern * @author Stefan Hanauska * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -80,6 +81,32 @@ public function definition() { $mform->addElement('date_time_selector', 'reminderdate', get_string('reminderdate', 'kanban'), ['optional' => true]); + $repeatgroup = []; + $repeatgroup[] = $mform->createElement('advcheckbox', 'repeat_enable', get_string('enable')); + $repeatgroup[] = $mform->createElement('text', 'repeat_interval', get_string('repeat_interval', 'kanban'), ['size' => 3]); + $repeatgroup[] = $mform->createElement('select', 'repeat_interval_type', get_string('repeat_interval_type', 'kanban'), [ + constants::MOD_KANBAN_REPEAT_HOURS => get_string('hours'), + constants::MOD_KANBAN_REPEAT_DAYS => get_string('days'), + constants::MOD_KANBAN_REPEAT_WEEKS => get_string('weeks'), + constants::MOD_KANBAN_REPEAT_MONTHS => get_string('months'), + constants::MOD_KANBAN_REPEAT_YEARS => get_string('years'), + ]); + $repeatgroup[] = $mform->createElement('select', 'repeat_newduedate', get_string('repeat_newduedate', 'kanban'), [ + constants::MOD_KANBAN_REPEAT_NONEWDUEDATE => get_string('nonewduedate', 'kanban'), + constants::MOD_KANBAN_REPEAT_NEWDUEDATE_AFTERDUE => get_string('afterdue', 'kanban'), + constants::MOD_KANBAN_REPEAT_NEWDUEDATE_AFTERCOMPLETION => get_string('aftercompletion', 'kanban'), + ]); + + $mform->addElement('group', 'repeatgroup', get_string('repeat', 'kanban'), $repeatgroup, ' ', false); + + $mform->setType('repeat_interval', PARAM_INT); + $mform->setType('repeat_interval_type', PARAM_INT); + $mform->setDefault('repeat_interval', 1); + $mform->disabledIf('repeatgroup', 'repeat_enable', 'notchecked'); + $mform->disabledIf('repeat_interval', 'repeat_newduedate', 'eq', constants::MOD_KANBAN_REPEAT_NONEWDUEDATE); + $mform->disabledIf('repeat_interval_type', 'repeat_newduedate', 'eq', constants::MOD_KANBAN_REPEAT_NONEWDUEDATE); + $mform->addHelpButton('repeatgroup', 'repeat', 'kanban'); + $mform->addElement('filemanager', 'attachments', get_string('attachments', 'kanban')); $mform->addElement('color', 'color', get_string('color', 'mod_kanban'), ['size' => 5]); diff --git a/db/install.xml b/db/install.xml index 6227c5a1..861fcbfb 100755 --- a/db/install.xml +++ b/db/install.xml @@ -78,6 +78,10 @@ + + + + diff --git a/db/upgrade.php b/db/upgrade.php index 5a15e923..274e60c4 100755 --- a/db/upgrade.php +++ b/db/upgrade.php @@ -18,7 +18,7 @@ * mod_kanban db upgrades. * * @package mod_kanban - * @copyright 2023-2024 ISB Bayern + * @copyright 2023-2024 ISB Bayern * @author Stefan Hanauska * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ @@ -29,6 +29,60 @@ * @param int $oldversion Version number the plugin is being upgraded from. */ function xmldb_kanban_upgrade($oldversion) { - // No upgrade steps until now. + global $DB; + $dbman = $DB->get_manager(); + + if ($oldversion < 2024121602) { + // Define field repeat_enable to be added to kanban_card. + $table = new xmldb_table('kanban_card'); + $field = new xmldb_field('repeat_enable', XMLDB_TYPE_INTEGER, '2', null, XMLDB_NOTNULL, null, '0', 'timemodified'); + + // Conditionally launch add field repeat_enable. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $field = new xmldb_field('repeat_interval', XMLDB_TYPE_INTEGER, '11', null, XMLDB_NOTNULL, null, '0', 'repeat_enable'); + + // Conditionally launch add field repeat_interval. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $field = new xmldb_field( + 'repeat_interval_type', + XMLDB_TYPE_INTEGER, + '11', + null, + XMLDB_NOTNULL, + null, + '0', + 'repeat_interval' + ); + + // Conditionally launch add field repeat_interval_type. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + $field = new xmldb_field( + 'repeat_newduedate', + XMLDB_TYPE_INTEGER, + '5', + null, + XMLDB_NOTNULL, + null, + '0', + 'repeat_interval_type' + ); + + // Conditionally launch add field repeat_newduedate. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Kanban savepoint reached. + upgrade_mod_savepoint(true, 2024121602, 'kanban'); + } return true; } diff --git a/lang/en/kanban.php b/lang/en/kanban.php index c06f3cfb..2ffa4a53 100644 --- a/lang/en/kanban.php +++ b/lang/en/kanban.php @@ -27,6 +27,8 @@ $string['addcard'] = 'Add a card to this column'; $string['addcolumn'] = 'Add a column to this board'; +$string['aftercompletion'] = 'after card is closed'; +$string['afterdue'] = 'after card is due'; $string['assignee'] = 'Assignee'; $string['assignees'] = 'Assignees'; $string['assignme'] = 'Assign me'; @@ -143,6 +145,7 @@ $string['newcolumn'] = 'New column'; $string['nogroupavailable'] = 'No group available'; $string['nokanbaninstances'] = 'There are no kanban boards in this course or you are not allowed to access them'; +$string['nonewduedate'] = 'No new due date'; $string['nouser'] = 'No user'; $string['nouserboards'] = 'No personal boards'; $string['pluginadministration'] = 'Kanban administration'; @@ -167,6 +170,12 @@ $string['pushcardconfirm'] = 'This will send a copy of this card to all boards inside this kanban activity including templates. Existing copies will be replaced.'; $string['reminderdate'] = 'Reminder date'; $string['remindertask'] = 'Send reminder notifications'; +$string['repeat'] = 'Repeat card'; +$string['repeat_help'] = "If selected, a new copy of this card will be created in the leftmost column as soon as this instance is completed. Discussion, history and assignees are not copied. +You can choose how to calculate the new due date, if needed. This will also be applied to the new reminder date."; +$string['repeat_interval'] = 'Interval'; +$string['repeat_interval_type'] = 'Frequency'; +$string['repeat_newduedate'] = 'New due date'; $string['reset_group'] = 'Reset group boards'; $string['reset_kanban'] = 'Reset shared boards'; $string['reset_personal'] = 'Reset personal boards'; diff --git a/tests/change_kanban_content_test.php b/tests/change_kanban_content_test.php index c08c4571..9e5b0fd2 100644 --- a/tests/change_kanban_content_test.php +++ b/tests/change_kanban_content_test.php @@ -263,10 +263,12 @@ public function test_move_card(): void { $update = json_decode($returnvalue['update'], true); - $this->assertCount(3, $update); + // As the target column has autoclose enabled by default, we get two updates for cards. + $this->assertCount(4, $update); $this->assertEquals('cards', $update[0]['name']); $this->assertEquals('columns', $update[1]['name']); $this->assertEquals('columns', $update[2]['name']); + $this->assertEquals('cards', $update[3]['name']); $this->assertEquals(join(',', [$cards[0]->id, $cards[2]->id]), $update[2]['fields']['sequence']); $this->assertEquals('', $update[1]['fields']['sequence']); @@ -301,10 +303,12 @@ public function test_move_card(): void { $update = json_decode($returnvalue['update'], true); - $this->assertCount(3, $update); + // As the target column has autoclose enabled by default, we get two updates for cards. + $this->assertCount(4, $update); $this->assertEquals('cards', $update[0]['name']); $this->assertEquals('columns', $update[1]['name']); $this->assertEquals('columns', $update[2]['name']); + $this->assertEquals('cards', $update[3]['name']); $this->assertEquals(join(',', [$cards[2]->id, $cards[1]->id, $cards[0]->id]), $update[2]['fields']['sequence']); $this->assertEquals('', $update[1]['fields']['sequence']); diff --git a/version.php b/version.php index a788d7f0..43c2b633 100644 --- a/version.php +++ b/version.php @@ -26,7 +26,7 @@ $plugin->component = 'mod_kanban'; $plugin->release = '0.2.6'; -$plugin->version = 2024121601; +$plugin->version = 2024121602; $plugin->requires = 2022112800; $plugin->supported = [401, 405]; $plugin->maturity = MATURITY_STABLE;