3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-02-05 03:05:41 +00:00

Merge pull request #18247 from uberbrady/multi_create_fixes

Fixed #18160 - Multi-create fixes
This commit is contained in:
snipe
2025-12-03 09:49:14 +00:00
committed by GitHub
6 changed files with 288 additions and 109 deletions

View File

@ -6,6 +6,7 @@ use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\CreateMultipleAssetRequest;
use App\Http\Requests\UpdateAssetRequest;
use App\Models\Actionlog;
use App\Http\Requests\UploadFileRequest;
@ -98,7 +99,7 @@ class AssetsController extends Controller
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v1.0]
*/
public function store(ImageUploadRequest $request) : RedirectResponse
public function store(CreateMultipleAssetRequest $request): RedirectResponse
{
$this->authorize(Asset::class);
@ -135,122 +136,136 @@ class AssetsController extends Controller
$successes = [];
$failures = [];
for ($a = 1, $aMax = count($asset_tags); $a <= $aMax; $a++) {
$asset = new Asset();
try {
DB::beginTransaction();
for ($a = 1, $aMax = count($asset_tags); $a <= $aMax; $a++) {
$asset = new Asset();
$asset->model()->associate($model);
$asset->name = $request->input('name');
$asset->model()->associate($model);
$asset->name = $request->input('name');
// Check for a corresponding serial
if (($serials) && (array_key_exists($a, $serials))) {
$asset->serial = $serials[$a];
}
if (($asset_tags) && (array_key_exists($a, $asset_tags))) {
$asset->asset_tag = $asset_tags[$a];
}
$asset->company_id = $companyId;
$asset->model_id = $request->input('model_id');
$asset->order_number = $request->input('order_number');
$asset->notes = $request->input('notes');
$asset->created_by = auth()->id();
$asset->status_id = request('status_id');
$asset->warranty_months = request('warranty_months', null);
$asset->purchase_cost = request('purchase_cost');
$asset->purchase_date = request('purchase_date', null);
$asset->asset_eol_date = request('asset_eol_date', null);
$asset->assigned_to = request('assigned_to', null);
$asset->supplier_id = request('supplier_id', null);
$asset->requestable = request('requestable', 0);
$asset->rtd_location_id = request('rtd_location_id', null);
$asset->byod = request('byod', 0);
if (! empty($settings->audit_interval)) {
$asset->next_audit_date = Carbon::now()->addMonths((int) $settings->audit_interval)->toDateString();
}
// Set location_id to rtd_location_id ONLY if the asset isn't being checked out
if (!request('assigned_user') && !request('assigned_asset') && !request('assigned_location')) {
$asset->location_id = $request->input('rtd_location_id', null);
}
if ($request->has('use_cloned_image')) {
$cloned_model_img = Asset::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
$new_image_name = 'clone-'.date('U').'-'.$cloned_model_img->image;
$new_image = 'assets/'.$new_image_name;
Storage::disk('public')->copy('assets/'.$cloned_model_img->image, $new_image);
$asset->image = $new_image_name;
// Check for a corresponding serial
if (($serials) && (array_key_exists($a, $serials))) {
$asset->serial = $serials[$a];
}
} else {
$asset = $request->handleImages($asset);
}
if (($asset_tags) && (array_key_exists($a, $asset_tags))) {
$asset->asset_tag = $asset_tags[$a];
}
// Update custom fields in the database.
// Validation for these fields is handled through the AssetRequest form request
$asset->company_id = $companyId;
$asset->model_id = $request->input('model_id');
$asset->order_number = $request->input('order_number');
$asset->notes = $request->input('notes');
$asset->created_by = auth()->id();
$asset->status_id = request('status_id');
$asset->warranty_months = request('warranty_months', null);
$asset->purchase_cost = request('purchase_cost');
$asset->purchase_date = request('purchase_date', null);
$asset->asset_eol_date = request('asset_eol_date', null);
$asset->assigned_to = request('assigned_to', null);
$asset->supplier_id = request('supplier_id', null);
$asset->requestable = request('requestable', 0);
$asset->rtd_location_id = request('rtd_location_id', null);
$asset->byod = request('byod', 0);
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
if ($field->field_encrypted == '1') {
if (Gate::allows('assets.view.encrypted_custom_fields')) {
if (!empty($settings->audit_interval)) {
$asset->next_audit_date = Carbon::now()->addMonths((int)$settings->audit_interval)->toDateString();
}
// Set location_id to rtd_location_id ONLY if the asset isn't being checked out
if (!request('assigned_user') && !request('assigned_asset') && !request('assigned_location')) {
$asset->location_id = $request->input('rtd_location_id', null);
}
if ($request->has('use_cloned_image')) {
$cloned_model_img = Asset::select('image')->find($request->input('clone_image_from_id'));
if ($cloned_model_img) {
$new_image_name = 'clone-' . date('U') . '-' . $cloned_model_img->image;
$new_image = 'assets/' . $new_image_name;
Storage::disk('public')->copy('assets/' . $cloned_model_img->image, $new_image);
$asset->image = $new_image_name;
}
} else {
$asset = $request->handleImages($asset);
}
// Update custom fields in the database.
// Validation for these fields is handled through the AssetRequest form request
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
if ($field->field_encrypted == '1') {
if (Gate::allows('assets.view.encrypted_custom_fields')) {
if (is_array($request->input($field->db_column))) {
$asset->{$field->db_column} = Crypt::encrypt(implode(', ', $request->input($field->db_column)));
} else {
$asset->{$field->db_column} = Crypt::encrypt($request->input($field->db_column));
}
}
} else {
if (is_array($request->input($field->db_column))) {
$asset->{$field->db_column} = Crypt::encrypt(implode(', ', $request->input($field->db_column)));
$asset->{$field->db_column} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = Crypt::encrypt($request->input($field->db_column));
$asset->{$field->db_column} = $request->input($field->db_column);
}
}
} else {
if (is_array($request->input($field->db_column))) {
$asset->{$field->db_column} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = $request->input($field->db_column);
}
}
// Validate the asset before saving
// Note - it can be tempting to instead want to call saveOrFail(), to automatically throw when an object
// is invalid (and can't save). But this won't work, because Custom Fields _overrides_ the save() method
// to inject the Custom Field Rules into the $rules property right before invoking the _real_ save.
// so, instead, we have to catch failures on the 'else' clause and throw there.
if ($asset->isValid() && $asset->save()) {
$target = null;
$location = null;
if ($userId = request('assigned_user')) {
$target = User::find($userId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.user'));
}
$location = $target->location_id;
} elseif ($assetId = request('assigned_asset')) {
$target = Asset::find($assetId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.asset'));
}
$location = $target->location_id;
} elseif ($locationId = request('assigned_location')) {
$target = Location::find($locationId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.location'));
}
$location = $target->id;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), $request->input('expected_checkin', null), 'Checked out on asset creation', $request->get('name'), $location);
}
$successes[] = "<a href='" . route('hardware.show', $asset) . "' style='color: white;'>" . e($asset->asset_tag) . "</a>";
} else {
$asset->throwValidationException(); // we have to do this for the reason listed above - can't use saveOrFail()
$failures[] = join(",", $asset->getErrors()->all()); //TODO - this can probably go away soon
}
}
// Validate the asset before saving
if ($asset->isValid() && $asset->save()) {
$target = null;
$location = null;
if ($userId = request('assigned_user')) {
$target = User::find($userId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.user'));
}
$location = $target->location_id;
} elseif ($assetId = request('assigned_asset')) {
$target = Asset::find($assetId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.asset'));
}
$location = $target->location_id;
} elseif ($locationId = request('assigned_location')) {
$target = Location::find($locationId);
if (!$target) {
return redirect()->back()->withInput()->with('error', trans('admin/hardware/message.create.target_not_found.location'));
}
$location = $target->id;
}
if (isset($target)) {
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), $request->input('expected_checkin', null), 'Checked out on asset creation', $request->get('name'), $location);
}
$successes[] = "<a href='" . route('hardware.show', $asset) . "' style='color: white;'>" . e($asset->asset_tag) . "</a>";
} else {
$failures[] = join(",", $asset->getErrors()->all());
}
} catch (\Throwable $e) {
\Log::debug("Caught exception in multi-create - rolling back: " . $e->getMessage());
DB::rollBack();
throw $e;
}
DB::commit();
if($request->get('redirect_option') === 'back'){
session()->put(['redirect_option' => 'index']);
} else {

View File

@ -0,0 +1,68 @@
<?php
namespace App\Http\Requests;
use App\Http\Requests\Traits\MayContainCustomFields;
use App\Models\Asset;
use Illuminate\Foundation\Http\FormRequest;
use App\Helpers\Helper;
use App\Models\Setting;
use App\Models\AssetModel;
use App\Rules\UniqueUndeleted;
use Illuminate\Support\Str;
class CreateMultipleAssetRequest extends ImageUploadRequest //should I extend from StoreAssetRequest? FIXME OR TODO OR THINKME
{
use MayContainCustomFields;
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true; //TODO - should I do the auth check here?
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
//grab the rules for serials and asset_tags, and tweak them into an array context for multi-create usage
$modelRules = (new Asset)->getRules();
unset($modelRules['serial']);
$asset_tag_rules = $modelRules['asset_tag'];
unset($modelRules['asset_tag']);
// now we replace the 'not_array' rule with 'distinct'
array_splice($asset_tag_rules, array_search('not_array', $asset_tag_rules), 1, 'distinct');
// and replace the 'unique_undeleted' rule with the Rule object
foreach ($asset_tag_rules as $i => $asset_tag_rule) {
if (Str::startsWith($asset_tag_rule, 'unique_undeleted')) {
$asset_tag_rules[$i] = new UniqueUndeleted('assets', 'asset_tag');
}
}
$serials_unique = Setting::getSettings()['unique_serial'];
$serials_required = AssetModel::find($this?->model_id)?->require_serial;
$serial_rules = ['string'];
if ($serials_unique) {
// $serial_rules[] = 'unique_undeleted:assets,serial';
$serial_rules[] = new UniqueUndeleted('assets', 'serial');
$serial_rules[] = 'distinct';
}
if ($serials_required) {
$serial_rules[] = 'required';
} else {
$serial_rules[] = 'nullable';
}
return array_merge($modelRules, [
'serials.*' => $serial_rules,
'asset_tags.*' => $asset_tag_rules,
]);
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Contracts\Validation\ValidatorAwareRule;
use Illuminate\Validation\Validator;
use Illuminate\Support\Facades\DB;
use Illuminate\Contracts\Validation\DataAwareRule;
class UniqueUndeleted implements ValidationRule, ValidatorAwareRule
{
protected ?Validator $validator = null;
protected array $columns = [];
protected $data = [];
public function __construct(
public string $table,
string ...$columns,
)
{
$this->columns = $columns;
}
public function setValidator(Validator $validator): static
{
$this->validator = $validator;
$this->data = $validator->getData();
//TODO - can we somehow grab the ID of the route-model-bound object, and omit its ID?
// to do that, we'd have to know _which_ parameter in the validator is actually the R-M-B'ed
// parameter. Or maybe we just change the function signature to let you specify it.
return $this;
}
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
$query = DB::table($this->table)->whereNull('deleted_at');
$query->where($this->columns[0], '=', $value); //the first column to check
$translation_string = 'validation.unique_undeleted'; //the normal validation string for a single-column check
foreach (array_slice($this->columns, 1) as $column) {
$translation_string = 'validation.two_column_unique_undeleted';
$query->where($column, '=', $this->data[$column]);
}
if ($query->count() > 0) {
$fail($translation_string)->translate();
}
}
}

View File

@ -230,7 +230,10 @@ return [
|
*/
'attributes' => [],
'attributes' => [
'serials.*' => 'Serial Number',
'asset_tags.*' => 'Asset Tag',
],
/*
|--------------------------------------------------------------------------

View File

@ -23,7 +23,7 @@
<!-- Asset Tag -->
<div class="form-group {{ $errors->has('asset_tag') ? ' has-error' : '' }}">
<div class="form-group {{ ($errors->has('asset_tag') || $errors->has('asset_tags.1')) ? ' has-error' : '' }}">
<label for="asset_tag" class="col-md-3 control-label">{{ trans('admin/hardware/form.tag') }}</label>
@ -39,8 +39,10 @@
@else
<!-- we are creating a new asset - let people use more than one asset tag -->
<div class="col-md-7 col-sm-12">
<input class="form-control" type="text" name="asset_tags[1]" id="asset_tag" value="{{ old('asset_tags.1', \App\Models\Asset::autoincrement_asset()) }}" required>
{!! $errors->first('asset_tags', '<span class="alert-msg"><i class="fas fa-times"></i> :message</span>') !!}
<input class="form-control"
type="text" name="asset_tags[1]" id="asset_tag"
value="{{ old('asset_tags.1', \App\Models\Asset::autoincrement_asset()) }}" required>
{!! $errors->first('asset_tags.1', '<span class="alert-msg"><i class="fas fa-times"></i> :message</span>') !!}
{!! $errors->first('asset_tag', '<span class="alert-msg"><i class="fas fa-times"></i> :message</span>') !!}
</div>
<div class="col-md-2 col-sm-12">
@ -57,6 +59,39 @@
@include ('partials.forms.edit.serial', ['fieldname'=> 'serials[1]', 'old_val_name' => 'serials.1', 'translated_serial' => trans('admin/hardware/form.serial')])
<div class="input_fields_wrap">
{{-- If we're back on this screen for an error, *and* we are doing 'create multiple', then... --}}
@php
$old_tags = old('asset_tags',[]);
/**
okay, so old() comes back as:
(
[1] => 1744410541
[2] => 1744410542
)
*/
if(array_key_exists('1',$old_tags)) {
unset($old_tags[1]); //we already handled 'asset_tag.1' - so unset it
}
@endphp
@foreach(array_keys($old_tags) as $i)
{{-- This is mostly stolen from the HTML that we add via javascript on the 'add_field_button' handler in the embedded JS below --}}
{{-- @include ('partials.forms.edit.serial', ['fieldname'=> 'serials['.$loop->iteration.']', 'old_val_name' => 'serials.'.$loop->iteration, 'translated_serial' => trans('admin/hardware/form.serial')])--}}
<span class="fields_wrapper">
<div class="form-group {{ $errors->has('asset_tags.'.$i) ? ' has-error' : '' }}"><label for="asset_tag"
class="col-md-3 control-label">{{ trans('admin/hardware/form.tag') }} {{ $i }}</label>
<div class="col-md-7 col-sm-12 required">
<input type="text" class="form-control" name="asset_tags[{{ $i }}]"
value="{{ old('asset_tags.'.$i) }}"
required>
{!! $errors->first('asset_tags.'.$i, '<span class="alert-msg"><i class="fas fa-times"></i> :message</span>') !!}
</div>
<div class="col-md-2 col-sm-12">
<a href="#" class="remove_field btn btn-default btn-sm"><x-icon type="minus"/></a>
</div>
</div>
@include ('partials.forms.edit.serial', ['fieldname'=> 'serials['.$i.']', 'old_val_name' => 'serials.'.$i, 'translated_serial' => trans('admin/hardware/form.serial')])
</span>
@endforeach
</div>
@include ('partials.forms.edit.model-select', ['translated_name' => trans('admin/hardware/form.model'), 'fieldname' => 'model_id', 'field_req' => true])
@ -290,7 +325,7 @@
var max_fields = 100; //maximum input boxes allowed
var wrapper = $(".input_fields_wrap"); //Fields wrapper
var add_button = $(".add_field_button"); //Add button ID
var x = 1; //initial text box count
var x = {{ old('asset_tags') ? count(old('asset_tags')) : 1 /* If we have old() data, use that to determine the 'next' number for 'Asset Tag 2' etc. Otherwise, use 1 */ }}; //initial text box count
@ -314,6 +349,8 @@
x++; //text box increment
// NOTE - this is duplicated in the blade itself in order to re-display an attempt to insert multiple assets
// So if this changes, that needs to change too.
box_html += '<span class="fields_wrapper">';
box_html += '<div class="form-group"><label for="asset_tag" class="col-md-3 control-label">{{ trans('admin/hardware/form.tag') }} ' + x + '</label>';
box_html += '<div class="col-md-7 col-sm-12 required">';
@ -322,7 +359,7 @@
box_html += '<div class="col-md-2 col-sm-12">';
box_html += '<a href="#" class="remove_field btn btn-default btn-sm"><x-icon type="minus" /></a>';
box_html += '</div>';
box_html += '</div>';
// box_html += '</div>';
box_html += '</div>';
box_html += '<div class="form-group"><label for="serial" class="col-md-3 control-label">{{ trans('admin/hardware/form.serial') }} ' + x + '</label>';
box_html += '<div class="col-md-7 col-sm-12">';

View File

@ -1,5 +1,5 @@
<!-- Serial -->
<div class="form-group {{ $errors->has('serial') ? ' has-error' : '' }}">
<div class="form-group {{ $errors->has($old_val_name ?? $fieldname) ? ' has-error' : '' }}">
<label for="{{ $fieldname }}" class="col-md-3 control-label">{{ trans('admin/hardware/form.serial') }} </label>
<div class="col-md-7 col-sm-12">
<input class="form-control" type="text" name="{{ $fieldname }}" id="{{ $fieldname }}" value="{{ old((isset($old_val_name) ? $old_val_name : $fieldname), $item->serial) }}" {{ (Helper::checkIfRequired($item, 'serial') || ($item->model && $item->model->require_serial)) ? ' required' : '' }} maxlength="191" />