snipe-it/app/Importer/Importer.php
snipe a831a34594 New field mapping
Signed-off-by: snipe <snipe@snipe.net>
2023-04-16 07:47:26 -07:00

541 lines
16 KiB
PHP

<?php
namespace App\Importer;
use App\Models\CustomField;
use App\Models\Department;
use App\Models\Setting;
use App\Models\User;
use ForceUTF8\Encoding;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use League\Csv\Reader;
abstract class Importer
{
protected $csv;
/**
* Id of User performing import
* @var
*/
protected $user_id;
/**
* Are we updating items in the import
* @var bool
*/
protected $updating;
/**
* Default Map of item fields->csv names
* @var array
*/
private $defaultFieldMap = [
'asset_tag' => 'asset tag',
'activated' => 'activated',
'category' => 'category',
'checkout_class' => 'checkout type', // Supports Location or User for assets. Using checkout_class instead of checkout_type because type exists on asset already.
'checkout_location' => 'checkout location',
'company' => 'company',
'item_name' => 'item name',
'item_number' => 'item number',
'image' => 'image',
'expiration_date' => 'expiration date',
'location' => 'location',
'notes' => 'notes',
'license_email' => 'licensed to email',
'license_name' => 'licensed to name',
'maintained' => 'maintained',
'manufacturer' => 'manufacturer',
'asset_model' => 'model name',
'model_number' => 'model number',
'order_number' => 'order number',
'purchase_cost' => 'purchase cost',
'purchase_date' => 'purchase date',
'purchase_order' => 'purchase order',
'qty' => 'quantity',
'reassignable' => 'reassignable',
'requestable' => 'requestable',
'seats' => 'seats',
'serial' => 'serial number',
'status' => 'status',
'supplier' => 'supplier',
'termination_date' => 'termination date',
'warranty_months' => 'warranty',
'full_name' => 'full name',
'email' => 'email',
'username' => 'username',
'address' => 'address',
'address2' => 'address2',
'city' => 'city',
'state' => 'state',
'country' => 'country',
'zip' => 'zip',
'jobtitle' => 'job title',
'employee_num' => 'employee number',
'phone_number' => 'phone number',
'first_name' => 'first name',
'last_name' => 'last name',
'department' => 'department',
'manager_name' => 'manager full name',
'manager_username' => 'manager username',
'min_amt' => 'minimum quantity',
'remote' => 'remote',
'vip' => 'vip',
];
/**
* Map of item fields->csv names
* @var array
*/
protected $fieldMap = [];
/**
* @var callable
*/
protected $logCallback;
protected $tempPassword;
/**
* @var callable
*/
protected $progressCallback;
/**
* @var null
*/
protected $usernameFormat;
/**
* @var callable
*/
protected $errorCallback;
/**
* ObjectImporter constructor.
* @param string $file
*/
public function __construct($file)
{
$this->fieldMap = $this->defaultFieldMap;
if (! ini_get('auto_detect_line_endings')) {
ini_set('auto_detect_line_endings', '1');
}
// By default the importer passes a url to the file.
// However, for testing we also support passing a string directly
if (is_file($file)) {
$this->csv = Reader::createFromPath($file);
} else {
$this->csv = Reader::createFromString($file);
}
$this->tempPassword = substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 40);
}
// Cached Values for import lookups
protected $customFields;
/**
* Sets up the database transaction and logging for the importer
*
* @return void
* @author Daniel Meltzer
* @since 5.0
*/
public function import()
{
$headerRow = $this->csv->fetchOne();
$this->csv->setHeaderOffset(0); //explicitly sets the CSV document header record
$results = $this->normalizeInputArray($this->csv->getRecords($headerRow));
$this->populateCustomFields($headerRow);
DB::transaction(function () use (&$results) {
Model::unguard();
$resultsCount = count($results);
foreach ($results as $row) {
$this->handle($row);
if ($this->progressCallback) {
call_user_func($this->progressCallback, $resultsCount);
}
$this->log('------------- Action Summary ----------------');
}
});
}
abstract protected function handle($row);
/**
* Fetch custom fields from database and translate/parse them into a format
* appropriate for use in the importer.
* @return void
* @author Daniel Meltzer
* @since 5.0
*/
protected function populateCustomFields($headerRow)
{
// Stolen From https://adamwathan.me/2016/07/14/customizing-keys-when-mapping-collections/
// This 'inverts' the fields such that we have a collection of fields indexed by name.
$this->customFields = CustomField::All()->reduce(function ($nameLookup, $field) {
$nameLookup[$field['name']] = $field;
return $nameLookup;
});
// Remove any custom fields that do not exist in the header row. This prevents nulling out values that shouldn't exist.
// In detail, we compare the lower case name of custom fields (indexed by name) to the keys in the header row. This
// results in an array with only custom fields that are in the file.
if ($this->customFields) {
$this->customFields = array_intersect_key(
array_change_key_case($this->customFields),
array_change_key_case(array_flip($headerRow))
);
}
}
/**
* Check to see if the given key exists in the array, and trim excess white space before returning it
*
* @author Daniel Melzter
* @since 3.0
* @param $array array
* @param $key string
* @param $default string
* @return string
*/
public function findCsvMatch(array $array, $key, $default = null)
{
$val = $default;
$key = $this->lookupCustomKey($key);
// $this->log("Custom Key: ${key}");
if (array_key_exists($key, $array)) {
$val = Encoding::toUTF8(trim($array[$key]));
}
//$this->log("${key}: ${val}");
return $val;
}
/**
* Looks up A custom key in the custom field map
*
* @author Daniel Melzter
* @since 4.0
* @param $key string
* @return string|null
*/
public function lookupCustomKey($key)
{
if (array_key_exists($key, $this->fieldMap)) {
return $this->fieldMap[$key];
}
// Otherwise no custom key, return original.
return $key;
}
/**
* Used to lowercase header values to ensure we're comparing values properly.
*
* @param $results
* @return array
*/
public function normalizeInputArray($results)
{
$newArray = [];
foreach ($results as $index => $arrayToNormalize) {
$newArray[$index] = array_change_key_case($arrayToNormalize);
}
return $newArray;
}
/**
* Figure out the fieldname of the custom field
*
* @author A. Gianotto <snipe@snipe.net>
* @since 3.0
* @param $array array
* @return string
*/
public function array_smart_custom_field_fetch(array $array, $key)
{
$index_name = strtolower($key->name);
return array_key_exists($index_name, $array) ? trim($array[$index_name]) : false;
}
protected function log($string)
{
if ($this->logCallback) {
call_user_func($this->logCallback, $string);
}
}
protected function logError($item, $field)
{
if ($this->errorCallback) {
call_user_func($this->errorCallback, $item, $field, $item->getErrors());
}
}
/**
* Finds the user matching given data, or creates a new one if there is no match.
* This is NOT used by the User Import, only for Asset/Accessory/etc where
* there are users listed and we have to create them and associate them at
* the same time. [ALG]
*
* @author Daniel Melzter
* @since 3.0
* @param $row array
* @return User Model w/ matching name
* @internal param array $user_array User details parsed from csv
*/
protected function createOrFetchUser($row, $type = null)
{
$user_array = [
'full_name' => $this->findCsvMatch($row, 'full_name'),
'email' => $this->findCsvMatch($row, 'email'),
'manager_id'=> '',
'department_id' => '',
'username' => $this->findCsvMatch($row, 'username'),
'activated' => $this->fetchHumanBoolean($this->findCsvMatch($row, 'activated')),
'remote' => $this->fetchHumanBoolean(($this->findCsvMatch($row, 'remote'))),
];
if ($type == 'manager') {
\Log::debug('Type = manager');
$user_array = [
'full_name' => $this->findCsvMatch($row, 'manager'),
'username' => $this->findCsvMatch($row, 'manager_username'),
];
}
// Maybe we're lucky and the user already exists.
if ($user = User::where('username', $user_array['username'])->first()) {
$this->log('User '.$user_array['username'].' already exists');
return $user;
}
// If the full name is empty, bail out--we need this to extract first name (at the very least)
if (empty($user_array['full_name'])) {
$this->log('Insufficient user data provided (Full name is required) - skipping user creation.');
\Log::debug(print_r($user_array, true));
\Log::debug(print_r($row, true));
return false;
}
// Populate email if it does not exist.
if (empty($user_array['email'])) {
$user_array['email'] = User::generateEmailFromFullName($user_array['full_name']);
}
$user_formatted_array = User::generateFormattedNameFromFullName($user_array['full_name'], Setting::getSettings()->username_format);
$user_array['first_name'] = $user_formatted_array['first_name'];
$user_array['last_name'] = $user_formatted_array['last_name'];
if (empty($user_array['username'])) {
$user_array['username'] = $user_formatted_array['username'];
if ($this->usernameFormat == 'email') {
$user_array['username'] = $user_array['email'];
}
}
// Does this ever actually fire??
// Check for a matching user after trying to guess username.
if ($user = User::where('username', $user_array['username'])->first()) {
$this->log('User '.$user_array['username'].' already exists');
return $user;
}
// If at this point we have not found a username or first name, bail out in shame.
if (empty($user_array['username']) || empty($user_array['first_name'])) {
return false;
}
// No Luck, let's create one.
$user = new User;
$user->first_name = $user_array['first_name'];
$user->last_name = $user_array['last_name'];
$user->username = $user_array['username'];
$user->email = $user_array['email'];
$user->manager_id = $user_array['manager_id'] ?? null;
$user->department_id = $user_array['department_id'] ?? null;
$user->activated = 1;
$user->password = $this->tempPassword;
\Log::debug('Creating a user with the following attributes: '.print_r($user_array, true));
if ($user->save()) {
$this->log('User '.$user_array['username'].' created');
return $user;
}
$this->logError($user, 'User "'.$user_array['username'].'" was not able to be created.');
return false;
}
/**
* Matches a user by user_id if user_name provided is a number
* @param string $user_name users full name from csv
* @return User User Matching ID
*/
protected function findUserByNumber($user_name)
{
// A number was given instead of a name
if (is_numeric($user_name)) {
$this->log('User '.$user_name.' is a number - lets see if it matches a user id');
return User::find($user_name);
}
}
/**
* Sets the Id of User performing import.
*
* @param mixed $user_id the user id
*
* @return self
*/
public function setUserId($user_id)
{
$this->user_id = $user_id;
return $this;
}
/**
* Sets the Are we updating items in the import.
*
* @param bool $updating the updating
*
* @return self
*/
public function setUpdating($updating)
{
$this->updating = $updating;
return $this;
}
/**
* Sets whether or not we should notify the user with a welcome email
*
* @param bool $send_welcome the send-welcome flag
*
* @return self
*/
public function setShouldNotify($send_welcome)
{
$this->send_welcome = $send_welcome;
return $this;
}
/**
* Defines mappings of csv fields
*
* @param bool $updating the updating
*
* @return self
*/
public function setFieldMappings($fields)
{
// Some initial sanitization.
$fields = array_map('strtolower', $fields);
$this->fieldMap = array_merge($this->defaultFieldMap, $fields);
// $this->log($this->fieldMap);
return $this;
}
/**
* Sets the callbacks for the import
*
* @param callable $logCallback Function to call when we have data to log
* @param callable $progressCallback Function to call to display progress
* @param callable $errorCallback Function to call when we have errors
*
* @return self
*/
public function setCallbacks(callable $logCallback, callable $progressCallback, callable $errorCallback)
{
$this->logCallback = $logCallback;
$this->progressCallback = $progressCallback;
$this->errorCallback = $errorCallback;
return $this;
}
/**
* Sets the value of usernameFormat.
*
* @param string $usernameFormat the username format
*
* @return self
*/
public function setUsernameFormat($usernameFormat)
{
$this->usernameFormat = $usernameFormat;
return $this;
}
public function fetchHumanBoolean($value)
{
return (int) filter_var($value, FILTER_VALIDATE_BOOLEAN);
}
/**
* Fetch an existing department, or create new if it doesn't exist
*
* @author A. Gianotto
* @since 4.6.5
* @param $user_department string
* @return int id of company created/found
*/
public function createOrFetchDepartment($user_department_name)
{
if ($user_department_name != '') {
$department = Department::where('name', '=', $user_department_name)->first();
if ($department) {
$this->log('A matching Department '.$user_department_name.' already exists');
return $department->id;
}
$department = new Department();
$department->name = $user_department_name;
if ($department->save()) {
$this->log('Department '.$user_department_name.' was created');
return $department->id;
}
$this->logError($department, 'Department');
}
return null;
}
/**
* Fetch an existing manager
*
* @author A. Gianotto
* @since 4.6.5
* @param $user_manager string
* @return int id of company created/found
*/
public function fetchManager($user_manager_first_name, $user_manager_last_name)
{
$manager = User::where('first_name', '=', $user_manager_first_name)
->where('last_name', '=', $user_manager_last_name)->first();
if ($manager) {
$this->log('A matching Manager '.$user_manager_first_name.' '.$user_manager_last_name.' already exists');
return $manager->id;
}
$this->log('No matching Manager '.$user_manager_first_name.' '.$user_manager_last_name.' found. If their user account is being created through this import, you should re-process this file again. ');
return null;
}
}