Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
101 / 101 |
|
100.00% |
12 / 12 |
CRAP | |
100.00% |
1 / 1 |
| workflow_store | |
100.00% |
101 / 101 |
|
100.00% |
12 / 12 |
50 | |
100.00% |
1 / 1 |
| save | |
100.00% |
27 / 27 |
|
100.00% |
1 / 1 |
6 | |||
| get | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| delete | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| workflows_for_event | |
100.00% |
14 / 14 |
|
100.00% |
1 / 1 |
3 | |||
| scheduled | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| check_run_as | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
8 | |||
| forget_event_map | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| trigger_settings | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
5 | |||
| errors | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
11 | |||
| missing_params | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
7 | |||
| event_name | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| is_event | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | // This file is part of Moodle - https://moodle.org/ |
| 3 | // |
| 4 | // Moodle is free software: you can redistribute it and/or modify |
| 5 | // it under the terms of the GNU General Public License as published by |
| 6 | // the Free Software Foundation, either version 3 of the License, or |
| 7 | // (at your option) any later version. |
| 8 | // |
| 9 | // Moodle is distributed in the hope that it will be useful, |
| 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 12 | // GNU General Public License for more details. |
| 13 | // |
| 14 | // You should have received a copy of the GNU General Public License |
| 15 | // along with Moodle. If not, see <https://www.gnu.org/licenses/>. |
| 16 | |
| 17 | namespace tool_autoflow\local; |
| 18 | |
| 19 | use tool_autoflow\local\engine\cron_expression; |
| 20 | use tool_autoflow\local\engine\graph; |
| 21 | use tool_autoflow\local\engine\invalid_graph; |
| 22 | use tool_autoflow\local\engine\node_type; |
| 23 | |
| 24 | /** |
| 25 | * Saves and loads workflows. |
| 26 | * |
| 27 | * The trigger type and its setting (event or cron) are copied out of the graph |
| 28 | * on save, so the event observer and the schedule task never parse graphs. |
| 29 | * |
| 30 | * A workflow runs as the user who saved it last, unless the person saving picks |
| 31 | * someone else. Picking another user needs moodle/user:loginas, the permission |
| 32 | * Moodle already uses for acting as someone, and picking a site administrator |
| 33 | * needs a site administrator. So a manager who edits an admin's workflow brings it |
| 34 | * down to their own permissions, and can never raise a workflow above them. |
| 35 | * |
| 36 | * @package tool_autoflow |
| 37 | * @copyright 2026 DevLMS <contact@devlms.com> |
| 38 | * @license https://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later |
| 39 | */ |
| 40 | final class workflow_store { |
| 41 | /** Cache key holding event class name to workflow ids. */ |
| 42 | private const EVENTMAP = 'map'; |
| 43 | |
| 44 | /** |
| 45 | * Save a workflow. An invalid graph is kept as a disabled draft. |
| 46 | * |
| 47 | * @param int $id 0 for a new workflow. |
| 48 | * @param string $name |
| 49 | * @param string $graphjson |
| 50 | * @param bool $enabled What the user asked for. Ignored if the graph has errors. |
| 51 | * @param int $runasuserid User the workflow runs as; 0 for the person saving. |
| 52 | * @return array id, enabled, runasuserid and errors (each with code and node ids). |
| 53 | */ |
| 54 | public static function save(int $id, string $name, string $graphjson, bool $enabled, int $runasuserid = 0): array { |
| 55 | global $DB, $USER; |
| 56 | $name = trim($name); |
| 57 | if ($name === '') { |
| 58 | throw new \invalid_parameter_exception('A workflow needs a name.'); |
| 59 | } |
| 60 | $runasuserid = self::check_run_as($runasuserid ?: (int) $USER->id); |
| 61 | try { |
| 62 | $graph = graph::from_json($graphjson); |
| 63 | } catch (invalid_graph $e) { |
| 64 | throw new \invalid_parameter_exception($e->getMessage()); |
| 65 | } |
| 66 | [$triggertype, $triggerdata] = self::trigger_settings($graph); |
| 67 | $errors = self::errors($graph, $triggertype, $triggerdata); |
| 68 | $enabled = $enabled && !$errors; |
| 69 | |
| 70 | $record = (object) [ |
| 71 | 'name' => $name, |
| 72 | 'graph' => $graphjson, |
| 73 | 'enabled' => (int) $enabled, |
| 74 | 'triggertype' => $triggertype, |
| 75 | 'triggerdata' => $triggerdata, |
| 76 | 'usermodified' => $USER->id, |
| 77 | 'runasuserid' => $runasuserid, |
| 78 | 'timemodified' => time(), |
| 79 | ]; |
| 80 | if ($id) { |
| 81 | $record->id = $DB->get_field('tool_autoflow_workflow', 'id', ['id' => $id], MUST_EXIST); |
| 82 | $DB->update_record('tool_autoflow_workflow', $record); |
| 83 | } else { |
| 84 | $record->timecreated = $record->timemodified; |
| 85 | $id = (int) $DB->insert_record('tool_autoflow_workflow', $record); |
| 86 | } |
| 87 | self::forget_event_map(); |
| 88 | return ['id' => (int) $id, 'enabled' => $enabled, 'runasuserid' => $runasuserid, 'errors' => $errors]; |
| 89 | } |
| 90 | |
| 91 | /** |
| 92 | * Load a workflow. |
| 93 | * |
| 94 | * @param int $id |
| 95 | * @return \stdClass |
| 96 | */ |
| 97 | public static function get(int $id): \stdClass { |
| 98 | global $DB; |
| 99 | return $DB->get_record('tool_autoflow_workflow', ['id' => $id], '*', MUST_EXIST); |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Delete a workflow and its run history. |
| 104 | * |
| 105 | * @param int $id |
| 106 | */ |
| 107 | public static function delete(int $id): void { |
| 108 | global $DB; |
| 109 | $DB->delete_records('tool_autoflow_run', ['workflowid' => $id]); |
| 110 | $DB->delete_records('tool_autoflow_workflow', ['id' => $id]); |
| 111 | self::forget_event_map(); |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Ids of enabled workflows started by an event. Cached; this runs on every event. |
| 116 | * |
| 117 | * @param string $eventname Event class name with a leading backslash. |
| 118 | * @return int[] |
| 119 | */ |
| 120 | public static function workflows_for_event(string $eventname): array { |
| 121 | global $DB; |
| 122 | $cache = \cache::make('tool_autoflow', 'eventmap'); |
| 123 | $map = $cache->get(self::EVENTMAP); |
| 124 | if ($map === false) { |
| 125 | $map = []; |
| 126 | $rows = $DB->get_records( |
| 127 | 'tool_autoflow_workflow', |
| 128 | ['enabled' => 1, 'triggertype' => 'trigger.event'], |
| 129 | 'id', |
| 130 | 'id, triggerdata' |
| 131 | ); |
| 132 | foreach ($rows as $row) { |
| 133 | $map[$row->triggerdata][] = (int) $row->id; |
| 134 | } |
| 135 | $cache->set(self::EVENTMAP, $map); |
| 136 | } |
| 137 | return $map[$eventname] ?? []; |
| 138 | } |
| 139 | |
| 140 | /** |
| 141 | * Enabled workflows started by a schedule. |
| 142 | * |
| 143 | * @return \stdClass[] Keyed by id, with id, triggerdata and lastscheduled. |
| 144 | */ |
| 145 | public static function scheduled(): array { |
| 146 | global $DB; |
| 147 | return $DB->get_records( |
| 148 | 'tool_autoflow_workflow', |
| 149 | ['enabled' => 1, 'triggertype' => 'trigger.schedule'], |
| 150 | 'id', |
| 151 | 'id, triggerdata, lastscheduled' |
| 152 | ); |
| 153 | } |
| 154 | |
| 155 | /** |
| 156 | * Whether the current user may make a workflow run as this user. |
| 157 | * |
| 158 | * @param int $userid |
| 159 | * @return int The user id. |
| 160 | */ |
| 161 | private static function check_run_as(int $userid): int { |
| 162 | global $USER; |
| 163 | $user = \core_user::get_user($userid); |
| 164 | if (!$user || $user->deleted || $user->suspended || isguestuser($user)) { |
| 165 | throw new \invalid_parameter_exception("No active user with id $userid."); |
| 166 | } |
| 167 | if ((int) $user->id === (int) $USER->id) { |
| 168 | return $userid; |
| 169 | } |
| 170 | require_capability('moodle/user:loginas', \context_system::instance()); |
| 171 | if (is_siteadmin($user) && !is_siteadmin($USER)) { |
| 172 | throw new \invalid_parameter_exception('Only a site administrator can make a workflow run as a site administrator.'); |
| 173 | } |
| 174 | return $userid; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * Drop the cached event map after any change. |
| 179 | */ |
| 180 | private static function forget_event_map(): void { |
| 181 | \cache::make('tool_autoflow', 'eventmap')->delete(self::EVENTMAP); |
| 182 | } |
| 183 | |
| 184 | /** |
| 185 | * The trigger type and its setting, if the graph has exactly one trigger. |
| 186 | * |
| 187 | * @param graph $graph |
| 188 | * @return string[] Type and setting, empty strings when there is no single trigger. |
| 189 | */ |
| 190 | private static function trigger_settings(graph $graph): array { |
| 191 | try { |
| 192 | $trigger = $graph->trigger(); |
| 193 | } catch (invalid_graph $e) { |
| 194 | return ['', '']; |
| 195 | } |
| 196 | $params = $trigger['data']['params'] ?? []; |
| 197 | $setting = match ($trigger['type']) { |
| 198 | 'trigger.schedule' => trim((string) ($params['cron'] ?? '')), |
| 199 | 'trigger.event' => self::event_name((string) ($params['eventname'] ?? '')), |
| 200 | default => '', |
| 201 | }; |
| 202 | return [$trigger['type'], $setting]; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Everything that stops the workflow from running. |
| 207 | * |
| 208 | * @param graph $graph |
| 209 | * @param string $triggertype |
| 210 | * @param string $triggerdata |
| 211 | * @return array[] |
| 212 | */ |
| 213 | private static function errors(graph $graph, string $triggertype, string $triggerdata): array { |
| 214 | $errors = $graph->errors(); |
| 215 | $registry = node_catalogue::registry(); |
| 216 | $unknown = []; |
| 217 | $missing = []; |
| 218 | foreach ($graph->nodes() as $node) { |
| 219 | if (!$registry->has($node['type'])) { |
| 220 | $unknown[] = $node['id']; |
| 221 | } else if (!graph::is_trigger($node['type']) && self::missing_params($registry->get($node['type']), $node)) { |
| 222 | $missing[] = $node['id']; |
| 223 | } |
| 224 | } |
| 225 | if ($unknown) { |
| 226 | $errors[] = ['code' => 'unknowntype', 'nodeids' => $unknown]; |
| 227 | } |
| 228 | if ($missing) { |
| 229 | $errors[] = ['code' => 'missingparam', 'nodeids' => $missing]; |
| 230 | } |
| 231 | if ($triggertype === 'trigger.schedule' && !cron_expression::is_valid($triggerdata)) { |
| 232 | $errors[] = ['code' => 'badcron', 'nodeids' => [$graph->trigger()['id']]]; |
| 233 | } |
| 234 | if ($triggertype === 'trigger.event' && !self::is_event($triggerdata)) { |
| 235 | $errors[] = ['code' => 'badevent', 'nodeids' => [$graph->trigger()['id']]]; |
| 236 | } |
| 237 | return $errors; |
| 238 | } |
| 239 | |
| 240 | /** |
| 241 | * Whether a node leaves a required setting blank. Triggers have their own checks. |
| 242 | * |
| 243 | * @param node_type $type |
| 244 | * @param array $node |
| 245 | * @return bool |
| 246 | */ |
| 247 | private static function missing_params(node_type $type, array $node): bool { |
| 248 | $values = $node['data']['params'] ?? []; |
| 249 | foreach ($type->params() as $param) { |
| 250 | $value = $values[$param->name] ?? null; |
| 251 | if ($param->required && ($value === null || (is_string($value) && trim($value) === '') || $value === [])) { |
| 252 | return true; |
| 253 | } |
| 254 | } |
| 255 | return false; |
| 256 | } |
| 257 | |
| 258 | /** |
| 259 | * An event class name in the form Moodle uses for $event->eventname. |
| 260 | * |
| 261 | * @param string $name |
| 262 | * @return string |
| 263 | */ |
| 264 | private static function event_name(string $name): string { |
| 265 | $name = trim($name); |
| 266 | return $name === '' ? '' : '\\' . ltrim($name, '\\'); |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Whether a class is a concrete Moodle event. |
| 271 | * |
| 272 | * @param string $classname |
| 273 | * @return bool |
| 274 | */ |
| 275 | private static function is_event(string $classname): bool { |
| 276 | if ($classname === '' || !class_exists($classname)) { |
| 277 | return false; |
| 278 | } |
| 279 | $class = new \ReflectionClass($classname); |
| 280 | return $class->isSubclassOf(\core\event\base::class) && !$class->isAbstract(); |
| 281 | } |
| 282 | } |