1<?php
2// This file is part of Moodle - http://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 <http://www.gnu.org/licenses/>.
16
17/**
18 * Class cli_helper
19 *
20 * @package     tool_uploaduser
21 * @copyright   2020 Marina Glancy
22 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace tool_uploaduser;
26
27defined('MOODLE_INTERNAL') || die();
28
29use tool_uploaduser\local\cli_progress_tracker;
30
31require_once($CFG->dirroot.'/user/profile/lib.php');
32require_once($CFG->dirroot.'/user/lib.php');
33require_once($CFG->dirroot.'/group/lib.php');
34require_once($CFG->dirroot.'/cohort/lib.php');
35require_once($CFG->libdir.'/csvlib.class.php');
36require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/locallib.php');
37require_once($CFG->dirroot.'/'.$CFG->admin.'/tool/uploaduser/user_form.php');
38require_once($CFG->libdir . '/clilib.php');
39
40/**
41 * Helper method for CLI script to upload users (also has special wrappers for cli* functions for phpunit testing)
42 *
43 * @package     tool_uploaduser
44 * @copyright   2020 Marina Glancy
45 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
46 */
47class cli_helper {
48
49    /** @var string */
50    protected $operation;
51    /** @var array */
52    protected $clioptions;
53    /** @var array */
54    protected $unrecognized;
55    /** @var string */
56    protected $progresstrackerclass;
57
58    /** @var process */
59    protected $process;
60
61    /**
62     * cli_helper constructor.
63     *
64     * @param string|null $progresstrackerclass
65     */
66    public function __construct(?string $progresstrackerclass = null) {
67        $this->progresstrackerclass = $progresstrackerclass ?? cli_progress_tracker::class;
68        $optionsdefinitions = $this->options_definitions();
69        $longoptions = [];
70        $shortmapping = [];
71        foreach ($optionsdefinitions as $key => $option) {
72            $longoptions[$key] = $option['default'];
73            if (!empty($option['alias'])) {
74                $shortmapping[$option['alias']] = $key;
75            }
76        }
77
78        list($this->clioptions, $this->unrecognized) = cli_get_params(
79            $longoptions,
80            $shortmapping
81        );
82    }
83
84    /**
85     * Options used in this CLI script
86     *
87     * @return array
88     */
89    protected function options_definitions(): array {
90        $options = [
91            'help' => [
92                'hasvalue' => false,
93                'description' => get_string('clihelp', 'tool_uploaduser'),
94                'default' => 0,
95                'alias' => 'h',
96            ],
97            'file' => [
98                'hasvalue' => 'PATH',
99                'description' => get_string('clifile', 'tool_uploaduser'),
100                'default' => null,
101                'validation' => function($file) {
102                    if (!$file) {
103                        $this->cli_error(get_string('climissingargument', 'tool_uploaduser', 'file'));
104                    }
105                    if ($file && (!file_exists($file) || !is_readable($file))) {
106                        $this->cli_error(get_string('clifilenotreadable', 'tool_uploaduser', $file));
107                    }
108                }
109            ],
110        ];
111        $form = new \admin_uploaduser_form1();
112        [$elements, $defaults] = $form->get_form_for_cli();
113        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
114        $form = new \admin_uploaduser_form2(null, ['columns' => ['type1'], 'data' => []]);
115        [$elements, $defaults] = $form->get_form_for_cli();
116        $options += $this->prepare_form_elements_for_cli($elements, $defaults);
117        return $options;
118    }
119
120    /**
121     * Print help for export
122     */
123    public function print_help(): void {
124        $this->cli_writeln(get_string('clititle', 'tool_uploaduser'));
125        $this->cli_writeln('');
126        $this->print_help_options($this->options_definitions());
127        $this->cli_writeln('');
128        $this->cli_writeln('Example:');
129        $this->cli_writeln('$sudo -u www-data /usr/bin/php admin/tool/uploaduser/cli/uploaduser.php --file=PATH');
130    }
131
132    /**
133     * Get CLI option
134     *
135     * @param string $key
136     * @return mixed|null
137     */
138    public function get_cli_option(string $key) {
139        return $this->clioptions[$key] ?? null;
140    }
141
142    /**
143     * Write a text to the given stream
144     *
145     * @param string $text text to be written
146     */
147    protected function cli_write($text): void {
148        if (PHPUNIT_TEST) {
149            echo $text;
150        } else {
151            cli_write($text);
152        }
153    }
154
155    /**
156     * Write error notification
157     * @param string $text
158     * @return void
159     */
160    protected function cli_problem($text): void {
161        if (PHPUNIT_TEST) {
162            echo $text;
163        } else {
164            cli_problem($text);
165        }
166    }
167
168    /**
169     * Write a text followed by an end of line symbol to the given stream
170     *
171     * @param string $text text to be written
172     */
173    protected function cli_writeln($text): void {
174        $this->cli_write($text . PHP_EOL);
175    }
176
177    /**
178     * Write to standard error output and exit with the given code
179     *
180     * @param string $text
181     * @param int $errorcode
182     * @return void (does not return)
183     */
184    protected function cli_error($text, $errorcode = 1): void {
185        $this->cli_problem($text);
186        $this->die($errorcode);
187    }
188
189    /**
190     * Wrapper for "die()" method so we can unittest it
191     *
192     * @param mixed $errorcode
193     * @throws \moodle_exception
194     */
195    protected function die($errorcode): void {
196        if (!PHPUNIT_TEST) {
197            die($errorcode);
198        } else {
199            throw new \moodle_exception('CLI script finished with error code '.$errorcode);
200        }
201    }
202
203    /**
204     * Display as CLI table
205     *
206     * @param array $column1
207     * @param array $column2
208     * @param int $indent
209     * @return string
210     */
211    protected function convert_to_table(array $column1, array $column2, int $indent = 0): string {
212        $maxlengthleft = 0;
213        $left = [];
214        $column1 = array_values($column1);
215        $column2 = array_values($column2);
216        foreach ($column1 as $i => $l) {
217            $left[$i] = str_repeat(' ', $indent) . $l;
218            if (strlen('' . $column2[$i])) {
219                $maxlengthleft = max($maxlengthleft, strlen($l) + $indent);
220            }
221        }
222        $maxlengthright = 80 - $maxlengthleft - 1;
223        $output = '';
224        foreach ($column2 as $i => $r) {
225            if (!strlen('' . $r)) {
226                $output .= $left[$i] . "\n";
227                continue;
228            }
229            $right = wordwrap($r, $maxlengthright, "\n");
230            $output .= str_pad($left[$i], $maxlengthleft) . ' ' .
231                str_replace("\n", PHP_EOL . str_repeat(' ', $maxlengthleft + 1), $right) . PHP_EOL;
232        }
233        return $output;
234    }
235
236    /**
237     * Display available CLI options as a table
238     *
239     * @param array $options
240     */
241    protected function print_help_options(array $options): void {
242        $left = [];
243        $right = [];
244        foreach ($options as $key => $option) {
245            if ($option['hasvalue'] !== false) {
246                $l = "--$key={$option['hasvalue']}";
247            } else if (!empty($option['alias'])) {
248                $l = "-{$option['alias']}, --$key";
249            } else {
250                $l = "--$key";
251            }
252            $left[] = $l;
253            $right[] = $option['description'];
254        }
255        $this->cli_write('Options:' . PHP_EOL . $this->convert_to_table($left, $right));
256    }
257
258    /**
259     * Process the upload
260     */
261    public function process(): void {
262        // First, validate all arguments.
263        $definitions = $this->options_definitions();
264        foreach ($this->clioptions as $key => $value) {
265            if ($validator = $definitions[$key]['validation'] ?? null) {
266                $validator($value);
267            }
268        }
269
270        // Read the CSV file.
271        $iid = \csv_import_reader::get_new_iid('uploaduser');
272        $cir = new \csv_import_reader($iid, 'uploaduser');
273        $cir->load_csv_content(file_get_contents($this->get_cli_option('file')),
274            $this->get_cli_option('encoding'), $this->get_cli_option('delimiter_name'));
275        $csvloaderror = $cir->get_error();
276
277        if (!is_null($csvloaderror)) {
278            $this->cli_error(get_string('csvloaderror', 'error', $csvloaderror), 1);
279        }
280
281        // Start upload user process.
282        $this->process = new \tool_uploaduser\process($cir, $this->progresstrackerclass);
283        $filecolumns = $this->process->get_file_columns();
284
285        $form = $this->mock_form(['columns' => $filecolumns, 'data' => ['iid' => $iid, 'previewrows' => 1]], $this->clioptions);
286
287        if (!$form->is_validated()) {
288            $errors = $form->get_validation_errors();
289            $this->cli_error(get_string('clivalidationerror', 'tool_uploaduser') . PHP_EOL .
290                $this->convert_to_table(array_keys($errors), array_values($errors), 2));
291        }
292
293        $this->process->set_form_data($form->get_data());
294        $this->process->process();
295    }
296
297    /**
298     * Mock form submission
299     *
300     * @param array $customdata
301     * @param array $submitteddata
302     * @return \admin_uploaduser_form2
303     */
304    protected function mock_form(array $customdata, array $submitteddata): \admin_uploaduser_form2 {
305        global $USER;
306        $submitteddata['description'] = ['text' => $submitteddata['description'], 'format' => FORMAT_HTML];
307
308        // Now mock the form submission.
309        $submitteddata['_qf__admin_uploaduser_form2'] = 1;
310        $oldignoresesskey = $USER->ignoresesskey ?? null;
311        $USER->ignoresesskey = true;
312        $form = new \admin_uploaduser_form2(null, $customdata, 'post', '', [], true, $submitteddata);
313        $USER->ignoresesskey = $oldignoresesskey;
314
315        $form->set_data($submitteddata);
316        return $form;
317    }
318
319    /**
320     * Prepare form elements for CLI
321     *
322     * @param \HTML_QuickForm_element[] $elements
323     * @param array $defaults
324     * @return array
325     */
326    protected function prepare_form_elements_for_cli(array $elements, array $defaults): array {
327        $options = [];
328        foreach ($elements as $element) {
329            if ($element instanceof \HTML_QuickForm_submit || $element instanceof \HTML_QuickForm_static) {
330                continue;
331            }
332            $type = $element->getType();
333            if ($type === 'html' || $type === 'hidden' || $type === 'header') {
334                continue;
335            }
336
337            $name = $element->getName();
338            if ($name === null || preg_match('/^mform_isexpanded_/', $name)
339                || preg_match('/^_qf__/', $name)) {
340                continue;
341            }
342
343            $label = $element->getLabel();
344            if (!strlen($label) && method_exists($element, 'getText')) {
345                $label = $element->getText();
346            }
347            $default = $defaults[$element->getName()] ?? null;
348
349            $postfix = '';
350            $possiblevalues = null;
351            if ($element instanceof \HTML_QuickForm_select) {
352                $selectoptions = $element->_options;
353                $possiblevalues = [];
354                foreach ($selectoptions as $option) {
355                    $possiblevalues[] = '' . $option['attr']['value'];
356                }
357                if (count($selectoptions) < 10) {
358                    $postfix .= ':';
359                    foreach ($selectoptions as $option) {
360                        $postfix .= "\n  ".$option['attr']['value']." - ".$option['text'];
361                    }
362                }
363                if (!array_key_exists($name, $defaults)) {
364                    $firstoption = reset($selectoptions);
365                    $default = $firstoption['attr']['value'];
366                }
367            }
368
369            if ($element instanceof \HTML_QuickForm_checkbox) {
370                $postfix = ":\n  0|1";
371                $possiblevalues = ['0', '1'];
372            }
373
374            if ($default !== null & $default !== '') {
375                $postfix .= "\n  ".get_string('clidefault', 'tool_uploaduser')." ".$default;
376            }
377            $options[$name] = [
378                'hasvalue' => 'VALUE',
379                'description' => $label.$postfix,
380                'default' => $default,
381            ];
382            if ($possiblevalues !== null) {
383                $options[$name]['validation'] = function($v) use ($possiblevalues, $name) {
384                    if (!in_array('' . $v, $possiblevalues)) {
385                        $this->cli_error(get_string('clierrorargument', 'tool_uploaduser',
386                            (object)['name' => $name, 'values' => join(', ', $possiblevalues)]));
387                    }
388                };
389            }
390        }
391        return $options;
392    }
393
394    /**
395     * Get process statistics.
396     *
397     * @return array
398     */
399    public function get_stats(): array {
400        return $this->process->get_stats();
401    }
402}
403