Vue importer (#3235)

* Begin work on vueifying the importer

* Beginning work on migrating the importer to use a vue/components for future interactivity

Update JS

More importer work.  Move to a vue based modal, begin handling of processing.  Still need to port error messages.

More importer work.  Move to a vue based modal, begin handling of processing.  Still need to port error messages.

Update importer.  Add error display.  Fix modal, update vue-strap to vue2

More progress.  Add select2 vue bits.

* Move to querying the db to find importer matches.  It scales better on large datasets.

Fix select2 related issues.  We were trying to initialize it twice, which led to the custom data being overwritten.

* Better error handling on uploads and deletion of files.  Restore progressbar on upload.

* Add support for generic exception reporting if app.debug is enabled.

* Handle Http 500 errors better.  Display errors if debug is enabled.  Assorted cleanups.

* Fix codacy issues, remove unused methods.

* Only bind vue to the importer for now.

* Load vue for passport as well.
This commit is contained in:
Daniel Meltzer 2017-01-25 23:29:23 -06:00 committed by snipe
parent a9bf34cf61
commit 5ba2ec881c
37 changed files with 53685 additions and 1180 deletions

View file

@ -68,15 +68,17 @@ class Handler extends ExceptionHandler
switch ($e->getStatusCode()) {
case '404':
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode . ' endpoint not found'), 404);
break;
case '405':
return response()->json(Helper::formatStandardApiResponse('error', null, 'Method not allowed'), 405);
break;
default:
return response()->json(Helper::formatStandardApiResponse('error', null, $e->getStatusCode()), 405);
return response()->json(Helper::formatStandardApiResponse('error', null, $statusCode), 405);
}
}
// Try to parse 500 Errors ina bit nicer way when debug is enabled.
if (config('app.debug')) {
return response()->json(Helper::formatStandardApiResponse('error', null, "An Error has occured! " . $e->getMessage()), 500);
}
}

View file

@ -2,16 +2,34 @@
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetRequest;
use App\Http\Transformers\AssetsTransformer;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\Location;
use App\Models\Setting;
use App\Models\User;
use Artisan;
use Auth;
use Carbon\Carbon;
use Config;
use DB;
use Gate;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Http\Transformers\AssetsTransformer;
use Input;
use Lang;
use Log;
use Mail;
use Paginator;
use Response;
use Slack;
use Str;
use TCPDF;
use Validator;
use View;
/**
* This class controls all actions related to assets for
@ -325,13 +343,11 @@ class AssetsController extends Controller
->update(array('assigned_to' => null));
$asset->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.delete.success')));
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/hardware/message.delete.success')));
}
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 404);
}
}

View file

@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ItemImportRequest;
use App\Http\Transformers\ImportsTransformer;
use App\Models\Company;
use App\Models\Import;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Input;
use Illuminate\Support\Facades\Session;
use Symfony\Component\HttpFoundation\File\Exception\FileException;
class ImportController extends Controller
{
/**
* Display a listing of the resource.
*
* @return \Illuminate\Http\Response
*/
public function index()
{
//
$imports = Import::latest()->get();
return (new ImportsTransformer)->transformImports($imports);
}
/**
* Store a newly created resource in storage.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\Response
*/
public function store()
{
//
if (!Company::isCurrentUserAuthorized()) {
return redirect()->route('hardware.index')->with('error', trans('general.insufficient_permissions'));
} elseif (!config('app.lock_passwords')) {
$files = Input::file('files');
$path = config('app.private_uploads').'/imports';
$results = [];
$import = new Import;
foreach ($files as $file) {
if (!in_array($file->getMimeType(), array(
'application/vnd.ms-excel',
'text/csv',
'text/plain',
'text/comma-separated-values',
'text/tsv'))) {
$results['error']='File type must be CSV';
return $results;
}
$date = date('Y-m-d-his');
$fixed_filename = str_replace(' ', '-', $file->getClientOriginalName());
try {
$file->move($path, $date.'-'.$fixed_filename);
} catch (FileException $exception) {
$results['error']=trans('admin/hardware/message.upload.error');
if (config('app.debug')) {
$results['error'].= ' ' . $exception->getMessage();
}
return response()->json(Helper::formatStandardApiResponse('error', null, $results['error']), 500);
}
$file_name = date('Y-m-d-his').'-'.$fixed_filename;
$import->file_path = $file_name;
$import->filesize = filesize($path.'/'.$file_name);
$import->save();
$results[] = $import;
}
$results = (new ImportsTransformer)->transformImports($results);
return [
'files' => $results
];
}
$results['error']=trans('general.feature_disabled');
return $results;
}
/**
* Processes the specified Import.
*
* @param \App\Import $import
* @return \Illuminate\Http\Response
*/
public function process(ItemImportRequest $request, $import_id)
{
$this->authorize('create', Asset::class);
$errors = $request->import(Import::find($import_id));
$redirectTo = "hardware";
switch ($request->get('import-type')) {
case "asset":
$redirectTo = "hardware.index";
break;
case "accessory":
$redirectTo = "accessories.index";
break;
case "consumable":
$redirectTo = "consumables.index";
break;
case "component":
$redirectTo = "components.index";
break;
case "license":
$redirectTo = "licenses.index";
break;
}
if ($errors) { //Failure
return response()->json(Helper::formatStandardApiResponse('import-errors', null, $errors), 500);
}
//Flash message before the redirect
Session::flash('success', trans('admin/hardware/message.import.success'));
return response()->json(Helper::formatStandardApiResponse('success', null, ['redirect_url' => route($redirectTo)]));
}
/**
* Remove the specified resource from storage.
*
* @param \App\Import $import
* @return \Illuminate\Http\Response
*/
public function destroy($import_id)
{
$this->authorize('create', Asset::class);
$import = Import::find($import_id);
try {
unlink(config('app.private_uploads').'/imports/'.$import->file_path);
$import->delete();
return response()->json(Helper::formatStandardApiResponse('success', null, trans('message.import.file_delete_success')));
} catch (\Exception $e) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.import.file_delete_error')), 500);
}
}
}

View file

@ -681,103 +681,9 @@ class AssetsController extends Controller
public function getImportUpload()
{
$this->authorize('create', Asset::class);
$path = config('app.private_uploads').'/imports/assets';
$files = array();
if (!Company::isCurrentUserAuthorized()) {
return redirect()->route('hardware.index')->with('error', trans('general.insufficient_permissions'));
}
// Check if the uploads directory exists. If not, try to create it.
if (!file_exists($path)) {
mkdir($path, 0755, true);
}
if ($handle = opendir($path)) {
/* This is the correct way to loop over the directory. */
while (false !== ($entry = readdir($handle))) {
clearstatcache();
if (substr(strrchr($entry, '.'), 1)=='csv') {
$files[] = array(
'filename' => $entry,
'filesize' => Setting::fileSizeConvert(filesize($path.'/'.$entry)),
'modified' => filemtime($path.'/'.$entry)
);
}
}
closedir($handle);
$files = array_reverse($files);
}
return View::make('hardware/import')->with('files', $files);
return View::make('hardware/import');
}
/**
* Upload the import file via AJAX
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v2.0]
* @param AssetFileRequest $request
* @return array
*/
public function postAPIImportUpload(AssetFileRequest $request)
{
if (!Company::isCurrentUserAuthorized()) {
return redirect()->route('hardware.index')->with('error', trans('general.insufficient_permissions'));
} elseif (!config('app.lock_passwords')) {
$files = Input::file('files');
$path = config('app.private_uploads').'/imports/assets';
$results = array();
foreach ($files as $file) {
if (!in_array($file->getMimeType(), array(
'application/vnd.ms-excel',
'text/csv',
'text/plain',
'text/comma-separated-values',
'text/tsv'))) {
$results['error']='File type must be CSV';
return $results;
}
$date = date('Y-m-d-his');
$fixed_filename = str_replace(' ', '-', $file->getClientOriginalName());
try {
$file->move($path, $date.'-'.$fixed_filename);
} catch (FileException $exception) {
$results['error']=trans('admin/hardware/message.upload.error');
if (config('app.debug')) {
$results['error'].= ' ' . $exception->getMessage();
}
return $results;
}
$name = date('Y-m-d-his').'-'.$fixed_filename;
$filesize = Setting::fileSizeConvert(filesize($path.'/'.$name));
$results[] = compact('name', 'filesize');
}
return [
'files' => $results
];
}
$results['error']=trans('general.feature_disabled');
return $results;
}
public function getDeleteImportFile($filename)
{
$this->authorize('create', Asset::class);
if (unlink(config('app.private_uploads').'/imports/assets/'.$filename)) {
return redirect()->back()->with('success', trans('admin/hardware/message.import.file_delete_success'));
}
return redirect()->back()->with('error', trans('admin/hardware/message.import.file_delete_error'));
}
/**
* Process the uploaded file
*

View file

@ -2,6 +2,7 @@
namespace App\Http\Requests;
use App\Models\Import;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
@ -29,10 +30,9 @@ class ItemImportRequest extends FormRequest
];
}
public function import()
public function import(Import $import)
{
$filename = config('app.private_uploads') . '/imports/assets/' . $this->get('filename');
$filename = config('app.private_uploads') . '/imports/' . $import->file_path;
$class = title_case($this->input('import-type'));
$classString = "App\\Importer\\{$class}Importer";
$importer = new $classString($filename);
@ -58,6 +58,7 @@ class ItemImportRequest extends FormRequest
public function errorCallback($item, $field, $errorString)
{
$this->errors[$item->name][$field] = $errorString;
// $this->errors[$item->name] = $errorString;
}
private $errors;

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Transformers;
use App\Models\Import;
use App\Models\Setting;
use Illuminate\Database\Eloquent\Collection;
class ImportsTransformer
{
public function transformImports($imports)
{
$array = array();
foreach ($imports as $import) {
$array[] = self::transformImport($import);
}
return $array;
}
public function transformImport(Import $import)
{
$array = [
'id' => $import->id,
'file_path' => $import->file_path,
'filesize' => Setting::fileSizeConvert($import->filesize),
'name' => $import->name,
'import_type' => $import->import_type,
'created_at' => $import->created_at->diffForHumans(),
];
return $array;
}
public function transformImportsDatatable($imports)
{
return (new DatatablesTransformer)->transformDatatables($imports);
}
}

View file

@ -6,14 +6,16 @@ use App\Helpers\Helper;
use App\Models\Asset;
use App\Models\Category;
use App\Models\Manufacturer;
use App\Models\Statuslabel;
class AssetImporter extends ItemImporter
{
protected $assets;
protected $defaultStatusLabelId;
public function __construct($filename)
{
parent::__construct($filename);
$this->assets = Asset::all();
$this->defaultStatusLabelId = Statuslabel::first()->id;
}
protected function handle($row)
@ -41,11 +43,8 @@ class AssetImporter extends ItemImporter
public function createAssetIfNotExists(array $row)
{
$editingAsset = false;
$asset = new Asset;
$asset_id = $this->assets->search(function ($key) {
return strcasecmp($key->asset_tag, $this->item['asset_tag']) == 0;
});
if ($asset_id !== false) {
$asset = Asset::where(['asset_tag'=> $this->item['asset_tag']])->first();
if ($asset) {
if (!$this->updating) {
$this->log('A matching Asset ' . $this->item['asset_tag'] . ' already exists');
return;
@ -53,9 +52,9 @@ class AssetImporter extends ItemImporter
$this->log("Updating Asset");
$editingAsset = true;
$asset = $this->assets[$asset_id];
} else {
$this->log("No Matching Asset, Creating a new one");
$asset = new Asset;
}
$this->item['serial'] = $this->array_smart_fetch($row, "serial number");
$this->item['image'] = $this->array_smart_fetch($row, "image");
@ -65,13 +64,11 @@ class AssetImporter extends ItemImporter
}
if (isset($this->item["status_label"])) {
$this->item['status_id'] = $this->item["status_label"]->id;
} elseif (!$editingAsset) {
// Assume if we are editing, we already have a status and can ignore.
$this->log("No status field found, defaulting to first status.");
$this->item['status_id'] = $this->defaultStatusLabelId;
}
// We should require a status or come up with a better way of doing this..
// elseif (!$editingAsset) {
// // Assume if we are editing, we already have a status and can ignore.
// $this->log("No status field found, defaulting to first status.");
// $status_id = $this->status_labels->first()->id;
// }
// By default we're set this to location_id in the item.
@ -90,9 +87,7 @@ class AssetImporter extends ItemImporter
$asset->{$custom_field} = $val;
}
}
if (!$editingAsset) {
$this->assets->add($asset);
}
if (!$this->testRun) {
if ($asset->save()) {
$asset->logCreate('Imported using csv importer');

View file

@ -1,15 +1,9 @@
<?php
namespace App\Importer;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\Location;
use App\Models\Manufacturer;
use App\Models\Setting;
use App\Models\Statuslabel;
use App\Models\Supplier;
use App\Models\User;
use ForceUTF8\Encoding;
use Illuminate\Database\Eloquent\Model;
@ -60,15 +54,9 @@ abstract class Importer
/**
* ObjectImporter constructor.
* @param string $filename
* @param callable $logCallback
* @param callable $progressCallback
* @param callable $errorCallback
* @param bool $testRun
* @param int $user_id
* @param bool $updating
* @param null $usernameFormat
*/
function __construct(string $filename) {
public function __construct(string $filename)
{
$this->filename = $filename;
$this->csv = Reader::createFromPath($filename);
@ -79,22 +67,12 @@ abstract class Importer
$this->tempPassword = substr(str_shuffle("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"), 0, 20);
}
// Cached Values for import lookups
protected $locations;
protected $categories;
protected $manufacturers;
protected $asset_models;
protected $companies;
protected $status_labels;
protected $suppliers;
protected $assets;
protected $accessories;
protected $consumables;
protected $customFields;
public function import()
{
$results = $this->normalizeInputArray($this->csv->fetchAssoc());
$this->initializeLookupArrays();
$this->customFields = CustomField::All(['name']);
DB::transaction(function () use (&$results) {
Model::unguard();
$resultsCount = sizeof($results);
@ -123,22 +101,6 @@ abstract class Importer
return $newArray;
}
/**
* Load Cached versions of all used methods.
*/
public function initializeLookupArrays()
{
$this->locations = Location::All(['name', 'id']);
$this->categories = Category::All(['name', 'category_type', 'id']);
$this->manufacturers = Manufacturer::All(['name', 'id']);
$this->asset_models = AssetModel::All(['name', 'model_number', 'category_id', 'manufacturer_id', 'id']);
$this->companies = Company::All(['name', 'id']);
$this->status_labels = Statuslabel::All(['name', 'id']);
$this->suppliers = Supplier::All(['name', 'id']);
$this->customFields = CustomField::All(['name']);
}
/**
* Check to see if the given key exists in the array, and trim excess white space before returning it
*
@ -156,7 +118,7 @@ abstract class Importer
$val = e(Encoding::toUTF8(trim($array[ $key ])));
}
$key = title_case($key);
$this->log("${key}: ${val}");
// $this->log("${key}: ${val}");
return $val;
}

View file

@ -129,6 +129,9 @@ class ItemImporter extends Importer
*/
private function shouldUpdateField($field)
{
if (empty($field)) {
return false;
}
return !($this->updating && empty($field));
}
/**
@ -156,23 +159,18 @@ class ItemImporter extends Importer
$asset_model_name ='Unknown';
}
$editingModel = $this->updating;
$asset_model_id = $this->asset_models->search(function ($key) use ($asset_model_name, $asset_modelNumber) {
return strcasecmp($key->name, $asset_model_name) ==0
&& $key->model_number == $asset_modelNumber;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
$asset_model = new AssetModel;
$item = $this->sanitizeItemForStoring($asset_model, $editingModel);
$item['name'] = $asset_model_name;
$item['model_number'] = $asset_modelNumber;
if ($asset_model_id !== false) {
$asset_model = $this->asset_models[$asset_model_id];
$asset_model = AssetModel::where(['name' => $asset_model_name, 'model_number' => $asset_modelNumber])->first();
if ($asset_model) {
if (!$this->updating) {
$this->log("A matching model already exists, returning it.");
return $asset_model;
}
$this->log("Matching Model found, updating it.");
$item = $this->sanitizeItemForStoring($asset_model, $editingModel);
$item['name'] = $asset_model_name;
$item['model_number'] = $asset_modelNumber;
$asset_model->update($item);
if (!$this->testRun) {
$asset_model->save();
@ -180,9 +178,13 @@ class ItemImporter extends Importer
return $asset_model;
}
$this->log("No Matching Model, Creating a new one");
$asset_model = new AssetModel();
$item = $this->sanitizeItemForStoring($asset_model, $editingModel);
$item['name'] = $asset_model_name;
$item['model_number'] = $asset_modelNumber;
$asset_model->fill($item);
$this->asset_models->add($asset_model);
if ($this->testRun) {
$this->log('TEST RUN - asset_model ' . $asset_model->name . ' not created');
@ -214,15 +216,11 @@ class ItemImporter extends Importer
if (empty($asset_category)) {
$asset_category = 'Unnamed Category';
}
$category = $this->categories->search(function ($key) use ($asset_category, $item_type) {
return (strcasecmp($key->name, $asset_category) == 0)
&& $key->category_type === $item_type;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
if ($category !== false) {
$category = Category::where(['name' => $asset_category, 'category_type' => $item_type])->first();
if ($category) {
$this->log("A matching category: " . $asset_category . " already exists");
return $this->categories[$category];
return $category;
}
$category = new Category();
@ -231,11 +229,9 @@ class ItemImporter extends Importer
$category->user_id = $this->user_id;
if ($this->testRun) {
$this->categories->add($category);
return $category;
}
if ($category->save()) {
$this->categories->add($category);
$this->log('Category ' . $asset_category . ' was created');
return $category;
}
@ -253,24 +249,19 @@ class ItemImporter extends Importer
*/
public function createOrFetchCompany($asset_company_name)
{
$company = $this->companies->search(function ($key) use ($asset_company_name) {
return strcasecmp($key->name, $asset_company_name) == 0;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
if ($company !== false) {
$company = Company::where(['name' => $asset_company_name])->first();
if ($company) {
$this->log('A matching Company ' . $asset_company_name . ' already exists');
return $this->companies[$company];
return $company;
}
$company = new Company();
$company->name = $asset_company_name;
if ($this->testRun) {
$this->companies->add($company);
return $company;
}
if ($company->save()) {
$this->companies->add($company);
$this->log('Company ' . $asset_company_name . ' was created');
return $company;
}
@ -291,14 +282,11 @@ class ItemImporter extends Importer
if (empty($asset_statuslabel_name)) {
return null;
}
$status = $this->status_labels->search(function ($key) use ($asset_statuslabel_name) {
return strcasecmp($key->name, $asset_statuslabel_name) == 0;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
if ($status !== false) {
$status = Statuslabel::where(['name' => $asset_statuslabel_name])->first();
if ($status) {
$this->log('A matching Status ' . $asset_statuslabel_name . ' already exists');
return $this->status_labels[$status];
return $status;
}
$this->log("Creating a new status");
$status = new Statuslabel();
@ -309,12 +297,10 @@ class ItemImporter extends Importer
$status->archived = 0;
if ($this->testRun) {
$this->status_labels->add($status);
return $status;
}
if ($status->save()) {
$this->status_labels->add($status);
$this->log('Status ' . $asset_statuslabel_name . ' was created');
return $status;
}
@ -338,14 +324,11 @@ class ItemImporter extends Importer
if (empty($item_manufacturer)) {
$item_manufacturer='Unknown';
}
$manufacturer = $this->manufacturers->search(function ($key) use ($item_manufacturer) {
return strcasecmp($key->name, $item_manufacturer) == 0;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
if ($manufacturer !== false) {
$manufacturer = Manufacturer::where(['name'=> $item_manufacturer])->first();
if ($manufacturer) {
$this->log('Manufacturer ' . $item_manufacturer . ' already exists') ;
return $this->manufacturers[$manufacturer];
return $manufacturer;
}
//Otherwise create a manufacturer.
@ -354,11 +337,9 @@ class ItemImporter extends Importer
$manufacturer->user_id = $this->user_id;
if ($this->testRun) {
$this->manufacturers->add($manufacturer);
return $manufacturer;
}
if ($manufacturer->save()) {
$this->manufacturers->add($manufacturer);
$this->log('Manufacturer ' . $manufacturer->name . ' was created');
return $manufacturer;
}
@ -380,14 +361,11 @@ class ItemImporter extends Importer
$this->log('No location given, so none created.');
return null;
}
$location = $this->locations->search(function ($key) use ($asset_location) {
return strcasecmp($key->name, $asset_location) == 0;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
$location = Location::where(['name' => $asset_location])->first();
if ($location !== false) {
$this->log('Location ' . $asset_location . ' already exists');
return $this->locations[$location];
return $location;
}
// No matching locations in the collection, create a new one.
$location = new Location();
@ -399,11 +377,9 @@ class ItemImporter extends Importer
$location->user_id = $this->user_id;
if ($this->testRun) {
$this->locations->add($location);
return $location;
}
if ($location->save()) {
$this->locations->add($location);
$this->log('Location ' . $asset_location . ' was created');
return $location;
}
@ -425,14 +401,11 @@ class ItemImporter extends Importer
$item_supplier='Unknown';
}
$supplier = $this->suppliers->search(function ($key) use ($item_supplier) {
return strcasecmp($key->name, $item_supplier) == 0;
});
// We need strict compare here because the index returned above can be 0.
// This casts to false and causes false positives
if ($supplier !== false) {
$supplier = Supplier::where(['name' => $item_supplier ])->first();
if ($supplier) {
$this->log('Supplier ' . $item_supplier . ' already exists');
return $this->suppliers[$supplier];
return $supplier;
}
$supplier = new Supplier();
@ -440,11 +413,9 @@ class ItemImporter extends Importer
$supplier->user_id = $this->user_id;
if ($this->testRun) {
$this->suppliers->add($supplier);
return $supplier;
}
if ($supplier->save()) {
$this->suppliers->add($supplier);
$this->log('Supplier ' . $item_supplier . ' was created');
return $supplier;
}

View file

@ -31,8 +31,8 @@ class Category extends SnipeModel
public $rules = array(
'user_id' => 'numeric|nullable',
'name' => 'required|min:1|max:255|unique_undeleted',
'require_acceptance' => 'required|boolean',
'use_default_eula' => 'required|boolean',
'require_acceptance' => 'boolean',
'use_default_eula' => 'boolean',
'category_type' => 'required|in:asset,accessory,consumable,component',
);

9
app/Models/Import.php Normal file
View file

@ -0,0 +1,9 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Import extends Model
{
}

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateImportsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('imports', function (Blueprint $table) {
$table->increments('id');
$table->string('name')->nullable();
$table->string('file_path');
$table->integer('filesize');
$table->string('import_type')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('imports');
}
}

View file

@ -15,8 +15,11 @@
"laravel-elixir-vue-2": "^0.2.0",
"laravel-elixir-webpack-official": "^1.0.2",
"lodash": "^4.16.2",
"vue": "^2.0.1",
"vue-resource": "^1.0.3"
"vue": "=2.1.6",
"vue-loader": "^10.0.2",
"vue-resource": "^1.0.3",
"vue-strap": "github:wffranco/vue-strap.git",
"vue-template-compiler": "=2.1.6"
},
"dependencies": {
"jquery": "^3.1.1",

Binary file not shown.

File diff suppressed because it is too large Load diff

Before

Width:  |  Height:  |  Size: 357 KiB

After

Width:  |  Height:  |  Size: 434 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
{
"assets/css/app.css": "assets/css/app-fc4d2fdcc4.css",
"assets/js/all.js": "assets/js/all-93b23559b9.js"
"assets/js/all.js": "assets/js/all-5e5715ba54.js"
}

View file

@ -0,0 +1,36 @@
<style scoped>
</style>
<template>
<div class="col-md-12" :class="alertType">
<div class="alert" :class="alertClassName">
<button type="button" class="close" @click="hideEvent">&times;</button>
<i class="fa fa-check faa-pulse animated" v-show="alertType == 'success'"></i>
<strong>{{ title }} </strong>
<slot></slot>
</div>
</div>
</template>
<script>
export default {
/*
* The component's data.
*/
props: ['alertType', 'title'],
computed: {
alertClassName() {
return 'alert-' + this.alertType;
}
},
methods: {
hideEvent() {
this.$emit('hide');
}
}
}
</script>

View file

@ -0,0 +1,42 @@
<style scoped>
</style>
<template>
<div class="box" v-if="errors">
<div class="box-body">
<div class="alert alert-warning">
<strong>Warning</strong> Some Errors occured while importing
</div>
<div class="errors-table">
<table class="table table-striped table-bordered" id="errors-table">
<thead>
<th>Item</th>
<th>Errors</th>
</thead>
<tbody>
<tr v-for="(error, item) in errors">
<td>{{ item }}</td>
<td v-for="(value, field) in error">
<b>{{ field }}:</b>
<span v-for="errorString in value">{{errorString[0]}}</span>
<br />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script>
export default {
/*
* The component's data.
*/
props: ['errors'],
}
</script>

View file

@ -0,0 +1,227 @@
<style scoped>
</style>
<template>
<div>
<alert v-show="alert.visible" :alertType="alert.type" v-on:hide="alert.visible = false">{{ alert.message }}</alert>
<errors :errors="importErrors"></errors>
<modal v-model="displayImportModal" effect="fade">
<div slot="modal-header" class="modal-title">Import File:</div>
<div slot="modal-body" class="modal-body">
<div class="dynamic-form-row">
<div class="col-md-4 col-xs-12">
<label for="import-type">Import Type:</label>
</div>
<div class="col-md-8 col-xs-12">
<select2 :options="modal.importTypes" v-model="modal.importType">
<option disabled value="0"></option>
</select2>
</div>
</div>
<div class="dynamic-form-row">
<div class="col-md-4 col-xs-12">
<label for="import-update">Update Existing Values?:</label>
</div>
<div class="col-md-8 col-xs-12">
<input type="checkbox" name="import-update" v-model="modal.update">
</div>
</div>
</div>
<div slot="modal-footer" class="modal-footer">
<div class="row">
<div class="alert alert-success col-md-5 col-md-offset-1" style="text-align:left" v-if="modal.statusText">{{ this.modal.statusText }}</div>
<button type="button" class="btn btn-default" @click="displayImportModal = false">Cancel</button>
<button type="submit" class="btn btn-primary" @click="postSave">Save</button>
</div>
</div>
</modal>
<div class="row">
<div class="col-md-12">
<div class="box">
<div class="box-body">
<div class="col-md-3">
<!-- The fileinput-button span is used to style the file input field as button -->
<span class="btn btn-info fileinput-button">
<i class="fa fa-plus icon-white"></i>
<span>Select Import File...</span>
<!-- The file input field used as target for the file upload widget -->
<input id="fileupload" type="file" name="files[]" data-url="/api/v1/imports" accept="text/csv">
</span>
</div>
<div class="col-md-9" v-show="progress.visible" style="padding-bottom:20px">
<div class="col-md-11">
<div class="progress progress-striped-active" style="margin-top: 8px">
<div class="progress-bar" :class="progress.currentClass" role="progressbar" :style="progressWidth">
<span>{{ progress.statusText }}</span>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<table class="table table-striped" id="upload-table">
<thead>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th></th>
</thead>
<tbody>
<tr v-for="file in files">
<td>{{ file.file_path }}</td>
<td>{{ file.created_at }} </td>
<td>{{ file.filesize }}</td>
<td>
<button class="btn btn-sm btn-info" @click="showModal(file)"><i class="fa fa-spinner process"></i>Process</button>
<button class="btn btn-danger btn-sm" @click="deleteFile(file)"><i class="fa fa-trash icon-white"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
require('blueimp-file-upload');
var modal = require('vue-strap').modal
export default {
/*
* The component's data.
*/
data() {
return {
files: [],
displayImportModal: false,
activeFile: null,
alert: {
type: null,
message: null,
visible: false,
},
modal: {
importType: 'asset',
update: false,
importTypes: [
{ id: 'asset', text: 'Assets' },
{ id: 'accessory', text: 'Accessories' },
{ id: 'consumable', text: 'Consumable' },
{ id: 'component', text: 'Components' },
{ id: 'license', text: 'Licenses' }
],
statusText: null,
},
importErrors: null,
progress: {
currentClass: "progress-bar-warning",
currentPercent: "0",
statusText: '',
visible: false
}
};
},
/**
* Prepare the component (Vue 2.x).
*/
mounted() {
this.fetchFiles();
let vm = this;
$('#fileupload').fileupload({
dataType: 'json',
done(e, data) {
vm.progress.currentClass="progress-bar-success";
vm.progress.statusText = "Success!";
vm.files = data.result.files.concat(vm.files);
},
add(e, data) {
data.headers = {
"X-Requested-With": 'XMLHttpRequest',
"X-CSRF-TOKEN": Laravel.csrfToken
};
data.process().done( () => {data.submit();});
vm.progress.visible=true;
},
progress(e, data) {
var progress = parseInt((data.loaded / data.total * 100, 10));
vm.progress.currentPercent = progress;
vm.progress.statusText = progress+'% Complete';
},
fail(e, data) {
vm.progress.currentClass = "progress-bar-danger";
vm.progress.statusText = data.errorThrown;
}
})
},
methods: {
fetchFiles() {
this.$http.get('/api/v1/imports')
.then( ({data}) => this.files = data, // Success
//Fail
(response) => {
this.alert.type="danger";
this.alert.visible=true;
this.alert.message="Something went wrong fetching files...";
});
},
deleteFile(file, key) {
this.$http.delete("/api/v1/imports/"+file.id)
.then((response) => this.files.splice(key, 1), // Success
(response) => {// Fail
this.alert.type="danger";
this.alert.visible=true;
this.alert.message=response.body.messages;
}
);
},
showModal(file) {
this.activeFile = file;
this.displayImportModal = true;
},
postSave() {
this.$http.post('/api/v1/imports/process/'+this.activeFile.id, {
'import-update': this.modal.update,
'import-type': this.modal.importType
}).then( (response) => {
// Success
this.modal.statusText = "Success... Redirecting.";
window.location.href = response.body.messages.redirect_url;
}, (response) => {
// Failure
if(response.body.status == 'import-errors') {
this.importErrors = response.body.messages;
} else {
this.alert.message= response.body.messages;
this.alert.type="danger";
this.alert.visible=true;
}
this.displayImportModal=false;
});
}
},
computed: {
progressWidth() {
return "width: "+this.progress.currentPercent*10+'%';
}
},
components: {
modal,
errors: require('./importer-errors.vue'),
alert: require('../alert.vue'),
select2: require('../select2.vue')
}
}
</script>

View file

@ -0,0 +1,40 @@
<style scoped>
</style>
<template>
<select style="width:100%">
<slot></slot>
</select>
</template>
<script>
require('select2');
export default {
/*
* The component's data.
*/
props: ['options', 'value'],
mounted() {
var vm = this;
$(this.$el)
.select2({
data: this.options
})
.on('change', function() { vm.$emit('input', this.value) } );
},
watch: {
value: function (value) {
$(this.$el).val(value)
},
options: function (options) {
$(this.$el).select2({data: options})
},
destroyed: function() {
$(this.$el).off().select2('destroy')
}
}
}
</script>

View file

@ -138,7 +138,13 @@ $(document).ready(function () {
var iOS = /iPhone|iPad|iPod/.test(navigator.userAgent) && !window.MSStream;
if(!iOS)
{
$(".select2").select2();
// Vue collision: Avoid overriding a vue select2 instance
// by checking to see if the item has already been select2'd.
$('select2:not([class^="select2-container"])').each((i,obj) => {
{
$(obj).select2();
}
});
}
$('.datepicker').datepicker();

File diff suppressed because one or more lines are too long

View file

@ -6,13 +6,11 @@
*/
require('./bootstrap');
/**
* Next, we will create a fresh Vue application instance and attach it to
* the page. Then, you may begin adding components to this application
* or customize the JavaScript scaffolding to fit your unique needs.
*/
Vue.component(
'passport-clients',
require('./components/passport/Clients.vue')
@ -28,7 +26,12 @@ Vue.component(
require('./components/passport/PersonalAccessTokens.vue')
);
Vue.component(
'importer',
require('./components/importer/importer.vue')
);
const app = new Vue({
el: '#app'
});
// Commented out currently to avoid trying to load vue everywhere.
// const app = new Vue({
// el: '#app'
// });

View file

@ -13,5 +13,12 @@
@else
<p class="help-block">{{ trans('general.feature_disabled') }}</p>
@endif
@stop
@section('moar_scripts')
<script>
new Vue({
el: "#app",
});
</script>
@endsection

View file

@ -1,5 +1,5 @@
@extends('layouts/default')
<link rel="stylesheet" type="text/css" href="{{ asset('assets/css/lib/jquery.fileupload.css') }}">
{{-- Page title --}}
@section('title')
{{ trans('general.import') }}
@ -8,209 +8,15 @@
{{-- Page content --}}
@section('content')
@if (session()->has('import_errors'))
<div class="box">
<div class="box-body">
<div class="alert alert-warning">
<strong>Warning</strong> {{trans('admin/hardware/message.import.errorDetail')}}
</div>
<div class="errors-table">
<table class="table table-striped table-bordered" id="errors-table">
<thead>
<th>Asset</th>
<th>Errors</th>
</thead>
<tbody>
@foreach (session('import_errors') as $asset => $itemErrors)
<tr>
<td> {{ $asset }}</td>
<td>
@foreach ($itemErrors as $field => $values )
<b>{{ $field }}:</b>
@foreach( $values as $errorString)
<span>{{$errorString[0]}} </span>
@endforeach
<br />
@endforeach
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
{{-- Modal import dialog --}}
<div class="modal fade" id="importModal">
<form id="import-modal-form" class="form-horizontal" method="post" action="{{ route('assets/import/process-file') }}" autocomplete="off" role="form">
{{ csrf_field()}}
<input type="hidden" id="modal-filename" name="filename" value="">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
<h4 class="modal-title">Import File:</h4>
</div>
<div class="modal-body">
<div class="dynamic-form-row">
<div class="col-md-4 col-xs-12">
<label for="import-type">Import Type:</label>
</div>
<div class="col-md-8 col-xs-12">
{{ Form::select('import-type', array('asset' => 'Assets', 'accessory' => "Accessories", 'consumable' => "Consumables", 'component' => "Components") , 'asset', array('class'=>'select2 parent', 'style'=>'width:100%','id' =>'import-type')) }}
</div>
</div>
<div class="dynamic-form-row">
<div class="col-md-4 col-xs-12">
<label for="import-update">Update Existing Values?:</label>
</div>
<div class="col-md-8 col-xs-12">
{{ Form::checkbox('import-update') }}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ trans('button.cancel') }}</button>
<!-- <button type="button" class="btn btn-primary" id="modal-save">{{ trans('general.save') }}</button> -->
{{Form::submit(trans('general.save'), ['class' => 'btn btn-primary'])}}
</div>
</div>
</div>
</form>
</div>
<div class="row">
<div class="col-md-12">
<div class="box">
<div class="box-body">
<div class="col-md-3">
<!-- The fileinput-button span is used to style the file input field as button -->
<span class="btn btn-info fileinput-button">
<i class="fa fa-plus icon-white"></i>
<span>Select Import File...</span>
<!-- The file input field used as target for the file upload widget -->
<input id="fileupload" type="file" name="files[]" data-url="{{ route('api.hardware.importFile') }}" accept="text/csv">
</span>
</div>
<div class="col-md-9" id="progress-container" style="visibility: hidden; padding-bottom: 20px;">
<!-- The global progress bar -->
<div class="col-md-11">
<div id="progress" class="progress progress-striped active" style="margin-top: 8px;">
<div class="progress-bar progress-bar-warning" role="progressbar" aria-valuenow="45" aria-valuemin="0" aria-valuemax="100" style="width: 45%">
<span id="progress-bar-text">0% Complete</span>
</div>
</div>
</div>
<div class="col-md-1">
<div class="pull-right progress-checkmark" style="display: none;">
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<table class="table table-striped" id="upload-table">
<thead>
<th>File</th>
<th>Created</th>
<th>Size</th>
<th></th>
</thead>
<tbody>
@foreach ($files as $file)
<tr>
<td>{{ $file['filename'] }}</td>
<td>{{ date("M d, Y g:i A", $file['modified']) }} </td>
<td>{{ $file['filesize'] }}</td>
<td>
<a href="#" data-toggle="modal" data-target="#importModal" data-filename={{$file['filename']}} class="btn btn-sm btn-info"><i class="fa fa-spinner process"></i> Process</a>
<a class="btn btn-danger btn-sm" href="import/delete/{{ $file['filename'] }}"><i class="fa fa-trash icon-white"></i></a>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div id="app">
<importer>
</div>
@stop
@section('moar_scripts')
<link rel="stylesheet" type="text/css" href="{{ asset('assets/css/lib/jquery.fileupload.css') }}">
<link rel="stylesheet" type="text/css" href="{{ asset('assets/css/lib/jquery.fileupload-ui.css') }}">
<script>
$(function () {
//binds to onchange event of your input field
var uploadedFileSize = 0;
$('#fileupload').bind('change', function() {
uploadedFileSize = this.files[0].size;
$('#progress-container').css('visibility', 'visible');
});
$('.process').bind('click', function() {
$('.process').addClass('fa-spin');
});
$('#fileupload').fileupload({
//maxChunkSize: 100000,
dataType: 'json',
formData: {_token: '{{ csrf_token() }}'},
progress: function (e, data) {
//var overallProgress = $('#fileupload').fileupload('progress');
//var activeUploads = $('#fileupload').fileupload('active');
var progress = parseInt((data.loaded / uploadedFileSize) * 100, 10);
$('.progress-bar').addClass('progress-bar-warning').css('width',progress + '%');
$('#progress-bar-text').html(progress + '%');
//console.dir(overallProgress);
},
done: function (e, data) {
console.dir(data);
// We use this instead of the fail option, since our API
// returns a 200 OK status which always shows as "success"
if (data && data.jqXHR.responseJSON && data.jqXHR.responseJSON.error) {
$('#progress-bar-text').html(data.jqXHR.responseJSON.error);
$('.progress-bar').removeClass('progress-bar-warning').addClass('progress-bar-danger').css('width','100%');
$('.progress-checkmark').fadeIn('fast').html('<i class="fa fa-times fa-3x icon-white" style="color: #d9534f"></i>');
//console.log(data.jqXHR.responseJSON.error);
} else {
$('.progress-bar').removeClass('progress-bar-warning').removeClass('progress-bar-danger').addClass('progress-bar-success').css('width','100%');
$('.progress-checkmark').fadeIn('fast');
$('#progress-container').delay(950).css('visibility', 'visible');
$('.progress-bar-text').html('Finished!');
$('.progress-checkmark').fadeIn('fast').html('<i class="fa fa-check fa-3x icon-white" style="color: green"></i>');
$.each(data.result.files, function (index, file) {
$('<tr><td>' + file.name + '</td><td>Just now</td><td>' + file.filesize + '</td><td><a class="btn btn-info btn-sm" href="#" data-toggle="modal" data-target="#importModal" data-filename='+ file.name + '><i class="fa fa-spinner process"></i> Process</a> <a class="btn btn-danger btn-sm" href="import/delete/' +file.name + '"><i class="fa fa-trash icon-white"></i></a></td></tr>').prependTo("#upload-table > tbody");
});
}
$('#progress').removeClass('active');
}
});
});
// Modal Import options handling
$('#importModal').on("show.bs.modal", function(event) {
var link = $(event.relatedTarget);
var filename = link.data('filename');
$(this).find('.modal-title').text("Import File: " + filename );
$("#modal-filename").val(filename);
});
</script>
@stop
<script>
new Vue({
el: '#app'
});
</script>
@endsection

View file

@ -16,3 +16,11 @@
@endif
@stop
@section('moar_scripts')
<script>
new Vue({
el: "#app",
});
</script>
@endsection

View file

@ -18,6 +18,7 @@ use App\Models\Statuslabel;
Route::group(['prefix' => 'v1','namespace' => 'Api'], function () {
/*---Hardware API---*/
Route::resource('users', 'UsersController',
['names' =>
@ -33,7 +34,6 @@ Route::group(['prefix' => 'v1','namespace' => 'Api'], function () {
]
);
Route::resource('licenses', 'LicensesController',
['names' =>
[
@ -47,6 +47,20 @@ Route::group(['prefix' => 'v1','namespace' => 'Api'], function () {
'parameters' => ['license' => 'license_id']
]
);
Route::post('imports/process/{import_id}', [ 'as' => 'api.imports.importFile', 'uses'=> 'ImportController@process']);
Route::resource('imports', 'ImportController',
['names' =>
[
'index' => 'api.imports.index',
'show' => 'api.imports.show',
'update' => 'api.imports.update',
'store' => 'api.imports.store',
'destroy' => 'api.imports.destroy'
],
'except' => ['edit']
]
);
Route::resource('models', 'AssetModelsController',
@ -246,7 +260,6 @@ Route::group(['prefix' => 'v1','namespace' => 'Api'], function () {
/*---Hardware API---*/
Route::post('hardware/import', [ 'as' => 'api.assets.importFile', 'uses'=> 'AssetsController@postAPIImportUpload']);
Route::match(['DELETE'], 'hardware/{id}', ['uses' => 'AssetsController@destroy','as' => 'api.assets.destroy']);

View file

@ -72,10 +72,7 @@ Route::group(
'uses' => 'AssetsController@postUpload'
]);
Route::get('{assetId}/deletefile/{fileId}', [
'as' => 'delete/assetfile',
'uses' => 'AssetsController@getDeleteFile'
]);
Route::get('{assetId}/showfile/{fileId}', [
'as' => 'show/assetfile',