diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php index 83c7a08ea1..d485fafa01 100755 --- a/app/Http/Controllers/Assets/AssetsController.php +++ b/app/Http/Controllers/Assets/AssetsController.php @@ -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] [] * @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[] = "" . e($asset->asset_tag) . ""; + + } 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[] = "" . e($asset->asset_tag) . ""; - - } 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 { diff --git a/app/Http/Requests/CreateMultipleAssetRequest.php b/app/Http/Requests/CreateMultipleAssetRequest.php new file mode 100644 index 0000000000..0cdd2d24b7 --- /dev/null +++ b/app/Http/Requests/CreateMultipleAssetRequest.php @@ -0,0 +1,68 @@ +|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, + ]); + } +} diff --git a/app/Rules/UniqueUndeleted.php b/app/Rules/UniqueUndeleted.php new file mode 100644 index 0000000000..e53274ea8d --- /dev/null +++ b/app/Rules/UniqueUndeleted.php @@ -0,0 +1,56 @@ +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(); + } + } +} diff --git a/resources/lang/en-US/validation.php b/resources/lang/en-US/validation.php index 2d4af64fb4..7276b44586 100644 --- a/resources/lang/en-US/validation.php +++ b/resources/lang/en-US/validation.php @@ -230,7 +230,10 @@ return [ | */ - 'attributes' => [], + 'attributes' => [ + 'serials.*' => 'Serial Number', + 'asset_tags.*' => 'Asset Tag', + ], /* |-------------------------------------------------------------------------- diff --git a/resources/views/hardware/edit.blade.php b/resources/views/hardware/edit.blade.php index 78abd69648..ffc8944dd4 100755 --- a/resources/views/hardware/edit.blade.php +++ b/resources/views/hardware/edit.blade.php @@ -23,7 +23,7 @@ -
+
@@ -39,8 +39,10 @@ @else
- - {!! $errors->first('asset_tags', ' :message') !!} + + {!! $errors->first('asset_tags.1', ' :message') !!} {!! $errors->first('asset_tag', ' :message') !!}
@@ -57,6 +59,39 @@ @include ('partials.forms.edit.serial', ['fieldname'=> 'serials[1]', 'old_val_name' => 'serials.1', 'translated_serial' => trans('admin/hardware/form.serial')])
+ {{-- 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')])--}} + +
+
+ + {!! $errors->first('asset_tags.'.$i, ' :message') !!} +
+
+ +
+
+ @include ('partials.forms.edit.serial', ['fieldname'=> 'serials['.$i.']', 'old_val_name' => 'serials.'.$i, 'translated_serial' => trans('admin/hardware/form.serial')]) +
+ @endforeach
@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 += ''; box_html += '
'; box_html += '
'; @@ -322,7 +359,7 @@ box_html += '
'; box_html += ''; box_html += '
'; - box_html += '
'; + // box_html += '
'; box_html += '
'; box_html += '
'; box_html += '
'; diff --git a/resources/views/partials/forms/edit/serial.blade.php b/resources/views/partials/forms/edit/serial.blade.php index 057fae0eea..391dc17af6 100644 --- a/resources/views/partials/forms/edit/serial.blade.php +++ b/resources/views/partials/forms/edit/serial.blade.php @@ -1,5 +1,5 @@ -
+
model && $item->model->require_serial)) ? ' required' : '' }} maxlength="191" />