Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2025-09-03 15:09:42 +01:00
32 changed files with 247 additions and 49 deletions

View File

@@ -16,6 +16,7 @@ class IconHelper
case 'clone':
return 'far fa-clone';
case 'delete':
case 'upload deleted':
return 'fas fa-trash';
case 'create':
return 'fa-solid fa-plus';

View File

@@ -50,6 +50,7 @@ class AssetModelsController extends Controller
'fieldset',
'deleted_at',
'updated_at',
'require_serial',
];
$assetmodels = AssetModel::select([
@@ -69,6 +70,7 @@ class AssetModelsController extends Controller
'models.fieldset_id',
'models.deleted_at',
'models.updated_at',
'models.require_serial'
])
->with('category', 'depreciation', 'manufacturer', 'fieldset.fields.defaultValues', 'adminuser')
->withCount('assets as assets_count');

View File

@@ -51,11 +51,7 @@ class UploadedFilesController extends Controller
];
$uploads = Actionlog::select('action_logs.*')
->whereNotNull('filename')
->where('item_type', self::$map_object_type[$object_type])
->where('item_id', $object->id)
->where('action_type', '=', 'uploaded')
$uploads = self::$map_object_type[$object_type]::withTrashed()->find($id)->uploads()
->with('adminuser');
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : abs($request->input('offset'));
@@ -206,7 +202,7 @@ class UploadedFilesController extends Controller
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
}
// Delete the record of the file
if ($log->delete()) {
if ($log->logUploadDelete($object, $log->filename)) {
return response()->json(Helper::formatStandardApiResponse('success', null, trans_choice('general.file_upload_status.delete.success', 1)), 200);
}

View File

@@ -82,6 +82,7 @@ class AssetModelsController extends Controller
$model->notes = $request->input('notes');
$model->created_by = auth()->id();
$model->requestable = $request->has('requestable');
$model->require_serial = $request->input('require_serial', 0);
if ($request->input('fieldset_id') != '') {
$model->fieldset_id = $request->input('fieldset_id');
@@ -155,7 +156,7 @@ class AssetModelsController extends Controller
$model->category_id = $request->input('category_id');
$model->notes = $request->input('notes');
$model->requestable = $request->input('requestable', '0');
$model->require_serial = $request->input('require_serial', 0);
$model->fieldset_id = $request->input('fieldset_id');
if ($model->save()) {

View File

@@ -110,17 +110,35 @@ class AssetsController extends Controller
// This is only necessary on create, not update, since bulk editing is handled
// differently
$asset_tags = $request->input('asset_tags');
$model = AssetModel::find($request->input('model_id'));
$serial_errors = [];
$serials = $request->input('serials');
$settings = Setting::getSettings();
//Validate required serial based on model setting
for ($a = 1, $aMax = count($asset_tags); $a <= $aMax; $a++) {
if ($model && $model->require_serial === 1 && empty($serials[$a])) {
$serial_errors["serials.$a"] = trans('admin/hardware/form.serial_required', ['number' => $a]);
}
}
if (!empty($serial_errors)) {
return redirect()->back()
->withInput()
->withErrors($serial_errors);
}
$asset = null;
$companyId = Company::getIdForCurrentUser($request->input('company_id'));
$successes = [];
$failures = [];
$serials = $request->input('serials');
$asset = null;
for ($a = 1; $a <= count($asset_tags); $a++) {
for ($a = 1, $aMax = count($asset_tags); $a <= $aMax; $a++) {
$asset = new Asset();
$asset->model()->associate(AssetModel::find($request->input('model_id')));
$asset->model()->associate($model);
$asset->name = $request->input('name');
// Check for a corresponding serial
@@ -132,7 +150,7 @@ class AssetsController extends Controller
$asset->asset_tag = $asset_tags[$a];
}
$asset->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$asset->company_id = $companyId;
$asset->model_id = $request->input('model_id');
$asset->order_number = $request->input('order_number');
$asset->notes = $request->input('notes');
@@ -172,7 +190,6 @@ class AssetsController extends Controller
// Update custom fields in the database.
// Validation for these fields is handled through the AssetRequest form request
$model = AssetModel::find($request->get('model_id'));
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field) {
@@ -453,6 +470,13 @@ class AssetsController extends Controller
]);
//Validate required serial based on model setting
if ($model && $model->require_serial === 1 && empty($serial[1])) {
return redirect()->to(Helper::getRedirectOption($request, $asset->id, 'Assets'))
->with('warning', trans('admin/hardware/form.serial_required_post_model_update', [
'asset_model' => $model->name
]));
}
if ($asset->save()) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('success', trans('admin/hardware/message.update.success'));

View File

@@ -92,7 +92,9 @@ class BulkAssetModelsController extends Controller
$update_array['min_amt'] = $request->input('min_amt');
}
if ($request->filled('require_serial')) {
$update_array['require_serial'] = $request->input('require_serial');
}
if (count($update_array) > 0) {
AssetModel::whereIn('id', $models_raw_array)->update($update_array);

View File

@@ -148,7 +148,7 @@ class UploadedFilesController extends Controller
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
}
// Delete the record of the file
if ($log->delete()) {
if ($log->logUploadDelete($object, $log->filename)) {
return redirect()->back()->withFragment('files')->with('success', trans_choice('general.file_upload_status.delete.success', 1));
}

View File

@@ -50,17 +50,20 @@ class ActionlogsTransformer
public function transformActionlog (Actionlog $actionlog, $settings = null)
{
$icon = $actionlog->present()->icon();
if (($actionlog->filename!='') && ($actionlog->action_type!='upload deleted')) {
$icon = Helper::filetype_icon($actionlog->filename);
}
static $custom_fields = false;
if ($custom_fields === false) {
$custom_fields = CustomField::all();
}
if ($actionlog->filename!='') {
$icon = Helper::filetype_icon($actionlog->filename);
}
// This is necessary since we can't escape special characters within a JSON object
if (($actionlog->log_meta) && ($actionlog->log_meta!='')) {

View File

@@ -65,6 +65,7 @@ class AssetModelsTransformer
'default_fieldset_values' => $default_field_values,
'eol' => ($assetmodel->eol > 0) ? $assetmodel->eol.' months' : 'None',
'requestable' => ($assetmodel->requestable == '1') ? true : false,
'require_serial' => $assetmodel->require_serial,
'notes' => Helper::parseEscapedMarkedownInline($assetmodel->notes),
'created_by' => ($assetmodel->adminuser) ? [
'id' => (int) $assetmodel->adminuser->id,

View File

@@ -66,7 +66,7 @@ class Accessory extends SnipeModel
'company_id' => 'integer|nullable',
'location_id' => 'exists:locations,id|nullable|fmcs_location',
'min_amt' => 'integer|min:0|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:9999999999999',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable',
];

View File

@@ -246,19 +246,6 @@ class Actionlog extends SnipeModel
}
/**
* Establishes the actionlog -> uploads relationship
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function uploads()
{
return $this->morphTo('item')
->where('action_type', '=', 'uploaded')
->withTrashed();
}
/**
* Establishes the actionlog -> userlog relationship
@@ -456,6 +443,26 @@ class Actionlog extends SnipeModel
}
/**
* @author Godfrey Martinez
* @since [v8.0.4]
* @return \App\Models\Actionlog
*/
public function logUploadDelete($object, $filename)
{
$log = new Actionlog;
$log->item_type = $object instanceof SnipeModel ? get_class($object) : $object;
$log->item_id = $object->id;
$log->created_by = auth()->id();
$log->target_id = null;
$log->filename = $filename;
$log->created_at = date('Y-m-d H:i:s');
$log->logaction('upload deleted');
return $log;
}
public function uploads_file_url()
{

View File

@@ -114,7 +114,7 @@ class Asset extends Depreciable
'rtd_location_id' => ['nullable', 'exists:locations,id', 'fmcs_location'],
'purchase_date' => ['nullable', 'date', 'date_format:Y-m-d'],
'serial' => ['nullable', 'string', 'unique_undeleted:assets,serial'],
'purchase_cost' => ['nullable', 'numeric', 'gte:0', 'max:9999999999999'],
'purchase_cost' => ['nullable', 'numeric', 'gte:0', 'max:99999999999999999.99'],
'supplier_id' => ['nullable', 'exists:suppliers,id'],
'asset_eol_date' => ['nullable', 'date'],
'eol_explicit' => ['nullable', 'boolean'],

View File

@@ -71,6 +71,7 @@ class AssetModel extends SnipeModel
'name',
'notes',
'requestable',
'require_serial'
];
use Searchable;

View File

@@ -43,7 +43,7 @@ class Component extends SnipeModel
'location_id' => 'exists:locations,id|nullable|fmcs_location',
'min_amt' => 'integer|min:0|nullable',
'purchase_date' => 'date_format:Y-m-d|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:9999999999999',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'manufacturer_id' => 'integer|exists:manufacturers,id|nullable',
];

View File

@@ -47,7 +47,7 @@ class Consumable extends SnipeModel
'company_id' => 'integer|nullable',
'location_id' => 'exists:locations,id|nullable|fmcs_location',
'min_amt' => 'integer|min:0|max:99999|nullable',
'purchase_cost' => 'numeric|nullable|gte:0|max:9999999999999',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable',
];

View File

@@ -51,7 +51,7 @@ class License extends Depreciable
'notes' => 'string|nullable',
'category_id' => 'required|exists:categories,id',
'company_id' => 'integer|nullable',
'purchase_cost'=> 'numeric|nullable|gte:0',
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
'purchase_date' => 'date_format:Y-m-d|nullable|max:10|required_with:depreciation_id',
'expiration_date' => 'date_format:Y-m-d|nullable|max:10',
'termination_date' => 'date_format:Y-m-d|nullable|max:10',

View File

@@ -32,12 +32,12 @@ class Maintenance extends SnipeModel implements ICompanyableChild
'asset_id' => 'required|integer',
'supplier_id' => 'nullable|integer',
'asset_maintenance_type' => 'required',
'name' => 'required|max:100',
'name' => 'required|max:100',
'is_warranty' => 'boolean',
'start_date' => 'required|date_format:Y-m-d',
'completion_date' => 'date_format:Y-m-d|nullable|after_or_equal:start_date',
'notes' => 'string|nullable',
'cost' => 'numeric|nullable',
'cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
];

View File

@@ -12,7 +12,14 @@ trait HasUploads
return $this->hasMany(Actionlog::class, 'item_id')
->where('item_type', self::class)
->where('action_type', '=', 'uploaded')
->whereNotNull('filename');
->whereNotNull('filename')
->whereNotIn('filename', function ($query) {
$query->select('filename')
->from('action_logs')
->where('item_type', '=', self::class)
->where('action_type', '=', 'upload deleted')
->where('item_id', $this->id);
});
}

View File

@@ -62,6 +62,10 @@ class ActionlogPresenter extends Presenter
return 'fa-solid fa-user-minus';
}
if ($this->action_type == 'upload deleted') {
return 'fa-solid fa-trash';
}
if ($this->action_type == 'update') {
return 'fa-solid fa-user-pen';
}
@@ -74,7 +78,7 @@ class ActionlogPresenter extends Presenter
return 'fa-solid fa-plus';
}
if ($this->action_type == 'delete') {
if (($this->action_type == 'delete') || ($this->action_type == 'upload deleted')) {
return 'fa-solid fa-trash';
}

View File

@@ -143,6 +143,14 @@ class AssetModelPresenter extends Presenter
'title' => trans('admin/hardware/general.requestable'),
'formatter' => 'trueFalseFormatter',
],
[
'field' => 'require_serial',
'searchable' => false,
'sortable' => true,
'visible' => false,
'title' => trans('admin/hardware/general.require_serial'),
'formatter' => 'trueFalseFormatter',
],
[
'field' => 'notes',
'searchable' => true,

View File

@@ -33,6 +33,7 @@ class AssetModelFactory extends Factory
'category_id' => Category::factory(),
'model_number' => $this->faker->creditCardNumber(),
'notes' => 'Created by demo seeder',
'require_serial' => 0,
];
}

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('models', function (Blueprint $table) {
$table->boolean( 'require_serial')->after('category_id')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('models', function (Blueprint $table) {
$table->dropColumn('require_serial');
});
}
};

View File

@@ -44,6 +44,8 @@ return [
'redirect_to_checked_out_to' => 'Go to Checked Out to',
'select_statustype' => 'Select Status Type',
'serial' => 'Serial',
'serial_required' => 'Asset :number requires a serial number',
'serial_required_post_model_update' => ':asset_model have been updated to require a serial number. Please add a serial number for this asset.',
'status' => 'Status',
'tag' => 'Asset Tag',
'update' => 'Asset Update',

View File

@@ -22,6 +22,8 @@ return [
'requested' => 'Requested',
'not_requestable' => 'Not Requestable',
'requestable_status_warning' => 'Do not change requestable status',
'require_serial' => 'Require Serial Number',
'require_serial_help' => 'A serial number will be required when creating a new asset of this model.',
'restore' => 'Restore Asset',
'pending' => 'Pending',
'undeployable' => 'Undeployable',

View File

@@ -611,6 +611,7 @@ return [
'use_cloned_no_image_help' => 'This item does not have an associated image and instead inherits from the model or category it belongs to. If you would like to use a specific image for this item, you can upload a new one below.',
'footer_credit' => '<a target="_blank" href="https://snipeitapp.com" rel="noopener">Snipe-IT</a> is open source software, made with <i class="fa fa-heart" aria-hidden="true" style="color: #a94442; font-size: 10px" /></i><span class="sr-only">love</span> by <a href="https://bsky.app/profile/snipeitapp.com" rel="noopener">@snipeitapp.com</a>.',
'set_password' => 'Set a Password',
'upload_deleted' => 'Upload Deleted',
// Add form placeholders here
'placeholders' => [

View File

@@ -105,8 +105,6 @@
@include ('partials.forms.edit.maintenance_type')
@include ('partials.forms.edit.supplier-select', ['translated_name' => trans('general.supplier'), 'fieldname' => 'supplier_id'])
<!-- Start Date -->
<div class="form-group {{ $errors->has('start_date') ? ' has-error' : '' }}">
@@ -142,6 +140,9 @@
</div>
</div>
@include ('partials.forms.edit.supplier-select', ['translated_name' => trans('general.supplier'), 'fieldname' => 'supplier_id'])
<!-- Warranty -->
<div class="form-group">
<div class="col-sm-offset-3 col-sm-9">
@@ -155,7 +156,7 @@
<!-- Asset Maintenance Cost -->
<div class="form-group {{ $errors->has('cost') ? ' has-error' : '' }}">
<label for="cost" class="col-md-3 control-label">{{ trans('admin/maintenances/form.cost') }}</label>
<div class="col-md-2">
<div class="col-md-3">
<div class="input-group">
<span class="input-group-addon">
@if (($item->asset) && ($item->asset->location) && ($item->asset->location->currency!=''))
@@ -164,7 +165,7 @@
{{ $snipeSettings->default_currency }}
@endif
</span>
<input class="col-md-2 form-control" type="text" name="cost" id="cost" value="{{ old('cost', Helper::formatCurrencyOutput($item->cost)) }}" />
<input class="form-control" type="number" name="cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="cost" id="cost" value="{{ old('cost', $item->cost) }}" maxlength="25" />
{!! $errors->first('cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>

View File

@@ -91,7 +91,27 @@
</div>
@include ('partials.forms.edit.minimum_quantity')
<!-- require serial boolean -->
<div class="form-group">
<label for="require_serial" class="col-md-3 control-label">
{{ trans('admin/hardware/general.require_serial') }}
</label>
<div class="col-md-9">
<div class="form-inline" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="require_serial" value="1" id="require_serial" aria-label="require_serial" />
<a
href="#"
data-tooltip="true"
title="{{ trans('admin/hardware/general.require_serial_help') }}"
style="display: inline-flex; align-items: center;"
>
<x-icon type="info-circle" />
<span class="sr-only">{{ trans('admin/hardware/general.require_serial_help') }}</span>
</a>
</div>
</div>
</div>
<!-- requestable -->
<div class="form-group{{ $errors->has('requestable') ? ' has-error' : '' }}">

View File

@@ -16,6 +16,27 @@
@include ('partials.forms.edit.depreciation')
@include ('partials.forms.edit.minimum_quantity')
<!-- require serial boolean -->
<div class="form-group">
<label for="require_serial" class="col-md-3 control-label">
{{ trans('admin/hardware/general.require_serial') }}
</label>
<div class="col-md-9">
<div class="form-inline" style="display: flex; align-items: center; gap: 8px;">
<input type="checkbox" name="require_serial" value="1" @checked(old('require_serial', $item->require_serial)) id="require_serial" aria-label="require_serial" />
<a
href="#"
data-tooltip="true"
title="{{ trans('admin/hardware/general.require_serial_help') }}"
style="display: inline-flex; align-items: center;"
>
<x-icon type="info-circle" />
<span class="sr-only">{{ trans('admin/hardware/general.require_serial_help') }}</span>
</a>
</div>
</div>
</div>
<!-- EOL -->
<div class="form-group {{ $errors->has('eol') ? ' has-error' : '' }}">

View File

@@ -2,8 +2,8 @@
<div class="form-group {{ $errors->has('purchase_cost') ? ' has-error' : '' }}">
<label for="purchase_cost" class="col-md-3 control-label">{{ trans('general.purchase_cost') }}</label>
<div class="col-md-9">
<div class="input-group col-md-4" style="padding-left: 0px;">
<input class="form-control" type="number" name="purchase_cost" min="0.00" max="10000000.000" step="0.001" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', $item->purchase_cost) }}" maxlength="24" />
<div class="input-group col-md-5" style="padding-left: 0px;">
<input class="form-control" type="number" name="purchase_cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', $item->purchase_cost) }}" maxlength="25" />
<span class="input-group-addon">
@if (isset($currency_type))
{{ $currency_type }}

View File

@@ -2,7 +2,11 @@
<div class="form-group {{ $errors->has('serial') ? ' 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')) ? ' required' : '' }} maxlength="191" />
{!! $errors->first('serial', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
<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" />
@error($old_val_name ?? $fieldname)
<span class="alert-msg" aria-hidden="true">
<i class="fas fa-times" aria-hidden="true"></i> {{ $message }}
</span>
@enderror
</div>
</div>

View File

@@ -137,7 +137,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
/**
* Categpries API routes
* Categories API routes
*/
Route::group(['prefix' => 'categories'], function () {

View File

@@ -2,6 +2,8 @@
namespace Tests\Feature\Assets\Ui;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\User;
use Tests\TestCase;
@@ -13,4 +15,63 @@ class StoreAssetsTest extends TestCase
->get(route('hardware.create'))
->assertOk();
}
public function testAssetCanBeStoredWithSerialRequiredAndSerialProvided()
{
$user = User::factory()->superuser()->create();
$this->actingAs($user);
$model = AssetModel::factory()->create([
'require_serial' => 1,
]);
$response = $this->post(route('hardware.store'), [
'model_id' => $model->id,
'serials' => [1 => 'ABC123'],
'asset_tags' =>[1 => '1234'],
'status_id' => 1,
// other required fields...
]);
$response->assertRedirect();
$response->assertSessionHas('success-unescaped');
$this->assertNotEquals(
trans('admin/hardware/form.serial_required'),
session('error')
);
$this->assertDatabaseHas('assets', [
'model_id' => $model->id,
'serial' => 'ABC123',
'asset_tag' => '1234',
]);
}
public function testAssetCannotBeStoredIfSerialRequiredAndMissing()
{
$user = User::factory()->superuser()->create();
$this->actingAs($user);
$model = AssetModel::factory()->create([
'require_serial' => 1,
]);
$response = $this->post(route('hardware.store'), [
'model_id' => $model->id,
'serials' => [], // ← serial missing
'asset_tags' => [1 => '1234'],
'status_id' => 1,
]);
$response->assertRedirect();
$response->assertSessionHasErrors(['serials.1']);
$this->assertDatabaseMissing('assets', [
'model_id' => $model->id,
'asset_tag' => '1234',
]);
$response->assertSessionMissing('success-unescaped');
}
}