Merge pull request #16653 from snipe/add_audit_custom_fields

Fixed #13475 - Add custom fields to audit screen
This commit is contained in:
snipe 2025-04-08 05:16:23 +01:00 committed by GitHub
commit de426c2d2c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 527 additions and 145 deletions

View file

@ -34,6 +34,7 @@ use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use App\View\Label;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;
/**
@ -1064,7 +1065,7 @@ class AssetsController extends Controller
* @param int $id
* @since [v4.0]
*/
public function audit(Request $request): JsonResponse
public function audit(Request $request, Asset $asset): JsonResponse
{
$this->authorize('audit', Asset::class);
@ -1072,36 +1073,15 @@ class AssetsController extends Controller
$settings = Setting::getSettings();
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
// No tag passed - return an error
if (!$request->filled('asset_tag')) {
return response()->json(Helper::formatStandardApiResponse('error', [
'asset_tag' => '',
'error' => trans('admin/hardware/message.no_tag'),
], trans('admin/hardware/message.no_tag')), 200);
// Allow the asset tag to be passed in the payload (legacy method)
if ($request->filled('asset_tag')) {
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
if ($asset) {
/**
* Even though we do a save() further down, we don't want to log this as a "normal" asset update,
* which would trigger the Asset Observer and would log an asset *update* log entry (because the
* de-normed fields like next_audit_date on the asset itself will change on save()) *in addition* to
* the audit log entry we're creating through this controller.
*
* To prevent this double-logging (one for update and one for audit), we skip the observer and bypass
* that de-normed update log entry by using unsetEventDispatcher(), BUT invoking unsetEventDispatcher()
* will bypass normal model-level validation that's usually handled at the observer )
*
* We handle validation on the save() by checking if the asset is valid via the ->isValid() method,
* which manually invokes Watson Validating to make sure the asset's model is valid.
*
* @see \App\Observers\AssetObserver::updating()
*/
$asset->unsetEventDispatcher();
$originalValues = $asset->getRawOriginal();
$asset->next_audit_date = $dt;
if ($request->filled('next_audit_date')) {
@ -1116,33 +1096,89 @@ class AssetsController extends Controller
$asset->last_audit_date = date('Y-m-d H:i:s');
// Set up the payload for re-display in the API response
$payload = [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'note' => $request->input('note'),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
];
/**
* 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 (($asset->model) && ($asset->model->fieldset)) {
$payload['custom_fields'] = [];
foreach ($asset->model->fieldset->fields as $field) {
if (($field->display_audit=='1') && ($request->has($field->db_column))) {
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} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = $request->input($field->db_column);
}
}
$payload['custom_fields'][$field->db_column] = $request->input($field->db_column);
}
}
}
// Validate custom fields
Validator::make($asset->toArray(), $asset->customFieldValidationRules())->validate();
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()));
}
/**
* Even though we do a save() further down, we don't want to log this as a "normal" asset update,
* which would trigger the Asset Observer and would log an asset *update* log entry (because the
* de-normed fields like next_audit_date on the asset itself will change on save()) *in addition* to
* the audit log entry we're creating through this controller.
*
* To prevent this double-logging (one for update and one for audit), we skip the observer and bypass
* that de-normed update log entry by using unsetEventDispatcher(), BUT invoking unsetEventDispatcher()
* will bypass normal model-level validation that's usually handled at the observer)
*
* We handle validation on the save() by checking if the asset is valid via the ->isValid() method,
* which manually invokes Watson Validating to make sure the asset's model is valid.
*
* @see \App\Observers\AssetObserver::updating()
* @see \App\Models\Asset::save()
*/
$asset->unsetEventDispatcher();
/**
* Invoke Watson Validating to check the asset itself and check to make sure it saved correctly.
* We have to invoke this manually because of the unsetEventDispatcher() above.)
*/
if ($asset->isValid() && $asset->save()) {
$asset->logAudit(request('note'), request('location_id'));
return response()->json(Helper::formatStandardApiResponse('success', [
'asset_tag' => e($asset->asset_tag),
'note' => e($request->input('note')),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
], trans('admin/hardware/message.audit.success')));
$asset->logAudit(request('note'), request('location_id'), null, $originalValues);
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/hardware/message.audit.success')));
}
// Asset failed validation or was not able to be saved
return response()->json(Helper::formatStandardApiResponse('error', [
'asset_tag' => e($asset->asset_tag),
'error' => $asset->getErrors()->first(),
], trans('admin/hardware/message.audit.error', ['error' => $asset->getErrors()->first()])), 200);
}
// No matching asset for the asset tag that was passed.
return response()->json(Helper::formatStandardApiResponse('error', [
'asset_tag' => e($request->input('asset_tag')),
'error' => trans('admin/hardware/message.audit.error'),
], trans('admin/hardware/message.audit.error', ['error' => trans('admin/hardware/message.does_not_exist')])), 200);
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
}

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\UpdateAssetRequest;
use App\Models\Actionlog;
use App\Http\Requests\UploadFileRequest;
use Illuminate\Support\Facades\Log;
@ -390,26 +391,26 @@ class AssetsController extends Controller
$asset = $request->handleImages($asset);
// Update custom fields in the database.
// Validation for these fields is handlded through the AssetRequest form request
// FIXME: No idea why this is returning a Builder error on db_column_name.
// Need to investigate and fix. Using static method for now.
$model = AssetModel::find($request->get('model_id'));
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));
if ($request->has($field->db_column)) {
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} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = $request->input($field->db_column);
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);
}
}
}
}
@ -865,13 +866,6 @@ class AssetsController extends Controller
return view('hardware/quickscan-checkin')->with('statusLabel_list', Helper::statusLabelList());
}
public function audit(Asset $asset)
{
$settings = Setting::getSettings();
$this->authorize('audit', Asset::class);
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
return view('hardware/audit')->with('asset', $asset)->with('next_audit_date', $dt)->with('locations_list');
}
public function dueForAudit()
{
@ -888,19 +882,59 @@ class AssetsController extends Controller
}
public function audit(Asset $asset)
{
$settings = Setting::getSettings();
$this->authorize('audit', Asset::class);
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
return view('hardware/audit')->with('asset', $asset)->with('item', $asset)->with('next_audit_date', $dt)->with('locations_list');
}
public function auditStore(UploadFileRequest $request, Asset $asset)
{
$this->authorize('audit', Asset::class);
$rules = [
'location_id' => 'exists:locations,id|nullable|numeric',
'next_audit_date' => 'date|nullable',
];
$originalValues = $asset->getRawOriginal();
$validator = Validator::make($request->all(), $rules);
$asset->next_audit_date = $request->input('next_audit_date');
$asset->last_audit_date = date('Y-m-d H:i:s');
if ($validator->fails()) {
return response()->json(Helper::formatStandardApiResponse('error', null, $validator->errors()->all()));
// Check to see if they checked the box to update the physical location,
// not just note it in the audit notes
if ($request->input('update_location') == '1') {
$asset->location_id = $request->input('location_id');
}
// Update custom fields in the database
if (($asset->model) && ($asset->model->fieldset)) {
foreach ($asset->model->fieldset->fields as $field) {
if (($field->display_audit=='1') && ($request->has($field->db_column))) {
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} = implode(', ', $request->input($field->db_column));
} else {
$asset->{$field->db_column} = $request->input($field->db_column);
}
}
}
}
}
// Validate custom fields
Validator::make($asset->toArray(), $asset->customFieldValidationRules())->validate();
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return redirect()->back()->withInput()->withErrors($asset->getErrors());
}
/**
@ -917,18 +951,11 @@ class AssetsController extends Controller
* which manually invokes Watson Validating to make sure the asset's model is valid.
*
* @see \App\Observers\AssetObserver::updating()
* @see \App\Models\Asset::save()
*/
$asset->unsetEventDispatcher();
$asset->next_audit_date = $request->input('next_audit_date');
$asset->last_audit_date = date('Y-m-d H:i:s');
// Check to see if they checked the box to update the physical location,
// not just note it in the audit notes
if ($request->input('update_location') == '1') {
$asset->location_id = $request->input('location_id');
}
/**
* Invoke Watson Validating to check the asset itself and check to make sure it saved correctly.
@ -942,7 +969,7 @@ class AssetsController extends Controller
$file_name = $request->handleFile('private_uploads/audits/', 'audit-'.$asset->id, $request->file('image'));
}
$asset->logAudit($request->input('note'), $request->input('location_id'), $file_name);
$asset->logAudit($request->input('note'), $request->input('location_id'), $file_name, $originalValues);
return redirect()->route('assets.audit.due')->with('success', trans('admin/hardware/message.audit.success'));
}

View file

@ -106,6 +106,7 @@ class CustomFieldsController extends Controller
"show_in_requestable_list" => $request->get("show_in_requestable_list", 0),
"display_checkin" => $request->get("display_checkin", 0),
"display_checkout" => $request->get("display_checkout", 0),
"display_audit" => $request->get("display_audit", 0),
"created_by" => auth()->id()
]);
@ -250,6 +251,7 @@ class CustomFieldsController extends Controller
$field->show_in_requestable_list = $request->get("show_in_requestable_list", 0);
$field->display_checkin = $request->get("display_checkin", 0);
$field->display_checkout = $request->get("display_checkout", 0);
$field->display_audit = $request->get("display_audit", 0);
if ($request->get('format') == 'CUSTOM REGEX') {
$field->format = e($request->get('custom_format'));

View file

@ -243,7 +243,7 @@ class ReportsController extends Controller
$header = [
trans('general.date'),
trans('general.admin'),
trans('general.created_by'),
trans('general.action'),
trans('general.type'),
trans('general.item'),

View file

@ -10,19 +10,36 @@ trait MayContainCustomFields
// this gets called automatically on a form request
public function withValidator($validator)
{
// find the model
if ($this->method() == 'POST') {
$asset_model = AssetModel::find($this->model_id);
}
if ($this->method() == 'PATCH' || $this->method() == 'PUT') {
$asset_model = $this->asset->model;
// In case the model is being changed via form
if (request()->has('model_id')!='') {
$asset_model = AssetModel::find(request()->input('model_id'));
// or if we have it available to route-model-binding
} elseif ((request()->route('asset') && (request()->route('asset')->model_id))) {
$asset_model = AssetModel::find(request()->route('asset')->model_id);
} else {
if ($this->method() == 'POST') {
$asset_model = AssetModel::find($this->model_id);
}
if ($this->method() == 'PATCH' || $this->method() == 'PUT') {
$asset_model = $this->asset->model;
}
}
// collect the custom fields in the request
$validator->after(function ($validator) use ($asset_model) {
$request_fields = $this->collect()->keys()->filter(function ($attributes) {
return str_starts_with($attributes, '_snipeit_');
});
// if there are custom fields, find the one's that don't exist on the model's fieldset and add an error to the validator's error bag
// if there are custom fields, find the ones that don't exist on the model's fieldset and add an error to the validator's error bag
if (count($request_fields) > 0 && $validator->errors()->isEmpty()) {
$request_fields->diff($asset_model?->fieldset?->fields?->pluck('db_column'))
->each(function ($request_field_name) use ($request_fields, $validator) {

View file

@ -50,6 +50,9 @@ class CustomFieldsTransformer
'display_in_user_view' => ($field->display_in_user_view =='1') ? true : false,
'auto_add_to_fieldsets' => ($field->auto_add_to_fieldsets == '1') ? true : false,
'show_in_listview' => ($field->show_in_listview == '1') ? true : false,
'display_checkin' => ($field->display_checkin == '1') ? true : false,
'display_checkout' => ($field->display_checkout == '1') ? true : false,
'display_audit' => ($field->display_audit == '1') ? true : false,
'created_at' => Helper::getFormattedDateObject($field->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($field->updated_at, 'datetime'),
];

View file

@ -213,6 +213,31 @@ class Asset extends Depreciable
$this->attributes['expected_checkin'] = $value;
}
public function customFieldValidationRules()
{
$customFieldValidationRules = [];
if (($this->model) && ($this->model->fieldset)) {
foreach ($this->model->fieldset->fields as $field) {
if ($field->format == 'BOOLEAN'){
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
$customFieldValidationRules += $this->model->fieldset->validation_rules();
}
return $customFieldValidationRules;
}
/**
* This handles the custom field validation for assets
*
@ -220,29 +245,7 @@ class Asset extends Depreciable
*/
public function save(array $params = [])
{
if ($this->model_id != '') {
$model = AssetModel::find($this->model_id);
if (($model) && ($model->fieldset)) {
foreach ($model->fieldset->fields as $field){
if($field->format == 'BOOLEAN'){
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
$this->rules += $model->fieldset->validation_rules();
if ($this->model->fieldset){
foreach ($this->model->fieldset->fields as $field){
if($field->format == 'BOOLEAN'){
$this->{$field->db_column} = filter_var($this->{$field->db_column}, FILTER_VALIDATE_BOOLEAN);
}
}
}
}
}
$this->rules += $this->customFieldValidationRules();
return parent::save($params);
}
@ -254,7 +257,7 @@ class Asset extends Depreciable
/**
* Returns the warranty expiration date as Carbon object
* @return \Carbon|null
* @return \Carbon\Carbon|null
*/
public function getWarrantyExpiresAttribute()
{
@ -687,6 +690,21 @@ class Asset extends Depreciable
->withTrashed();
}
/**
* Get the list of audits for this asset
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v2.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function audits()
{
return $this->assetlog()->where('action_type', '=', 'audit')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* Get the list of checkins for this asset
*

View file

@ -220,9 +220,41 @@ trait Loggable
* @since [v4.0]
* @return \App\Models\Actionlog
*/
public function logAudit($note, $location_id, $filename = null)
public function logAudit($note, $location_id, $filename = null, $originalValues = [])
{
$log = new Actionlog;
if (static::class == Asset::class) {
if ($asset = Asset::find($log->item_id)) {
// add the custom fields that were changed
if ($asset->model->fieldset) {
$fields_array = [];
foreach ($asset->model->fieldset->fields as $field) {
if ($field->display_audit == 1) {
$fields_array[$field->db_column] = $asset->{$field->db_column};
}
}
}
}
}
$changed = [];
unset($originalValues['updated_at'], $originalValues['last_audit_date']);
foreach ($originalValues as $key => $value) {
if ($value != $this->getAttributes()[$key]) {
$changed[$key]['old'] = $value;
$changed[$key]['new'] = $this->getAttributes()[$key];
}
}
if (!empty($changed)){
$log->log_meta = json_encode($changed);
}
$location = Location::find($location_id);
if (static::class == LicenseSeat::class) {
$log->item_type = License::class;
@ -235,6 +267,7 @@ trait Loggable
$log->note = $note;
$log->created_by = auth()->id();
$log->filename = $filename;
$log->action_date = date('Y-m-d H:i:s');
$log->logaction('audit');
$params = [
@ -276,6 +309,7 @@ trait Loggable
$log->item_id = $this->id;
}
$log->location_id = null;
$log->action_date = date('Y-m-d H:i:s');
$log->note = $note;
$log->created_by = $created_by;
$log->logaction('create');
@ -303,6 +337,7 @@ trait Loggable
$log->note = $note;
$log->target_id = null;
$log->created_at = date('Y-m-d H:i:s');
$log->action_date = date('Y-m-d H:i:s');
$log->filename = $filename;
$log->logaction('uploaded');

View file

@ -229,7 +229,7 @@ class AccessoryPresenter extends Presenter
'field' => 'created_by',
'searchable' => false,
'sortable' => false,
'title' => trans('general.admin'),
'title' => trans('general.created_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
],

View file

@ -102,6 +102,10 @@ class ActionlogPresenter extends Presenter
return 'fas fa-sticky-note';
}
if ($this->action_type == 'audit') {
return 'fas fa-clipboard-check';
}
return 'fa-solid fa-rotate-right';
}

View file

@ -262,7 +262,7 @@ class LocationPresenter extends Presenter
'field' => 'created_by',
'searchable' => false,
'sortable' => false,
'title' => trans('general.admin'),
'title' => trans('general.created_by'),
'visible' => false,
'formatter' => 'usersLinkObjFormatter',
],

View file

@ -385,6 +385,12 @@ class UserFactory extends Factory
return $this->appendPermission(['suppliers.delete' => '1']);
}
public function auditAssets()
{
return $this->appendPermission(['assets.audit' => '1']);
}
private function appendPermission(array $permission)
{
return $this->state(function ($currentState) use ($permission) {

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('custom_fields', function (Blueprint $table) {
$table->boolean('display_audit')->default(0);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('custom_fields', function (Blueprint $table) {
$table->dropColumn('display_audit');
});
}
};

View file

@ -1175,7 +1175,8 @@ th.css-history > .th-inner::before {
padding: 6px 12px;
height: 34px;
}
.form-group.has-error label {
.form-group.has-error label,
.form-group.has-error .help-block {
color: #a94442;
}
.select2-container--default .select2-selection--multiple {

View file

@ -806,7 +806,8 @@ th.css-history > .th-inner::before {
padding: 6px 12px;
height: 34px;
}
.form-group.has-error label {
.form-group.has-error label,
.form-group.has-error .help-block {
color: #a94442;
}
.select2-container--default .select2-selection--multiple {

View file

@ -22510,7 +22510,8 @@ th.css-history > .th-inner::before {
padding: 6px 12px;
height: 34px;
}
.form-group.has-error label {
.form-group.has-error label,
.form-group.has-error .help-block {
color: #a94442;
}
.select2-container--default .select2-selection--multiple {
@ -24061,7 +24062,8 @@ th.css-history > .th-inner::before {
padding: 6px 12px;
height: 34px;
}
.form-group.has-error label {
.form-group.has-error label,
.form-group.has-error .help-block {
color: #a94442;
}
.select2-container--default .select2-selection--multiple {

View file

@ -2,8 +2,8 @@
"/js/build/app.js": "/js/build/app.js?id=607de09b70b83ef82a427e4b36341682",
"/css/dist/skins/skin-black-dark.css": "/css/dist/skins/skin-black-dark.css?id=06c13e817cc022028b3f4a33c0ca303a",
"/css/dist/skins/_all-skins.css": "/css/dist/skins/_all-skins.css?id=79aa889a1a6691013be6c342ca7391cd",
"/css/build/overrides.css": "/css/build/overrides.css?id=712a9b236379148e57e34b70c878bff9",
"/css/build/app.css": "/css/build/app.css?id=9e4ea63fed30049dc054a87a0acfaa61",
"/css/build/overrides.css": "/css/build/overrides.css?id=4d62149a0ee9dc139bdf03ff2f83930d",
"/css/build/app.css": "/css/build/app.css?id=d47ce0dc14671bb4e462e111001488e5",
"/css/build/AdminLTE.css": "/css/build/AdminLTE.css?id=4ea0068716c1bb2434d87a16d51b98c9",
"/css/dist/skins/skin-yellow.css": "/css/dist/skins/skin-yellow.css?id=7b315b9612b8fde8f9c5b0ddb6bba690",
"/css/dist/skins/skin-yellow-dark.css": "/css/dist/skins/skin-yellow-dark.css?id=ea22079836a432d7f46a5d390c445e13",
@ -19,7 +19,7 @@
"/css/dist/skins/skin-blue.css": "/css/dist/skins/skin-blue.css?id=a82b065847bf3cd5d713c04ee8dc86c6",
"/css/dist/skins/skin-blue-dark.css": "/css/dist/skins/skin-blue-dark.css?id=6ea836d8126de101081c49abbdb89417",
"/css/dist/skins/skin-black.css": "/css/dist/skins/skin-black.css?id=76482123f6c70e866d6b971ba91de7bb",
"/css/dist/all.css": "/css/dist/all.css?id=0d5289d806efaf15e9d47833843cc986",
"/css/dist/all.css": "/css/dist/all.css?id=7c861c2086473c513fe26c21e3c4d433",
"/css/dist/signature-pad.css": "/css/dist/signature-pad.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/css/dist/signature-pad.min.css": "/css/dist/signature-pad.min.css?id=6a89d3cd901305e66ced1cf5f13147f7",
"/js/select2/i18n/af.js": "/js/select2/i18n/af.js?id=4f6fcd73488ce79fae1b7a90aceaecde",

View file

@ -886,7 +886,7 @@ th.css-history > .th-inner::before {
height: 34px;
}
.form-group.has-error label {
.form-group.has-error label, .form-group.has-error .help-block {
color: #a94442;
}

View file

@ -59,5 +59,6 @@ return [
'encrypted_options' => 'This field is encrypted, so some display options will not be available.',
'display_checkin' => 'Display in checkin forms',
'display_checkout' => 'Display in checkout forms',
'display_audit' => 'Display in audit forms',
];

View file

@ -31,6 +31,7 @@ return [
'accept_assets_menu' => 'Accept Assets',
'accept_item' => 'Accept Item',
'audit' => 'Audit',
'audits' => 'Audits',
'audit_report' => 'Audit Log',
'assets' => 'Assets',
'assets_audited' => 'assets audited',

View file

@ -125,7 +125,7 @@
<thead>
<tr>
<th class="col-sm-2" data-visible="false" data-sortable="true" data-field="created_at" data-formatter="dateDisplayFormatter">{{ trans('general.record_created') }}</th>
<th class="col-sm-2"data-visible="true" data-sortable="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th class="col-sm-2"data-visible="true" data-sortable="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th class="col-sm-2" data-sortable="true" data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-field="file" data-visible="false" data-formatter="fileUploadNameFormatter">{{ trans('general.file_name') }}</th>
<th class="col-sm-2" data-sortable="true" data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>

View file

@ -421,7 +421,7 @@
{{ trans('general.date') }}
</th>
<th data-searchable="false" data-sortable="false" data-field="note">{{ trans('general.notes') }}</th>
<th data-searchable="false" data-sortable="false" data-field="admin">{{ trans('general.admin') }}</th>
<th data-searchable="false" data-sortable="false" data-field="admin">{{ trans('general.created_by') }}</th>
</tr>
</thead>
</table>
@ -472,7 +472,7 @@
<tr>
<th data-visible="true" data-field="icon" style="width: 40px;" class="hidden-xs" data-formatter="iconFormatter">{{ trans('admin/hardware/table.icon') }}</th>
<th data-visible="true" data-field="action_date" data-sortable="true" data-formatter="dateDisplayFormatter">{{ trans('general.date') }}</th>
<th data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-field="file" data-visible="false" data-formatter="fileUploadNameFormatter">{{ trans('general.file_name') }}</th>
<th data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>

View file

@ -224,6 +224,14 @@
</label>
</div>
<!-- Show in Audit Form -->
<div class="col-md-9 col-md-offset-3" id="display_audit" style="padding-bottom: 10px;">
<label class="form-control">
<input type="checkbox" name="display_audit" aria-label="display_audit" value="1" {{ (old('display_audit') || $field->display_audit) ? ' checked="checked"' : '' }}>
{{ trans('admin/custom_fields/general.display_audit') }}
</label>
</div>
<!-- Show in View All Assets profile view -->
<div class="col-md-9 col-md-offset-3" id="display_in_user_view">

View file

@ -196,6 +196,14 @@
</span>
</th>
<th data-sortable="true" data-visible="false" data-searchable="false" class="text-center"
data-tooltip="{{ trans('admin/custom_fields/general.display_audit') }}">
<x-icon type="due" />
<span class="sr-only">
{{ trans('admin/custom_fields/general.display_audit') }}
</span>
</th>
<th data-sortable="true" data-searchable="true" class="text-center">{{ trans('admin/custom_fields/general.field_element_short') }}</th>
@ -227,6 +235,7 @@
<td class="text-center">{!! ($field->is_unique=='1') ? '<i class="fas fa-check text-success" aria-hidden="true"><span class="sr-only">'.trans('general.yes').'</span></i>' : '<i class="fas fa-times text-danger" aria-hidden="true"><span class="sr-only">'.trans('general.no').'</span></i>' !!}</td>
<td class="text-center">{!! ($field->display_checkin=='1') ? '<i class="fas fa-check text-success" aria-hidden="true"><span class="sr-only">'.trans('general.yes').'</span></i>' : '<i class="fas fa-times text-danger" aria-hidden="true"><span class="sr-only">'.trans('general.no').'</span></i>' !!}</td>
<td class="text-center">{!! ($field->display_checkout=='1') ? '<i class="fas fa-check text-success" aria-hidden="true"><span class="sr-only">'.trans('general.yes').'</span></i>' : '<i class="fas fa-times text-danger" aria-hidden="true"><span class="sr-only">'.trans('general.no').'</span></i>' !!}</td>
<td class="text-center">{!! ($field->display_audit=='1') ? '<i class="fas fa-check text-success" aria-hidden="true"><span class="sr-only">'.trans('general.yes').'</span></i>' : '<i class="fas fa-times text-danger" aria-hidden="true"><span class="sr-only">'.trans('general.no').'</span></i>' !!}</td>
<td>{{ $field->element }}</td>
<td>
@foreach($field->fieldset as $fieldset)

View file

@ -115,6 +115,12 @@
</div>
</div>
<!-- Custom fields -->
@include("models/custom_fields_form", [
'model' => $asset->model,
'show_display_checkout_fields' => 'true'
])
<!-- Note -->
<div class="form-group{{ $errors->has('note') ? ' has-error' : '' }}">

View file

@ -142,7 +142,7 @@
var formData = $('#audit-form').serializeArray();
$.ajax({
url: "{{ route('api.asset.audit') }}",
url: "{{ route('api.asset.audit.legacy') }}",
type : 'POST',
headers: {
"X-Requested-With": 'XMLHttpRequest',

View file

@ -111,6 +111,22 @@
@endif
@if ($asset->audits->count() > 0)
<li>
<a href="#audits" data-toggle="tab" data-tooltip="true">
<span class="hidden-lg hidden-md">
<i class="fas fa-clipboard-check fa-2x"></i>
</span>
<span class="hidden-xs hidden-sm">
{{ trans('general.audits') }}
{!! ($asset->audits()->count() > 0 ) ? '<span class="badge badge-secondary">'.number_format($asset->audits()->count()).'</span>' : '' !!}
</span>
</a>
</li>
@endif
<li>
<a href="#history" data-toggle="tab">
<span class="hidden-lg hidden-md">
@ -1390,7 +1406,53 @@
</div> <!-- /.row -->
</div> <!-- /.tab-pane maintenances -->
<div class="tab-pane fade" id="history">
<div class="tab-pane fade" id="audits">
<!-- checked out assets table -->
<div class="row">
<div class="col-md-12">
<table
class="table table-striped snipe-table"
id="asseAuditHistory"
data-pagination="true"
data-id-table="asseAuditHistory"
data-search="true"
data-side-pagination="server"
data-show-columns="true"
data-show-fullscreen="true"
data-show-refresh="true"
data-sort-order="desc"
data-sort-name="created_at"
data-show-export="true"
data-export-options='{
"fileName": "export-asset-{{ $asset->id }}-audits",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'
data-url="{{ route('api.activity.index', ['item_id' => $asset->id, 'item_type' => 'asset', 'action_type' => 'audit']) }}"
data-cookie-id-table="assetHistory"
data-cookie="true">
<thead>
<tr>
<th data-visible="true" data-field="icon" style="width: 40px;" class="hidden-xs" data-formatter="iconFormatter">{{ trans('admin/hardware/table.icon') }}</th>
<th data-visible="true" data-field="created_at" data-sortable="true" data-formatter="dateDisplayFormatter">{{ trans('general.date') }}</th>
<th data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th class="col-sm-2" data-field="file" data-sortable="true" data-visible="false" data-formatter="fileUploadNameFormatter">{{ trans('general.file_name') }}</th>
<th data-field="note">{{ trans('general.notes') }}</th>
<th data-visible="false" data-field="file" data-visible="false" data-formatter="fileUploadFormatter">{{ trans('general.download') }}</th>
<th data-field="log_meta" data-visible="true" data-formatter="changeLogFormatter">{{ trans('admin/hardware/table.changed')}}</th>
<th data-field="remote_ip" data-visible="false" data-sortable="true">{{ trans('admin/settings/general.login_ip') }}</th>
<th data-field="user_agent" data-visible="false" data-sortable="true">{{ trans('admin/settings/general.login_user_agent') }}</th>
<th data-field="action_source" data-visible="false" data-sortable="true">{{ trans('general.action_source') }}</th>
</tr>
</thead>
</table>
</div>
</div> <!-- /.row -->
</div> <!-- /.tab-pane history -->
<div class="tab-pane fade" id="history">
<!-- checked out assets table -->
<div class="row">
<div class="col-md-12">
@ -1419,7 +1481,7 @@
<tr>
<th data-visible="true" data-field="icon" style="width: 40px;" class="hidden-xs" data-formatter="iconFormatter">{{ trans('admin/hardware/table.icon') }}</th>
<th data-visible="true" data-field="action_date" data-sortable="true" data-formatter="dateDisplayFormatter">{{ trans('general.date') }}</th>
<th data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-field="file" data-visible="false" data-formatter="fileUploadNameFormatter">{{ trans('general.file_name') }}</th>
<th data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>

View file

@ -526,7 +526,7 @@ dir="{{ Helper::determineLanguageDirection() }}">
@can('audit', \App\Models\Asset::class)
<li{!! (Request::is('hardware/audit/due') ? ' class="active"' : '') !!}>
<a href="{{ route('assets.audit.due') }}">
<x-icon type="due" class="text-yellow fa-fw"/>
<x-icon type="audit" class="text-yellow fa-fw"/>
{{ trans('general.audit_due') }}
<span class="badge">{{ (isset($total_due_and_overdue_for_audit)) ? $total_due_and_overdue_for_audit : '' }}</span>
</a>

View file

@ -498,7 +498,7 @@
<thead>
<tr>
<th class="col-sm-2" data-visible="false" data-sortable="true" data-field="created_at" data-formatter="dateDisplayFormatter">{{ trans('general.record_created') }}</th>
<th class="col-sm-2"data-visible="true" data-sortable="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th class="col-sm-2"data-visible="true" data-sortable="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th class="col-sm-2" data-sortable="true" data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-field="file" data-visible="false" data-formatter="fileUploadNameFormatter">{{ trans('general.file_name') }}</th>
<th class="col-sm-2" data-sortable="true" data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>

View file

@ -434,7 +434,7 @@
<tr>
<th data-visible="true" data-field="icon" style="width: 40px;" class="hidden-xs" data-formatter="iconFormatter">{{ trans('admin/hardware/table.icon') }}</th>
<th class="col-sm-2" data-visible="true" data-field="action_date" data-formatter="dateDisplayFormatter">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th class="col-sm-1" data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th class="col-sm-1" data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>
<th class="col-sm-2" data-visible="true" data-field="target" data-formatter="polymorphicItemFormatter">{{ trans('general.target') }}</th>

View file

@ -101,10 +101,4 @@
@endif
<script nonce="{{ csrf_token() }}">
// We have to re-call the tooltip since this is pulled in after the DOM has loaded
$('[data-tooltip="true"]').tooltip({
container: 'body',
animation: true,
});
</script>

View file

@ -341,7 +341,7 @@
function hardwareAuditFormatter(value, row) {
return '<a href="{{ config('app.url') }}/hardware/audit/' + row.id + '/" class="btn btn-sm bg-yellow" data-tooltip="true" title="Audit this item">{{ trans('general.audit') }}</a>';
return '<a href="{{ config('app.url') }}/hardware/' + row.id + '/audit" class="btn btn-sm bg-yellow" data-tooltip="true" title="Audit this item">{{ trans('general.audit') }}</a>';
}

View file

@ -46,7 +46,7 @@
<th data-sortable="true" data-field="location" data-formatter="deployedLocationFormatter" data-visible="false">{{ trans('general.location') }}</th>
<th data-sortable="true" data-field="rtd_location" data-formatter="deployedLocationFormatter" data-visible="false">{{ trans('admin/hardware/form.default_location') }}</th>
<th data-searchable="true" data-sortable="true" data-field="is_warranty" data-formatter="trueFalseFormatter">{{ trans('admin/asset_maintenances/table.is_warranty') }}</th>
<th data-searchable="true" data-sortable="true" data-field="user_id" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th data-searchable="true" data-sortable="true" data-field="user_id" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th data-searchable="true" data-sortable="true" data-field="notes" data-visible="false">{{ trans('admin/asset_maintenances/form.notes') }}</th>
</tr>
</thead>

View file

@ -1003,7 +1003,7 @@
<th data-field="signature_file" data-visible="false" data-formatter="imageFormatter">{{ trans('general.signature') }}</th>
@endif
<th data-field="item.serial" data-visible="false">{{ trans('admin/hardware/table.serial') }}</th>
<th data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th data-field="remote_ip" data-visible="false" data-sortable="true">{{ trans('admin/settings/general.login_ip') }}</th>
<th data-field="user_agent" data-visible="false" data-sortable="true">{{ trans('admin/settings/general.login_user_agent') }}</th>
<th data-field="action_source" data-visible="false" data-sortable="true">{{ trans('general.action_source') }}</th>

View file

@ -511,8 +511,17 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'throttle:api']], functi
->where(['action' => 'audit|audits|checkins', 'upcoming_status' => 'due|overdue|due-or-overdue']);
// Legacy URL for audit
Route::post('audit',
[
Api\AssetsController::class,
'audit'
]
)->name('api.asset.audit.legacy');
Route::post('audit',
// Newer url for audit
Route::post('{asset}/audit',
[
Api\AssetsController::class,
'audit'

View file

@ -63,14 +63,14 @@ Route::group(
->push(trans_choice('general.checkin_due_days', Setting::getSettings()->due_checkin_days, ['days' => Setting::getSettings()->due_checkin_days]), route('assets.audit.due'))
);
Route::get('audit/{asset}', [AssetsController::class, 'audit'])
Route::get('{asset}/audit', [AssetsController::class, 'audit'])
->name('asset.audit.create')
->breadcrumbs(fn (Trail $trail, Asset $asset) =>
$trail->parent('hardware.show', $asset)
->push(trans('general.audit'))
);
Route::post('audit/{asset}',
Route::post('{asset}/audit',
[AssetsController::class, 'auditStore']
)->name('asset.audit.store');

View file

@ -0,0 +1,78 @@
<?php
namespace Tests\Feature\Assets\Api;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\Location;
use App\Models\Statuslabel;
use App\Models\Supplier;
use App\Models\User;
use App\Models\CustomField;
use Illuminate\Support\Facades\Crypt;
use Tests\TestCase;
class AuditAssetTest extends TestCase
{
public function testThatANonExistentAssetIdReturnsError()
{
$this->actingAsForApi(User::factory()->auditAssets()->create())
->postJson(route('api.asset.audit', 123456789))
->assertStatusMessageIs('error');
}
public function testRequiresPermissionToAuditAsset()
{
$asset = Asset::factory()->create();
$this->actingAsForApi(User::factory()->create())
->postJson(route('api.asset.audit', $asset))
->assertForbidden();
}
public function testLegacyAssetAuditIsSaved()
{
$asset = Asset::factory()->create();
$this->actingAsForApi(User::factory()->auditAssets()->create())
->postJson(route('api.asset.audit.legacy'), [
'asset_tag' => $asset->asset_tag,
'note' => 'test',
])
->assertStatusMessageIs('success')
->assertJson(
[
'messages' =>trans('admin/hardware/message.audit.success'),
'payload' => [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'note' => 'test'
],
])
->assertStatus(200);
}
public function testAssetAuditIsSaved()
{
$asset = Asset::factory()->create();
$this->actingAsForApi(User::factory()->auditAssets()->create())
->postJson(route('api.asset.audit', $asset), [
'note' => 'test'
])
->assertStatusMessageIs('success')
->assertJson(
[
'messages' =>trans('admin/hardware/message.audit.success'),
'payload' => [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'note' => 'test'
],
])
->assertStatus(200);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace Tests\Feature\Assets\Ui;
use App\Models\Asset;
use App\Models\User;
use Tests\TestCase;
class AuditAssetTest extends TestCase
{
public function testPermissionRequiredToCreateAssetModel()
{
$this->actingAs(User::factory()->create())
->get(route('clone/hardware', Asset::factory()->create()))
->assertForbidden();
}
public function testPageCanBeAccessed(): void
{
$this->actingAs(User::factory()->auditAssets()->create())
->get(route('asset.audit.create', Asset::factory()->create()))
->assertStatus(200);
}
public function testAssetCanBeAudited()
{
$response = $this->actingAs(User::factory()->auditAssets()->create())
->post(route('asset.audit.store', Asset::factory()->create()))
->assertStatus(302)
->assertRedirect(route('assets.audit.due'));
$this->followRedirects($response)->assertSee('success');
}
}