mirror of
https://github.com/snipe/snipe-it.git
synced 2026-08-18 11:15:42 +00:00
928 lines
31 KiB
PHP
928 lines
31 KiB
PHP
<?php
|
|
|
|
namespace App\Importer;
|
|
|
|
use App\Models\CustomField;
|
|
use App\Models\Department;
|
|
use App\Models\Setting;
|
|
use App\Models\User;
|
|
use Carbon\CarbonImmutable;
|
|
use ForceUTF8\Encoding;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
use League\Csv\Reader;
|
|
|
|
abstract class Importer
|
|
{
|
|
protected $csv;
|
|
|
|
/**
|
|
* Id of User performing import
|
|
*/
|
|
protected $created_by;
|
|
|
|
/**
|
|
* Are we updating items in the import
|
|
*
|
|
* @var bool
|
|
*/
|
|
protected $updating;
|
|
|
|
/**
|
|
* Default Map of item fields->csv names
|
|
*
|
|
* This has been moved into app/Http/Livewire/Importer.php to be more granular.
|
|
* This private variable is ONLY used for the cli-importer.
|
|
*
|
|
* @todo - find a way to make this less duplicative
|
|
*
|
|
* @var array
|
|
*/
|
|
private $defaultFieldMap = [
|
|
'id' => 'id',
|
|
'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',
|
|
'display_name' => 'display 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',
|
|
'manager_employee_num' => 'manager employee number',
|
|
'min_amt' => 'minimum quantity',
|
|
'remote' => 'remote',
|
|
'vip' => 'vip',
|
|
'tag_color' => 'tag color',
|
|
];
|
|
|
|
/**
|
|
* 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.
|
|
//
|
|
// Memory-conscious construction: this constructor runs once per JS-
|
|
// chunked slice against the same file (see ItemImportRequest::import,
|
|
// which builds a new Importer per POST from the Livewire importer's
|
|
// chunk loop). Buffering the whole file into a string here would
|
|
// multiply peak memory by the chunk count, which defeats the entire
|
|
// point of the chunked-import rework. Common case: sample the head
|
|
// of the file, if it's UTF-8 use Reader::createFromPath so the CSV
|
|
// reader streams from disk. Only fall back to full-file buffering
|
|
// when encoding conversion is actually needed.
|
|
if (is_file($file)) {
|
|
$sample = self::readSampleForEncodingProbe($file);
|
|
if ($sample !== null && ! mb_check_encoding($sample, 'UTF-8')) {
|
|
$contents = file_get_contents($file);
|
|
if ($contents !== false) {
|
|
$contents = self::convertToUtf8IfNeeded($contents);
|
|
$this->csv = Reader::createFromString($contents);
|
|
} else {
|
|
$this->csv = Reader::createFromPath($file);
|
|
}
|
|
} else {
|
|
$this->csv = Reader::createFromPath($file);
|
|
}
|
|
} else {
|
|
// Raw string input (tests, callers that build a CSV string
|
|
// in-process). Already in memory, so run the same encoding
|
|
// guard directly on the string.
|
|
$contents = mb_check_encoding($file, 'UTF-8') ? $file : self::convertToUtf8IfNeeded($file);
|
|
$this->csv = Reader::createFromString($contents);
|
|
}
|
|
$this->tempPassword = '*** NO PASSWORD - IMPORTED VIA CSV ***';
|
|
}
|
|
|
|
/**
|
|
* Read a small head-of-file sample cheaply for encoding detection. 8KB
|
|
* is enough to tell UTF-8 from GBK / Windows-1252 / Shift-JIS in
|
|
* practice (CSVs have uniform encoding throughout), and reading a
|
|
* sample this size stays in a single filesystem block on almost every
|
|
* modern deployment.
|
|
*
|
|
* Returns null if the file can't be opened, which callers should
|
|
* treat as "assume UTF-8 and let downstream errors surface".
|
|
*/
|
|
private static function readSampleForEncodingProbe(string $path): ?string
|
|
{
|
|
$handle = @fopen($path, 'rb');
|
|
if ($handle === false) {
|
|
return null;
|
|
}
|
|
$sample = @fread($handle, 8192);
|
|
fclose($handle);
|
|
|
|
return $sample === false ? null : $sample;
|
|
}
|
|
|
|
/**
|
|
* Detect the source encoding of a non-UTF-8 string and convert it to
|
|
* UTF-8. Returns the source unchanged if the contents are already
|
|
* UTF-8, if no source encoding can be determined, or if conversion
|
|
* would drop more than half the source bytes (see
|
|
* conversionExceedsLossThreshold).
|
|
*/
|
|
private static function convertToUtf8IfNeeded(string $contents): string
|
|
{
|
|
if (mb_check_encoding($contents, 'UTF-8')) {
|
|
return $contents;
|
|
}
|
|
|
|
$encoding = self::detectSourceEncoding($contents);
|
|
if ($encoding === null) {
|
|
return $contents;
|
|
}
|
|
|
|
$converted = self::runEncodingConversion($contents, $encoding);
|
|
if ($converted === null) {
|
|
return $contents;
|
|
}
|
|
|
|
if (self::conversionExceedsLossThreshold($contents, $converted, $encoding)) {
|
|
return $contents;
|
|
}
|
|
|
|
return $converted;
|
|
}
|
|
|
|
/**
|
|
* Try the Onnov detector first, falling back to mb_detect only when
|
|
* Onnov abstains. Onnov is usually more reliable when it commits to
|
|
* an answer, and overriding it with the CJK-leaning mb_detect list
|
|
* produces mojibake on short Cyrillic input. Returns null when no
|
|
* usable non-UTF-8 encoding was found.
|
|
*/
|
|
private static function detectSourceEncoding(string $contents): ?string
|
|
{
|
|
$encoding = null;
|
|
if (class_exists('\Onnov\DetectEncoding\EncodingDetector')) {
|
|
$detector = new \Onnov\DetectEncoding\EncodingDetector;
|
|
$encoding = $detector->getEncoding($contents);
|
|
}
|
|
|
|
if (! $encoding || strcasecmp($encoding, 'UTF-8') === 0) {
|
|
$detected = mb_detect_encoding($contents, ['UTF-8', 'GBK', 'GB2312', 'GB18030', 'BIG5', 'SJIS', 'EUC-JP', 'EUC-KR', 'Windows-1252', 'Windows-1251', 'ISO-8859-1'], true);
|
|
if ($detected) {
|
|
$encoding = $detected;
|
|
}
|
|
}
|
|
|
|
if (! $encoding || strcasecmp($encoding, 'UTF-8') === 0) {
|
|
return null;
|
|
}
|
|
|
|
return $encoding;
|
|
}
|
|
|
|
/**
|
|
* Run the actual byte-level conversion. Prefers iconv with //IGNORE so
|
|
* real-world CSVs with a stray invalid byte still import successfully,
|
|
* falling back to mb_convert_encoding on hosts without iconv. Returns
|
|
* null when no converter is available or the converter refused the
|
|
* input entirely.
|
|
*/
|
|
private static function runEncodingConversion(string $contents, string $encoding): ?string
|
|
{
|
|
$converted = null;
|
|
if (function_exists('iconv')) {
|
|
$result = @iconv(strtoupper($encoding), 'UTF-8//IGNORE', $contents);
|
|
$converted = $result === false ? null : $result;
|
|
} elseif (function_exists('mb_convert_encoding')) {
|
|
$converted = @mb_convert_encoding($contents, 'UTF-8', $encoding);
|
|
}
|
|
|
|
return ($converted === null || $converted === '') ? null : $converted;
|
|
}
|
|
|
|
/**
|
|
* Loss-ratio safety net for the //IGNORE flag on iconv. If the
|
|
* converted output is less than half the source size we treat that as
|
|
* "//IGNORE dropped most of the file", log a warning, and let the
|
|
* caller fall back to unconverted source so downstream sees the
|
|
* problem instead of an eerily-empty import.
|
|
*/
|
|
private static function conversionExceedsLossThreshold(string $source, string $converted, string $encoding): bool
|
|
{
|
|
if (strlen($converted) >= intdiv(strlen($source), 2)) {
|
|
return false;
|
|
}
|
|
|
|
Log::warning(sprintf(
|
|
'CSV import: refusing lossy encoding conversion (%s -> UTF-8) that kept %d/%d bytes',
|
|
$encoding,
|
|
strlen($converted),
|
|
strlen($source),
|
|
));
|
|
|
|
return true;
|
|
}
|
|
|
|
// 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(?int $offset = null, ?int $limit = null)
|
|
{
|
|
$headerRow = $this->csv->fetchOne();
|
|
$this->csv->setHeaderOffset(0); // explicitly sets the CSV document header record
|
|
|
|
$this->populateCustomFields($headerRow);
|
|
|
|
DB::transaction(function () use ($headerRow, $offset, $limit) {
|
|
$importedItemsCount = 0;
|
|
$processedInSlice = 0;
|
|
Model::unguard();
|
|
|
|
// Sliced imports: when the caller passes offset+limit we only
|
|
// process rows [offset, offset+limit). Preserves the original
|
|
// "iterate everything" behavior when neither is provided so
|
|
// CLI callers (ObjectImportCommand) and any external caller
|
|
// hitting the API without offset/limit still work unchanged.
|
|
foreach ($this->csv->getRecords($headerRow) as $row) {
|
|
// Fully blank rows (every cell empty, like ",,,,,,,,") carry
|
|
// no importable data. Skipping them before both the offset
|
|
// walk AND handle() means slice math, tallies, and per-row
|
|
// logging all reflect real work rather than padding rows.
|
|
if (self::rowIsBlank($row)) {
|
|
continue;
|
|
}
|
|
|
|
if ($offset !== null && $importedItemsCount < $offset) {
|
|
$importedItemsCount++;
|
|
|
|
continue;
|
|
}
|
|
|
|
if ($limit !== null && $processedInSlice >= $limit) {
|
|
break;
|
|
}
|
|
|
|
// Lowercase header values to ensure we're comparing values properly.
|
|
$row = array_change_key_case($row, CASE_LOWER);
|
|
|
|
$this->handle($row);
|
|
|
|
$importedItemsCount++;
|
|
$processedInSlice++;
|
|
|
|
if ($this->progressCallback) {
|
|
call_user_func($this->progressCallback, $importedItemsCount);
|
|
}
|
|
|
|
$this->log('------------- Action Summary ----------------');
|
|
}
|
|
Model::reguard();
|
|
});
|
|
}
|
|
|
|
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)) {
|
|
$trimmed = trim($array[$key]);
|
|
if (mb_check_encoding($trimmed, 'UTF-8')) {
|
|
$val = $trimmed;
|
|
} else {
|
|
$val = Encoding::toUTF8($trimmed);
|
|
}
|
|
}
|
|
|
|
// $this->log("${key}: ${val}");
|
|
return $val;
|
|
}
|
|
|
|
/**
|
|
* True when the CSV row contains a value for the given logical key
|
|
* (whether the value is populated or empty). Callers use this to
|
|
* distinguish "column absent from the CSV" (leave DB alone) from
|
|
* "column present with an empty value" (clear the DB field on update).
|
|
*/
|
|
protected function csvRowHas(array $row, string $csvKey): bool
|
|
{
|
|
return array_key_exists($this->lookupCustomKey($csvKey), $row);
|
|
}
|
|
|
|
/**
|
|
* True when every cell in the CSV row is empty after trimming.
|
|
* Fully blank rows (like ",,,,,,,") get filtered out before handle()
|
|
* so they do not count toward the tally, appear in the preview,
|
|
* or trigger per-row logging.
|
|
*/
|
|
protected static function rowIsBlank(array $row): bool
|
|
{
|
|
foreach ($row as $value) {
|
|
if (trim((string) $value) !== '') {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Assign a value from the CSV row into $this->item under the given item
|
|
* key, only when the CSV row actually contained that column. Empty CSV
|
|
* cells are assigned as null (not as empty strings) so the DB stores
|
|
* NULL when the user explicitly clears a nullable field on update.
|
|
* Columns absent from the CSV row are never touched, so update mode
|
|
* preserves existing DB values for any field the user did not include
|
|
* in their file.
|
|
*/
|
|
protected function setItemFromCsvIfPresent(array $row, string $itemKey, ?string $csvKey = null): void
|
|
{
|
|
$csvKey = $csvKey ?? $itemKey;
|
|
if ($this->csvRowHas($row, $csvKey)) {
|
|
$value = $this->findCsvMatch($row, $csvKey);
|
|
$this->item[$itemKey] = ($value === '') ? null : $value;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
/**
|
|
* 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());
|
|
}
|
|
}
|
|
|
|
protected function addErrorToBag($item, $field, $error_message)
|
|
{
|
|
if ($this->errorCallback) {
|
|
call_user_func($this->errorCallback, $item, $field, [$field => [$error_message]]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Per-row tally accumulated across the current slice. The wizard UI adds
|
|
* these across slices so the user sees a real "N created, M updated,
|
|
* K skipped as duplicates" summary at the end of an import instead of
|
|
* a generic success flash. logError() and addErrorToBag() auto-record
|
|
* errored; subclasses call recordCreated/Updated/Skipped explicitly
|
|
* from the branches of their handle() method.
|
|
*/
|
|
protected array $tally = [
|
|
'created' => 0,
|
|
'updated' => 0,
|
|
'skipped' => 0,
|
|
'errored' => 0,
|
|
];
|
|
|
|
protected function recordCreated(): void
|
|
{
|
|
$this->tally['created']++;
|
|
}
|
|
|
|
protected function recordUpdated(): void
|
|
{
|
|
$this->tally['updated']++;
|
|
}
|
|
|
|
protected function recordSkipped(): void
|
|
{
|
|
$this->tally['skipped']++;
|
|
}
|
|
|
|
protected function recordErrored(): void
|
|
{
|
|
$this->tally['errored']++;
|
|
}
|
|
|
|
public function getTally(): array
|
|
{
|
|
return $this->tally;
|
|
}
|
|
|
|
/**
|
|
* 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 = 'user')
|
|
{
|
|
|
|
$user_array = [
|
|
'full_name' => $this->findCsvMatch($row, 'full_name'),
|
|
'first_name' => $this->findCsvMatch($row, 'first_name'),
|
|
'last_name' => $this->findCsvMatch($row, 'last_name'),
|
|
'display_name' => $this->findCsvMatch($row, 'display_name'),
|
|
'email' => $this->findCsvMatch($row, 'email'),
|
|
'manager_id' => '',
|
|
// ItemImporter::handle() has already created the Department (if
|
|
// the CSV row had one) and stored its id on $this->item so it
|
|
// can flow through to the user record. Previously this value
|
|
// was hard-coded to '' and the Department was orphaned.
|
|
'department_id' => $this->item['department_id'] ?? '',
|
|
'username' => $this->findCsvMatch($row, 'username'),
|
|
'activated' => $this->fetchHumanBoolean($this->findCsvMatch($row, 'activated')),
|
|
'remote' => $this->fetchHumanBoolean(($this->findCsvMatch($row, 'remote'))),
|
|
];
|
|
|
|
if ($type == 'manager') {
|
|
$user_array['full_name'] = $this->findCsvMatch($row, 'manager');
|
|
$user_array['username'] = $this->findCsvMatch($row, 'manager_username');
|
|
}
|
|
|
|
// Maybe we're lucky and the username was passed and it already exists.
|
|
if (! empty($user_array['username'])) {
|
|
if ($user = User::where('username', $user_array['username'])->first()) {
|
|
$this->log('User '.$user_array['username'].' already exists');
|
|
|
|
return $user;
|
|
}
|
|
}
|
|
|
|
// If the full name and username is empty, bail out--we need this to extract first name (at the very least)
|
|
if ((empty($user_array['username'])) && (empty($user_array['full_name'])) && (empty($user_array['first_name']))) {
|
|
$this->log('Insufficient user data provided (Full name, first name or username is required) - skipping user creation.');
|
|
Log::debug('User array: ');
|
|
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']);
|
|
}
|
|
|
|
// Get some variables for $user_formatted_array in case we need them later
|
|
$user_formatted_array = User::generateFormattedNameFromFullName($user_array['full_name'], Setting::getSettings()->username_format);
|
|
|
|
if (empty($user_array['first_name'])) {
|
|
// Get some fields for first name and last name based off of full name
|
|
$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'];
|
|
}
|
|
|
|
// Check for a matching username one more time 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 finding a user on username or first name, let's create one.
|
|
|
|
// Floater-mode escalation guard (#19200). A side-effect of an asset
|
|
// import is that referenced users get minted with no company pivot —
|
|
// under floater mode that promotes them to system-wide visibility.
|
|
// Refuse to create the user when the importer's actor isn't trusted
|
|
// to grant floater status. Same guard the User CSV importer uses.
|
|
if (Auth::check() && ! auth()->user()->canGrantFloaterStatus()) {
|
|
$this->log('Skipping creation of referenced user "'.($user_array['username'] ?? '').'": cannot create a user with no company assignment while floater mode is enabled (#19200).');
|
|
|
|
return false;
|
|
}
|
|
|
|
$user = new User;
|
|
|
|
$user->first_name = $user_array['first_name'];
|
|
$user->last_name = $user_array['last_name'];
|
|
$user->username = $user_array['username'];
|
|
$user->display_name = $user_array['display_name'] ?? null;
|
|
$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;
|
|
// Use $this->created_by (set by setCreatedBy() from both
|
|
// ItemImportRequest for web imports and ObjectImportCommand for
|
|
// CLI imports) rather than auth()->id() so CLI-run imports get
|
|
// the --user_id option value instead of null. Without this,
|
|
// users minted as checkout targets during asset import land in
|
|
// the DB with a null created_by, breaking blame in the user list.
|
|
$user->created_by = $this->created_by;
|
|
|
|
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 created_by 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 $created_by the user id
|
|
* @return self
|
|
*/
|
|
public function setCreatedBy($created_by)
|
|
{
|
|
$this->created_by = $created_by;
|
|
|
|
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)
|
|
{
|
|
$true = [
|
|
'yes',
|
|
'y',
|
|
'true',
|
|
];
|
|
|
|
if (in_array(strtolower($value), $true)) {
|
|
return 1;
|
|
}
|
|
|
|
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)
|
|
{
|
|
// Explicit is_null check before the loose equality guard so a null
|
|
// input doesn't trigger PHP 8.x null-to-string deprecation warnings
|
|
// (the previous form was `!= ''` which coerces null to '' first).
|
|
if (is_null($user_department_name) || $user_department_name === '') {
|
|
return null;
|
|
}
|
|
|
|
$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;
|
|
// $this->created_by, not auth()->id(), so CLI-run imports
|
|
// attribute created_by to the --user_id option value rather
|
|
// than null.
|
|
$department->created_by = $this->created_by;
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Parse a date or return null
|
|
*
|
|
* @author A. Gianotto
|
|
*
|
|
* @since 7.0.0
|
|
*
|
|
* @return string|null
|
|
*/
|
|
public function parseOrNullDate($field, $format = 'date')
|
|
{
|
|
|
|
$date_format = 'Y-m-d';
|
|
|
|
if ($format == 'datetime') {
|
|
$date_format = 'Y-m-d H:i:s';
|
|
}
|
|
|
|
if (array_key_exists($field, $this->item) && $this->item[$field] != '') {
|
|
|
|
try {
|
|
$value = CarbonImmutable::parse($this->item[$field])->format($date_format);
|
|
|
|
return $value;
|
|
} catch (\Exception $e) {
|
|
$this->log('Unable to parse date: '.$this->item[$field]);
|
|
|
|
return null;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|