diff --git a/include/class.auth.php b/include/class.auth.php index eb7b740923cccb67ecaf32a4c930bf37259465b9..e320a4f5ec384bfa16618c155d952973b4397c10 100644 --- a/include/class.auth.php +++ b/include/class.auth.php @@ -212,7 +212,7 @@ abstract class AuthenticationBackend { $bk->audit($result, $credentials); } - static function process($username, $password=null, &$errors) { + static function process($username, $password=null, &$errors=array()) { if (!$username) return false; diff --git a/include/class.import.php b/include/class.import.php index 3e67c394a27106dd60b54bd647048e8089edc0bb..c128136b7439845714435a503f89ed131609e0bd 100644 --- a/include/class.import.php +++ b/include/class.import.php @@ -16,6 +16,7 @@ **********************************************************************/ class ImportError extends Exception {} +class ImportDataError extends ImportError {} class CsvImporter { var $stream; @@ -42,16 +43,16 @@ class CsvImporter { } } - function importCsv($forms=array(), $defaults=array()) { - $named_fields = $all_fields = array(); - foreach ($forms as $F) - foreach ($F->getFields() as $field) - $all_fields[] = $field; + function __destruct() { + fclose($this->stream); + } + function importCsv($all_fields=array(), $defaults=array()) { + $named_fields = array(); $has_header = true; foreach ($all_fields as $f) if ($f->get('name')) - $named_fields[] = $f; + $named_fields[$f->get('name')] = $f; // Read the first row and see if it is a header or not if (!($data = fgetcsv($this->stream, 1000, ","))) @@ -91,15 +92,14 @@ class CsvImporter { if (!$has_header) fseek($this->stream, 0); - $objects = $fields = $keys = array(); - foreach ($forms as $F) { - foreach ($headers as $h => $label) { - if (!($f = $F->getField($h))) - continue; + $objects = $fields = array(); + foreach ($headers as $h => $label) { + if (!isset($named_fields[$h])) + continue; - $name = $keys[] = $f->get('name'); - $fields[$name] = $f->getImpl(); - } + $f = $named_fields[$h]; + $name = $f->get('name'); + $fields[$name] = $f; } // Add default fields (org_id, etc). @@ -107,35 +107,87 @@ class CsvImporter { // Don't apply defaults which are also being imported if (isset($header[$key])) unset($defaults[$key]); - $keys[] = $key; } - while (($data = fgetcsv($this->stream, 4096, ",")) !== false) { - if (count($data) == 1 && $data[0] == null) + // Avoid reading the entire CSV before yielding the first record. + // Use an iterator. This will also allow row-level errors to be + // continuable such that the rows with errors can be handled and the + // iterator can continue with the next row. + return new CsvImportIterator($this->stream, $headers, $fields, $defaults); + } +} + +class CsvImportIterator +implements Iterator { + var $stream; + var $start = 0; + var $headers; + var $fields; + var $defaults; + + var $current = true; + var $row = 0; + + function __construct($stream, $headers, $fields, $defaults) { + $this->stream = $stream; + $this->start = ftell($stream); + $this->headers = $headers; + $this->fields = $fields; + $this->defaults = $defaults; + } + + // Iterator interface ------------------------------------- + function rewind() { + @fseek($this->stream, $this->start); + if (ftell($this->stream) != $this->start) + throw new RuntimeException('Stream cannot be rewound'); + $this->row = 0; + $this->next(); + } + function valid() { + return $this->current; + } + function current() { + return $this->current; + } + function key() { + return $this->row; + } + + function next() { + do { + if (($csv = fgetcsv($this->stream, 4096, ",")) === false) { + // Read error + $this->current = false; + break; + } + + if (count($csv) == 1 && $csv[0] == null) // Skip empty rows continue; - elseif (count($data) != count($headers)) - throw new ImportError(sprintf(__('Bad data. Expected: %s'), implode(', ', $headers))); + elseif (count($csv) != count($this->headers)) + throw new ImportDataError(sprintf(__('Bad data. Expected: %s'), + implode(', ', $this->headers))); + // Validate according to field configuration $i = 0; - foreach ($headers as $h => $label) { - $f = $fields[$h]; - $T = $f->parse($data[$i]); + $this->current = $this->defaults; + foreach ($this->headers as $h => $label) { + $f = $this->fields[$h]; + $f->_errors = array(); + $T = $f->parse($csv[$i]); if ($f->validateEntry($T) && $f->errors()) - throw new ImportError(sprintf(__( + throw new ImportDataError(sprintf(__( /* 1 will be a field label, and 2 will be error messages */ '%1$s: Invalid data: %2$s'), $label, implode(', ', $f->errors()))); // Convert to database format - $data[$h] = $data[$i++] = $f->to_database($T); + $this->current[$h] = $f->to_database($T); + $i++; } - // Add default fields - foreach ($defaults as $key => $val) - $data[$key] = $data[$i++] = $val; - - $objects[] = $data; } - - return $objects; + // Use the do-loop only for the empty line skipping + while (false); + $this->row++; } } diff --git a/include/class.staff.php b/include/class.staff.php index a40ea046ad140b8ee631e8085ce3e5c0333d3d3a..ed93f6441f7115cda215f6555a6544e39c989c48 100644 --- a/include/class.staff.php +++ b/include/class.staff.php @@ -831,6 +831,66 @@ implements AuthenticatedUser, EmailContact, TemplateVariable { $msg['body']); } + static function importCsv($stream, $defaults=array(), $callback=false) { + require_once INCLUDE_DIR . 'class.import.php'; + + $importer = new CsvImporter($stream); + $imported = 0; + $fields = array( + 'firstname' => new TextboxField(array( + 'label' => __('First name'), + )), + 'lastname' => new TextboxField(array( + 'label' => __('Last name'), + )), + 'email' => new TextboxField(array( + 'label' => __('Email Address'), + 'configuration' => array( + 'validator' => 'email', + ), + )), + 'username' => new TextboxField(array( + 'label' => __('Username'), + 'validators' => function($self, $value) { + if (!Validator::is_username($value)) + $self->addError('Not a valid username'); + }, + )), + ); + $form = new SimpleForm($fields); + + try { + db_autocommit(false); + $records = $importer->importCsv($form->getFields(), $defaults); + foreach ($records as $data) { + if (!isset($data['email']) || !isset($data['username'])) + throw new ImportError('Both `username` and `email` fields are required'); + + if ($agent = self::lookup(array('username' => $data['username']))) { + // TODO: Update the user + } + elseif ($agent = self::create($data, $errors)) { + if ($callback) + $callback($agent, $data); + $agent->save(); + } + else { + throw new ImportError(sprintf(__('Unable to import (%s): %s'), + $data['username'], + print_r($errors, true) + )); + } + $imported++; + } + db_autocommit(true); + } + catch (Exception $ex) { + db_rollback(); + return $ex->getMessage(); + } + return $imported; + } + function save($refetch=false) { if ($this->dirty) $this->updated = SqlFunction::NOW(); diff --git a/include/class.user.php b/include/class.user.php index c1c7255f6c250a81c9fd05d881b4d04b767777dc..16279f76eb834247940fc8f96f2e7807af497f64 100644 --- a/include/class.user.php +++ b/include/class.user.php @@ -462,13 +462,13 @@ implements TemplateVariable { $imported = 0; try { db_autocommit(false); - $records = $importer->importCsv(array(UserForm::getUserForm()), $defaults); + $records = $importer->importCsv(UserForm::getUserForm()->getFields(), $defaults); foreach ($records as $data) { if (!isset($data['email']) || !isset($data['name'])) throw new ImportError('Both `name` and `email` fields are required'); if (!($user = static::fromVars($data, true, true))) throw new ImportError(sprintf(__('Unable to import user: %s'), - print_r($vars, true))); + print_r($data, true))); $imported++; } db_autocommit(true); diff --git a/include/cli/modules/agent.php b/include/cli/modules/agent.php new file mode 100644 index 0000000000000000000000000000000000000000..7a55a351d6677a747e57e6f1b05d8f9e4d88978f --- /dev/null +++ b/include/cli/modules/agent.php @@ -0,0 +1,185 @@ +<?php + +class AgentManager extends Module { + var $prologue = 'CLI agent manager'; + + var $arguments = array( + 'action' => array( + 'help' => 'Action to be performed', + 'options' => array( + 'import' => 'Import agents from CSV file', + 'export' => 'Export agents from the system to CSV', + 'list' => 'List agents based on search criteria', + 'login' => 'Attempt login as an agent', + 'backends' => 'List agent authentication backends', + ), + ), + ); + + var $options = array( + 'file' => array('-f', '--file', 'metavar'=>'path', + 'help' => 'File or stream to process'), + 'verbose' => array('-v', '--verbose', 'default'=>false, + 'action'=>'store_true', 'help' => 'Be more verbose'), + + 'welcome' => array('-w', '--welcome', 'default'=>false, + 'action'=>'store_true', 'help'=>'Send a welcome email on import'), + + 'backend' => array('', '--backend', + 'help'=>'Specify the authentication backend (used with `login` and `import`)'), + + // -- Search criteria + 'username' => array('-U', '--username', + 'help' => 'Search by username'), + 'email' => array('-E', '--email', + 'help' => 'Search by email address'), + 'id' => array('-U', '--id', + 'help' => 'Search by user id'), + 'dept' => array('-D', '--dept', 'help' => 'Search by access to department name or id'), + 'team' => array('-T', '--team', 'help' => 'Search by membership in team name or id'), + ); + + var $stream; + + function run($args, $options) { + global $ost, $cfg; + + Bootstrap::connect(); + + if (!($ost=osTicket::start()) || !($cfg = $ost->getConfig())) + $this->fail('Unable to load config info!'); + + switch ($args['action']) { + case 'import': + // Properly detect Macintosh style line endings + ini_set('auto_detect_line_endings', true); + + if (!$options['file'] || $options['file'] == '-') + $options['file'] = 'php://stdin'; + if (!($this->stream = fopen($options['file'], 'rb'))) + $this->fail("Unable to open input file [{$options['file']}]"); + + // Defaults + $extras = array( + 'isadmin' => 0, + 'isactive' => 1, + 'isvisible' => 1, + 'dept_id' => $cfg->getDefaultDeptId(), + 'timezone' => $cfg->getDefaultTimezone(), + 'welcome_email' => $options['welcome'], + ); + + if ($options['backend']) + $extras['backend'] = $options['backend']; + + $stderr = $this->stderr; + $status = Staff::importCsv($this->stream, $extras, + function ($agent, $data) use ($stderr, $options) { + if (!$options['verbose']) + return; + $stderr->write( + sprintf("\n%s - %s --- imported!", + $agent->getName(), + $agent->getUsername())); + } + ); + if (is_numeric($status)) + $this->stderr->write("Successfully processed $status agents\n"); + else + $this->fail($status); + break; + + case 'export': + $stream = $options['file'] ?: 'php://stdout'; + if (!($this->stream = fopen($stream, 'c'))) + $this->fail("Unable to open output file [{$options['file']}]"); + + fputcsv($this->stream, array('First Name', 'Last Name', 'Email', 'UserName')); + foreach ($this->getAgents($options) as $agent) + fputcsv($this->stream, array( + $agent->getFirstName(), + $agent->getLastName(), + $agent->getEmail(), + $agent->getUserName(), + )); + break; + + case 'list': + $agents = $this->getAgents($options); + foreach ($agents as $A) { + $this->stdout->write(sprintf( + "%d \t - %s\t<%s>\n", + $A->staff_id, $A->getName(), $A->getEmail())); + } + break; + + case 'login': + $this->stderr->write('Username: '); + $username = trim(fgets(STDIN)); + $this->stderr->write('Password: '); + $password = trim(fgets(STDIN)); + + $agent = null; + foreach (StaffAuthenticationBackend::allRegistered() as $id=>$bk) { + if ((!$options['backend'] || $options['backend'] == $id) + && $bk->supportsInteractiveAuthentication() + && ($agent = $bk->authenticate($username, $password)) + && $agent instanceof AuthenticatedUser + ) { + break; + } + } + + if ($agent instanceof Staff) { + $this->stdout->write(sprintf("Successfully authenticated as '%s', using '%s'\n", + (string) $agent->getName(), + $bk->getName() + )); + } + else { + $this->stdout->write('Authentication failed'); + } + break; + + case 'backends': + foreach (StaffAuthenticationBackend::allRegistered() as $name=>$bk) { + if (!$bk->supportsInteractiveAuthentication()) + continue; + $this->stdout->write(sprintf("%s\t%s\n", + $name, $bk->getName() + )); + } + break; + + default: + $this->fail($args['action'].': Unknown action!'); + } + @fclose($this->stream); + } + + function getAgents($options, $requireOne=false) { + $agents = Staff::objects(); + + if ($options['email']) + $agents->filter(array('email__contains' => $options['email'])); + if ($options['username']) + $agents->filter(array('username__contains' => $options['username'])); + if ($options['id']) + $agents->filter(array('staff_id' => $options['id'])); + if ($options['dept']) + $agents->filter(Q::any(array( + 'dept_id' => $options['dept'], + 'dept__name__contains' => $options['dept'], + 'dept_access__dept_id' => $options['dept'], + 'dept_access__dept__name__contains' => $options['dept'], + ))); + if ($options['team']) + $agents->filter(Q::any(array( + 'teams__team_id' => $options['team'], + 'teams__team__name__contains' => $options['team'], + ))); + + return $agents->distinct('staff_id'); + } +} +Module::register('agent', 'AgentManager');