diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..81ba4368 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,53 @@ +> **Note:** Please fill out all required sections and remove irrelevant ones. +### πŸ”€ Purpose of this PR: + +- [ ] Fixes a bug +- [ ] Updates for a new Moodle version +- [ ] Adds a new feature of functionality +- [ ] Improves or enhances existing features +- [ ] Refactoring: restructures code for better performance or maintainability +- [ ] Testing: add missing or improve existing tests +- [ ] Miscellaneous: code cleaning (without functional changes), documentation, configuration, ... + +--- + +### πŸ“ Description: + +Please describe the purpose of this PR in a few sentences. + +- What feature or bug does it address? +- Why is this change or addition necessary? +- What is the expected behavior after the change? + +--- + +### πŸ“‹ Checklist + +Please confirm the following (check all that apply): + +- [ ] I have `phpunit` and/or `behat` tests that cover my changes or additions. +- [ ] Code passes the code checker without errors and warnings. +- [ ] Code passes the moodle-ci/cd pipeline on all supported Moodle versions or the ones the plugin supports. +- [ ] Code does not have `var_dump()` or `var_export` or any other debugging statements (or commented out code) that + should not appear on the productive branch. +- [ ] Code only uses language strings instead of hard-coded strings. +- [ ] If there are changes in the database: I updated/created the necessary upgrade steps in `db/upgrade.php` and + updated the `version.php`. +- [ ] If there are changes in javascript: I build new `.min` files with the `grunt amd` command. +- [ ] If it is a Moodle update PR: I read the release notes, updated the `version.php` and the `CHANGES.md`. + I ran all tests thoroughly checking for errors. I checked if bootstrap had any changes/deprecations that require + changes in the plugins UI. + +--- + +### πŸ” Related Issues + +- Related to #[IssueNumber] + +--- + +### πŸ§ΎπŸ“ΈπŸŒ Additional Information (like screenshots, documentation, links, etc.) + +Any other relevant information. + +--- \ No newline at end of file diff --git a/.github/workflows/config.json b/.github/workflows/config.json index 3dd0a5ff..cc1bd5a9 100644 --- a/.github/workflows/config.json +++ b/.github/workflows/config.json @@ -1,9 +1,16 @@ { "main-moodle": "MOODLE_405_STABLE", "main-php": "8.3", - "moodle-php": { - "MOODLE_401_STABLE": ["8.0","8.1"], - "MOODLE_405_STABLE": ["8.1","8.2","8.3"] + "main-db": "pgsql", + "moodle-testmatrix": { + "MOODLE_401_STABLE": { + "php": ["8.0", "8.1"], + "db": ["pgsql", "mariadb", "mysqli"] + }, + "MOODLE_405_STABLE": { + "php": ["8.1", "8.2", "8.3"], + "db": ["pgsql", "mariadb", "mysqli"] + } }, "moodle-plugin-ci": "4.4.5" } diff --git a/.gitignore b/.gitignore index b45ed724..5071d654 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ # Used for simulating cron during development development.php +/.idea/ +/trigger/customfieldsemester/ diff --git a/activeprocesses.php b/activeprocesses.php index ff60506a..0b801e87 100644 --- a/activeprocesses.php +++ b/activeprocesses.php @@ -32,6 +32,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::ACTIVE_PROCESSES)); diff --git a/activeworkflows.php b/activeworkflows.php index 988543bc..702c7705 100644 --- a/activeworkflows.php +++ b/activeworkflows.php @@ -33,6 +33,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::ACTIVE_WORKFLOWS)); diff --git a/classes/local/intersectedRecordset.php b/classes/local/intersectedRecordset.php new file mode 100644 index 00000000..29611f2e --- /dev/null +++ b/classes/local/intersectedRecordset.php @@ -0,0 +1,157 @@ +. + +/** + * Helper class which intersects multiple moodle record sets. + * + * @package tool_lifecycle + * @copyright 2025 Michael Schink JKU + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_lifecycle\local; + +defined('MOODLE_INTERNAL') || die(); + +class intersectedRecordset implements \Iterator, \Countable { + private $records = []; + private $position = 0; + private $wasFilled = false; + + /** + * Constructor: Inits class & intersects passed recordsets. + * + * @param moodle_recordset|array|null $recordsets + * @param string $key + */ + public function __construct($recordsets = null, string $key = 'id') { + if($recordsets !== null) { + if(is_array($recordsets)) { + // For multiple recordsets + foreach($recordsets as $recordset) { + // If recordset is a chunked recordset + if(is_array($recordset)) { + //mtrace('Chunked recordset'); + // Create new array for chunked recordset + $chunkedRecords = []; + // For each chunked recordset + foreach($recordset as $chunk_recordset) { + // For each record in chunked recordset + foreach($chunk_recordset as $record) { + if(isset($record->$key)) { $chunkedRecords[$record->$key] = $record; } + } + } + // Add all records of chunked recordsets + $this->add($chunkedRecords, $key); + } else { + //mtrace('Normal recordset'); + $this->add($recordset, $key); + } + } + } else { $this->add($recordsets, $key); } + } + } + + /** + * Adds recordset & saves intersection of all recordsets. + * + * @param moodle_recordset $recordset + * @param string $key + */ + public function add($recordset, string $key = 'id'): void { + // Add new records to array with key + $newRecords = []; + foreach($recordset as $record) { + if(isset($record->$key)) { $newRecords[$record->$key] = $record; } + } + //mtrace(' Found '.count($newRecords).' records in recordset'); + //$recordset->close(); + + // Store new records without key, if no records were stored & return + if(empty($this->records) && !$this->wasFilled) { + $this->records = array_values($newRecords); + $this->wasFilled = true; + + return; + } + + // Add existing records to array with key + $existingRecords = []; + foreach($this->records as $record) { + if(isset($record->$key)) { $existingRecords[$record->$key] = $record; } + } + + // Intersect existing & new records by keys + $intersectionKeys = array_intersect_key($existingRecords, $newRecords); + // Clear existing records + $this->records = []; + // Store intersected records by keys + foreach($intersectionKeys as $keyValue => $record) { + $this->records[] = $existingRecords[$keyValue]; + } + //mtrace('Add - Intersected record sets: '.count($this->records)); + } + + /** + * Returns current recordset. + * + * @return mixed + */ + public function current(): mixed { + return $this->records[$this->position]; + } + + /** + * Returns current key (index). + * + * @return int + */ + public function key(): int { + return $this->position; + } + + /** + * Moves internal pointer to next recordset. + */ + public function next(): void { + $this->position++; + } + + /** + * Returns internal pointer to start. + */ + public function rewind(): void { + $this->position = 0; + } + + /** + * Checks if current pointer points to a valid recordset. + * + * @return bool + */ + public function valid(): bool { + return isset($this->records[$this->position]); + } + + /** + * Returns the amount of all recordsets. + * + * @return int + */ + public function count(): int { + return count($this->records); + } +} \ No newline at end of file diff --git a/classes/processor.php b/classes/processor.php index 37be60a5..0ee8412b 100644 --- a/classes/processor.php +++ b/classes/processor.php @@ -194,27 +194,80 @@ public function process_course_interactive($processid) { */ public function get_course_recordset($triggers, $exclude, $forcounting = false) { global $DB; - - $where = 'true'; + + $where = []; $whereparams = []; + $recordsets = []; foreach ($triggers as $trigger) { $lib = lib_manager::get_automatic_trigger_lib($trigger->subpluginname); [$sql, $params] = $lib->get_course_recordset_where($trigger->id); if (!empty($sql)) { - $where .= ' AND ' . $sql; - $whereparams = array_merge($whereparams, $params); + $where[] = 'true AND ' . $sql; + $whereparams[] = $params; } } - + if (!empty($exclude)) { [$insql, $inparams] = $DB->get_in_or_equal($exclude, SQL_PARAMS_NAMED); - $where .= " AND NOT {course}.id {$insql}"; - $whereparams = array_merge($whereparams, $inparams); + $where[] = "true AND NOT {course}.id {$insql}"; + $whereparams[] = $inparams; } - + + $maxparams = 65535; + //$maxparams = 20000; + mtrace(''); + //mtrace('Start - MAX params: '.$maxparams.', trigger where parts: '.count($where)/*.' '.print_r($where, true)*/.' & params: '.count($whereparams)/*.' '.print_r($whereparams, true)*/); + foreach($whereparams as $key => $whereparam) { + if(count($whereparam) > $maxparams) { + //mtrace('More than '.$maxparams.' params with key '.$key.': '.count($whereparam)); + // Get where part of params array + $wherepart = $where[$key]; + // Get first & last param + $first = ':'.array_key_first($whereparam); + $last = ':'.array_key_last($whereparam); + //mtrace(' 1. Get first param '.$first.' & last param '.$last); + // Get where part before first param & after last param (to re-create where part) + $position = strpos($wherepart, $first); + $before = substr($wherepart, 0, $position); + $position = strpos($wherepart, $last); + $after = substr($wherepart, $position + strlen($last)); + //mtrace(' 2. Re-create where part: '.$before.' '.$after); + // Remove original where part & params + //unset($where[$key]); + //unset($whereparams[$key]); + $where[$key] = []; + $whereparams[$key] = []; + //mtrace(' 3. Remove where part & params with key: '.$key); + // Chunk params + $whereparam_chunks = array_chunk($whereparam, $maxparams, true); + //mtrace(' 4. Chunk params: '.count($whereparam_chunks)/*.print_r($whereparam_chunks, true))*/.' ('.count($whereparam).'/'.$maxparams.')'); + // For each chunk of params + $counter = 0; + foreach($whereparam_chunks as $whereparam_chunk) { + $counter++; + // Create param string of chunk params + $whereparam_chunk_string = implode(',', array_map(function($value) { return ':' . $value; }, array_keys($whereparam_chunk))); + // Re-create where part for chunk + $where_chunk = $before.$whereparam_chunk_string.$after; + //if(count($whereparam_chunks) > 10 && $counter == 10 ) { mtrace(' ...'); } + //if($counter < 5 || $counter >= (count($whereparam_chunks) - 5)) { mtrace(' 5.'.$counter.' Add chunk query: '.(strlen($where_chunk) > 150 ? substr($where_chunk, 0, 150) . '...' : $where_chunk).' & params: '.count($whereparam_chunk)/*.print_r($whereparam_chunk, true)*/); } + // Add where part & params of chunk + if(count($where) && count($whereparams)) { + $where[$key][] = $where_chunk; + $whereparams[$key][] = $whereparam_chunk; + } else { mtrace('ERROR: Amount of where parts & params are not the same!'); } + } + } + } + //mtrace('End - MAX params: '.$maxparams.', trigger where parts: '.count($where)/*.' '.print_r($where, true)*/.' & params '.count($whereparams)/*.' '.print_r($whereparams, true)*/); + //mtrace(''); + //die(); + if ($forcounting) { - // Get course hasotherprocess and delay with the sql. - $sql = "SELECT {course}.id, + foreach ($where as $key => $where_tmp) { + $whereparams_tmp = $whereparams[$key]; + // Get course hasotherprocess and delay with the sql. + $sql = "SELECT {course}.id, COALESCE(p.courseid, pe.courseid, 0) as hasprocess, CASE WHEN COALESCE(p.workflowid, 0) > COALESCE(pe.workflowid, 0) THEN p.workflowid @@ -232,17 +285,63 @@ public function get_course_recordset($triggers, $exclude, $forcounting = false) LEFT JOIN {tool_lifecycle_proc_error} pe ON {course}.id = pe.courseid LEFT JOIN {tool_lifecycle_delayed} d ON {course}.id = d.courseid LEFT JOIN {tool_lifecycle_delayed_workf} dw ON {course}.id = dw.courseid - WHERE " . $where; + WHERE "; + + if(is_array($where_tmp)) { + //mtrace('Chunked recordset: '.count($where_tmp)/*.' '.print_r($where_tmp, true)*/.', params: '.count($whereparams_tmp)/*.' '.print_r($whereparams_tmp, true)*/); + $tmp = []; + foreach($where_tmp as $chunk_key => $chunk_where_tmp) { + $sql_tmp = $sql.$chunk_where_tmp; + $tmp[] = $DB->get_recordset_sql($sql_tmp, $whereparams_tmp[$chunk_key]); + } + $recordsets[] = $tmp; + } else { + //mtrace('Nomrmal recordset: '.$where_tmp.', params: '.count($whereparams_tmp)/*.' '.print_r($whereparams_tmp, true)*/); + $sql_tmp = $sql.$where_tmp; + $recordsets[] = $DB->get_recordset_sql($sql_tmp, $whereparams_tmp); + } + } + + //use tool_lifecycle\local\intersectedRecordset; + $recordsets = new \tool_lifecycle\local\intersectedRecordset($recordsets); + //mtrace('Intersected record sets (for counting): '.count($recordsets)); } else { - // Get only courses which are not part of an existing process. - $sql = 'SELECT {course}.id from {course} '. - 'LEFT JOIN {tool_lifecycle_process} '. - 'ON {course}.id = {tool_lifecycle_process}.courseid '. - 'LEFT JOIN {tool_lifecycle_proc_error} pe ON {course}.id = pe.courseid ' . - 'WHERE {tool_lifecycle_process}.courseid is null AND ' . - 'pe.courseid IS NULL AND '. $where; + foreach ($where as $key => $where_tmp) { + $whereparams_tmp = $whereparams[$key]; + // Get only courses which are not part of an existing process. + $sql = 'SELECT {course}.id from {course} '. + 'LEFT JOIN {tool_lifecycle_process} '. + 'ON {course}.id = {tool_lifecycle_process}.courseid '. + 'LEFT JOIN {tool_lifecycle_proc_error} pe ON {course}.id = pe.courseid ' . + 'WHERE {tool_lifecycle_process}.courseid is null AND ' . + 'pe.courseid IS NULL AND '; + + if(is_array($where_tmp)) { + //mtrace('Chunked recordset: '.count($where_tmp)/*.' '.print_r($where_tmp, true)*/.', params: '.count($whereparams_tmp)/*.' '.print_r($whereparams_tmp, true)*/); + $tmp = []; + foreach($where_tmp as $chunk_key => $chunk_where_tmp) { + $sql_tmp = $sql.$chunk_where_tmp; + $tmp[] = $DB->get_recordset_sql($sql_tmp, $whereparams_tmp[$chunk_key]); + } + $recordsets[] = $tmp; + } else { + //mtrace('Nomrmal recordset: '.$where_tmp.', params: '.count($whereparams_tmp)/*.' '.print_r($whereparams_tmp, true)*/); + $sql_tmp = $sql.$where_tmp; + $recordsets[] = $DB->get_recordset_sql($sql_tmp, $whereparams_tmp); + } + } + + //use tool_lifecycle\local\intersectedRecordset; + $recordsets = new \tool_lifecycle\local\intersectedRecordset($recordsets); + //mtrace('Intersected record sets: '.count($recordsets)); } - return $DB->get_recordset_sql($sql, $whereparams); + + //mtrace(''); + //mtrace('FINAL recordsets: '.$recordsets->count()/*.' '.print_r($recordsets, true)*/); + //mtrace(''); + //die(); + + return $recordsets; } /** diff --git a/confirmation.php b/confirmation.php index d212d228..633cea1a 100644 --- a/confirmation.php +++ b/confirmation.php @@ -29,6 +29,7 @@ require_once(__DIR__ . '/../../../config.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::CONFIRMATION)); diff --git a/coursebackups.php b/coursebackups.php index bdd44234..a275da3b 100644 --- a/coursebackups.php +++ b/coursebackups.php @@ -31,6 +31,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::COURSE_BACKUPS)); diff --git a/createworkflowfromexisting.php b/createworkflowfromexisting.php index d98a8f7a..82c226c8 100644 --- a/createworkflowfromexisting.php +++ b/createworkflowfromexisting.php @@ -33,6 +33,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $workflowid = optional_param('wf', null, PARAM_INT); diff --git a/deactivatedworkflows.php b/deactivatedworkflows.php index c27133c1..e45f6d4a 100644 --- a/deactivatedworkflows.php +++ b/deactivatedworkflows.php @@ -32,6 +32,7 @@ use tool_lifecycle\urls; require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::DEACTIVATED_WORKFLOWS)); diff --git a/delayedcourses.php b/delayedcourses.php index 28702872..8a589950 100644 --- a/delayedcourses.php +++ b/delayedcourses.php @@ -32,6 +32,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::DELAYED_COURSES)); diff --git a/editelement.php b/editelement.php index 807a914a..ad63d8ff 100644 --- a/editelement.php +++ b/editelement.php @@ -39,6 +39,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $type = required_param('type', PARAM_ALPHA); $elementid = optional_param('elementid', null, PARAM_INT); diff --git a/editworkflow.php b/editworkflow.php index 4a030357..da361db0 100644 --- a/editworkflow.php +++ b/editworkflow.php @@ -33,6 +33,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_context($syscontext); diff --git a/errors.php b/errors.php index dff2ffe6..93857e4d 100644 --- a/errors.php +++ b/errors.php @@ -32,6 +32,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::PROCESS_ERRORS)); diff --git a/step/adminapprove/index.php b/step/adminapprove/index.php index 86e85634..5383b8e1 100644 --- a/step/adminapprove/index.php +++ b/step/adminapprove/index.php @@ -28,6 +28,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $PAGE->set_context(context_system::instance()); $PAGE->set_url(new \moodle_url("/admin/tool/lifecycle/step/adminapprove/index.php")); diff --git a/uploadworkflow.php b/uploadworkflow.php index 45e1ba01..df375a70 100644 --- a/uploadworkflow.php +++ b/uploadworkflow.php @@ -33,6 +33,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::UPLOAD_WORKFLOW)); diff --git a/version.php b/version.php index 3f06f144..4b6e2dd4 100644 --- a/version.php +++ b/version.php @@ -25,8 +25,8 @@ defined('MOODLE_INTERNAL') || die; $plugin->maturity = MATURITY_STABLE; -$plugin->version = 2025050403; +$plugin->version = 2025050404; $plugin->component = 'tool_lifecycle'; $plugin->requires = 2022112800; // Requires Moodle 4.1+. $plugin->supported = [401, 405]; -$plugin->release = 'v4.5-r4'; +$plugin->release = 'v4.5-r5'; diff --git a/workflowdrafts.php b/workflowdrafts.php index 9657d4e6..d419885b 100644 --- a/workflowdrafts.php +++ b/workflowdrafts.php @@ -31,6 +31,7 @@ require_once($CFG->libdir . '/adminlib.php'); require_login(); +require_capability('moodle/site:config', context_system::instance()); $syscontext = context_system::instance(); $PAGE->set_url(new \moodle_url(urls::WORKFLOW_DRAFTS)); diff --git a/workflowoverview.php b/workflowoverview.php index 81278f3e..8680cb1f 100644 --- a/workflowoverview.php +++ b/workflowoverview.php @@ -49,6 +49,7 @@ use tool_lifecycle\urls; require_login(); +require_capability('moodle/site:config', context_system::instance()); $workflowid = required_param('wf', PARAM_INT); $stepid = optional_param('step', null, PARAM_INT);