diff --git a/README.md b/README.md index 331b2fa6b9..c3e4acdfd8 100644 --- a/README.md +++ b/README.md @@ -76,26 +76,37 @@ Since the release of the JSON REST API, several third-party developers have been > [!NOTE] > As these were created by third-parties, Snipe-IT cannot provide support for these project, and you should contact the developers directly if you need assistance. Additionally, Snipe-IT makes no guarantees as to the reliability, accuracy or maintainability of these libraries. Use at your own risk. :) -- [Python Module](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer) +#### Libraries & Modules + - [SnipeSharp - .NET module in C#](https://github.com/barrycarey/SnipeSharp) by [@barrycarey](https://github.com/barrycarey) -- [InQRy -unmaintained-](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft) - [SnipeitPS](https://github.com/snazy2000/SnipeitPS) by [@snazy2000](https://github.com/snazy2000) - Powershell API Wrapper for Snipe-it - [jamf2snipe](https://github.com/grokability/jamf2snipe) - Python script to sync assets between a JAMFPro instance and a Snipe-IT instance - [jamf-snipe-rename](https://macblog.org/jamf-snipe-rename/) - Python script to rename computers in Jamf from Snipe-IT -- [Marksman](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT - [Snipe-IT plugin for Jira Service Desk](https://marketplace.atlassian.com/apps/1220964/snipe-it-for-jira) - [Python 3 CSV importer](https://github.com/gastamper/snipeit-csvimporter) - allows importing assets into Snipe-IT based on Item Name rather than Asset Tag. - [Snipe-IT Kubernetes Helm Chart](https://github.com/t3n/helm-charts/tree/master/snipeit) - For more information, [click here](https://hub.helm.sh/charts/t3n/snipeit). - [Snipe-IT Bulk Edit](https://github.com/bricelabelle/snipe-it-bulkedit) - Google Script files to use Google Sheets as a bulk checkout/checkin/edit tool for Snipe-IT. - [MosyleSnipeSync](https://github.com/RodneyLeeBrands/MosyleSnipeSync) by [@Karpadiem](https://github.com/Karpadiem) - Python script to synchronize information between Mosyle and Snipe-IT. - [WWW::SnipeIT](https://github.com/SEDC/perl-www-snipeit) by [@SEDC](https://github.com/SEDC) - perl module for accessing the API -- [UniFi to Snipe-IT](https://github.com/RodneyLeeBrands/UnifiSnipeSync) by [@karpadiem](https://github.com/karpadiem) - Python script that synchronizes UniFi devices with Snipe-IT. +- [UniFi to Snipe-IT](https://www.edtechirl.com/p/snipe-it-and-azure-asset-management) originally by [@karpadiem](https://github.com/karpadiem) - Python script that synchronizes UniFi devices with Snipe-IT. - [Kandji2Snipe](https://github.com/grokability/kandji2snipe) by [@briangoldstein](https://github.com/briangoldstein) - Python script that synchronizes Kandji with Snipe-IT. - [SnipeAgent](https://github.com/ReticentRobot/SnipeAgent) by [@ReticentRobot](https://github.com/ReticentRobot) - Windows agent for Snipe-IT. - [Gate Pass Generator](https://github.com/cha7uraAE/snipe-it-gate-pass-system) by [@cha7uraAE](https://github.com/cha7uraAE) - A Streamlit application for generating gate passes based on hardware data from a Snipe-IT API. +- [InQRy (archived)](https://github.com/Microsoft/InQRy) by [@Microsoft](https://github.com/Microsoft) +- [Marksman (archived)](https://github.com/Scope-IT/marksman) - A Windows agent for Snipe-IT +- [Python Module (archived)](https://github.com/jbloomer/SnipeIT-PythonAPI) by [@jbloomer](https://github.com/jbloomer) We also have a handful of [Google Apps scripts](https://github.com/grokability/google-apps-scripts-for-snipe-it) to help with various tasks. +#### Mobile Apps + +We're currently working on our own mobile app, but in the meantime, check out these third-party apps that work with Snipe-IT: + +- [SnipeMate](https://snipemate.app/) (iOS, Google Play, Huawei AppGallery) by Mars Technology +- [Snipe-Scan](https://apps.apple.com/do/app/snipe-scan/id6744179400?uo=2) (iOS) by Nicolas Maton +- [Snipe-IT Assets Management](https://play.google.com/store/apps/details?id=com.diegogarciadev.assetsmanager.snipeit&hl=en&pli=1) (Google Play) by DiegoGarciaDEV +- [AssetX](https://apps.apple.com/my/app/assetx-for-snipe-it/id6741996196?uo=2) (iOS) for Snipe-IT by Rishi Gupta + ----- ### Join the Community! diff --git a/app/Console/Commands/FixBulkAccessoryCheckinActionLogEntries.php b/app/Console/Commands/FixBulkAccessoryCheckinActionLogEntries.php new file mode 100644 index 0000000000..694d8d3b30 --- /dev/null +++ b/app/Console/Commands/FixBulkAccessoryCheckinActionLogEntries.php @@ -0,0 +1,151 @@ +skipBackup = $this->option('skip-backup'); + $this->dryrun = $this->option('dry-run'); + + if ($this->dryrun) { + $this->info('This is a DRY RUN - no changes will be saved.'); + $this->newLine(); + } + + $logs = Actionlog::query() + // only look for accessory checkin logs + ->where('item_type', Accessory::class) + // that were part of a bulk checkin + ->where('note', 'Bulk checkin items') + // logs that were improperly timestamped should have created_at in the 1970s + ->whereYear('created_at', '1970') + ->get(); + + if ($logs->isEmpty()) { + $this->info('No logs found with incorrect timestamps.'); + return 0; + } + + $this->info('Found ' . $logs->count() . ' logs with incorrect timestamps:'); + + $this->table( + ['ID', 'Created By', 'Created At', 'Updated At'], + $logs->map(function ($log) { + return [ + $log->id, + $log->created_by, + $log->created_at, + $log->updated_at, + ]; + }) + ); + + if (!$this->dryrun && !$this->confirm('Update these logs?')) { + return 0; + } + + if (!$this->dryrun && !$this->skipBackup) { + $this->info('Backing up the database before making changes...'); + $this->call('snipeit:backup'); + } + + if ($this->dryrun) { + $this->newLine(); + $this->info('DRY RUN. NOT ACTUALLY UPDATING LOGS.'); + } + + foreach ($logs as $log) { + $this->newLine(); + $this->info('Processing log id:' . $log->id); + + // created_by was not being set for accessory bulk checkins + // so let's see if there was another bulk checkin log + // with the same timestamp and a created_by value we can use. + if (is_null($log->created_by)) { + $createdByFromSimilarLog = $this->getCreatedByAttributeFromSimilarLog($log); + + if ($createdByFromSimilarLog) { + $this->line(vsprintf('Updating log id:%s created_by to %s', [$log->id, $createdByFromSimilarLog])); + $log->created_by = $createdByFromSimilarLog; + } else { + $this->warn(vsprintf('No created_by found for log id:%s', [$log->id])); + $this->warn('Skipping updating this log since no similar log was found to update created_by from.'); + + // If we can't find a similar log then let's skip updating it + continue; + } + } + + $this->line(vsprintf('Updating log id:%s from %s to %s', [$log->id, $log->created_at, $log->updated_at])); + $log->created_at = $log->updated_at; + + if (!$this->dryrun) { + Model::withoutTimestamps(function () use ($log) { + $log->saveQuietly(); + }); + } + } + + $this->newLine(); + + if ($this->dryrun) { + $this->info('DRY RUN. NO CHANGES WERE ACTUALLY MADE.'); + } + + return 0; + } + + /** + * Hopefully the bulk checkin included other items like assets or licenses + * so we can use one of those logs to get the correct created_by value. + * + * This method attempts to find a bulk check in log that was + * created at the same time as the log passed in. + */ + private function getCreatedByAttributeFromSimilarLog(Actionlog $log): null|int + { + $similarLog = Actionlog::query() + ->whereNotNull('created_by') + ->where([ + 'action_type' => 'checkin from', + 'note' => 'Bulk checkin items', + 'target_id' => $log->target_id, + 'target_type' => $log->target_type, + 'created_at' => $log->updated_at, + ]) + ->first(); + + if ($similarLog) { + return $similarLog->created_by; + } + + return null; + } +} diff --git a/app/Console/Commands/TestLocationsFMCS.php b/app/Console/Commands/TestLocationsFMCS.php new file mode 100644 index 0000000000..f14c78063a --- /dev/null +++ b/app/Console/Commands/TestLocationsFMCS.php @@ -0,0 +1,51 @@ +info('This script checks for company ID inconsistencies if Full Multiple Company Support with scoped locations will be used.'); + $this->info('This could take few moments if have a very large dataset.'); + $this->newLine(); + + // if parameter location_id is set, only test this location + $location_id = null; + if ($this->option('location_id')) { + $location_id = $this->option('location_id'); + } + + $mismatched = Helper::test_locations_fmcs(true, $location_id); + $this->warn(trans_choice('admin/settings/message.location_scoping.mismatch', count($mismatched))); + $this->newLine(); + $this->info('Edit your locations to associate them with the correct company.'); + + $header = ['Type', 'ID', 'Name', 'Checkout Type', 'Company ID', 'Item Company', 'Item Location', 'Location Company', 'Location Company ID']; + sort($mismatched); + + $this->table($header, $mismatched); + + } + +} diff --git a/app/Helpers/Helper.php b/app/Helpers/Helper.php index f146d57d29..27a1f99c01 100644 --- a/app/Helpers/Helper.php +++ b/app/Helpers/Helper.php @@ -12,6 +12,7 @@ use App\Models\Depreciation; use App\Models\Setting; use App\Models\Statuslabel; use App\Models\License; +use App\Models\Location; use Illuminate\Support\Facades\Crypt; use Illuminate\Contracts\Encryption\DecryptException; use Carbon\Carbon; @@ -1529,4 +1530,93 @@ class Helper } return redirect()->back()->with('error', trans('admin/hardware/message.checkout.error')); } + + /** + * Check for inconsistencies before activating scoped locations with FullMultipleCompanySupport + * If there are locations with different companies than related objects unforseen problems could arise + * + * @author T. Regnery + * @since 7.0 + * + * @param $artisan when false, bail out on first inconsistent entry + * @param $location_id when set, only test this specific location + * @param $new_company_id in case of updating a location, this is the newly requested company_id + * @return string [] + */ + static public function test_locations_fmcs($artisan, $location_id = null, $new_company_id = null) { + $mismatched = []; + + if ($location_id) { + $location = Location::find($location_id); + if ($location) { + $locations = collect([])->push(Location::find($location_id)); + } + } else { + $locations = Location::all(); + } + + foreach($locations as $location) { + // in case of an update of a single location, use the newly requested company_id + if ($new_company_id) { + $location_company = $new_company_id; + } else { + $location_company = $location->company_id; + } + + // Depending on the relationship, we must use different operations to retrieve the objects + $keywords_relation = [ + 'many' => [ + 'accessories', + 'assets', + 'assignedAccessories', + 'assignedAssets', + 'components', + 'consumables', + 'rtd_assets', + 'users', + ], + 'one' => [ + 'manager', + 'parent', + ]]; + + // In case of a single location, the children must be checked as well, because we don't walk every location + if ($location_id) { + $keywords_relation['many'][] = 'children'; + } + + foreach ($keywords_relation as $relation => $keywords) { + foreach($keywords as $keyword) { + if ($relation == 'many') { + $items = $location->{$keyword}->all(); + } else { + $items = collect([])->push($location->$keyword); + } + + foreach ($items as $item) { + + if ($item && $item->company_id != $location_company) { + $mismatched[] = [ + class_basename(get_class($item)), + $item->id, + $item->name ?? $item->asset_tag ?? $item->serial ?? $item->username, + str_replace('App\\Models\\', '', $item->assigned_type) ?? null, + $item->company_id ?? null, + $item->company->name ?? null, +// $item->defaultLoc->id ?? null, +// $item->defaultLoc->name ?? null, +// $item->defaultLoc->company->id ?? null, +// $item->defaultLoc->company->name ?? null, + $item->location->name ?? null, + $item->location->company->name ?? null, + $location_company ?? null, + ]; + + } + } + } + } + } + return $mismatched; + } } diff --git a/app/Http/Controllers/Api/AssetsController.php b/app/Http/Controllers/Api/AssetsController.php index d780bd8bde..d60ded3919 100644 --- a/app/Http/Controllers/Api/AssetsController.php +++ b/app/Http/Controllers/Api/AssetsController.php @@ -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); + } diff --git a/app/Http/Controllers/Api/LicenseSeatsController.php b/app/Http/Controllers/Api/LicenseSeatsController.php index 2ed7097322..1a76139447 100644 --- a/app/Http/Controllers/Api/LicenseSeatsController.php +++ b/app/Http/Controllers/Api/LicenseSeatsController.php @@ -136,13 +136,13 @@ class LicenseSeatsController extends Controller if ($licenseSeat->save()) { if ($is_checkin) { - $licenseSeat->logCheckin($target, $request->input('note')); + $licenseSeat->logCheckin($target, $request->input('notes')); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); } // in this case, relevant fields are touched but it's not a checkin operation. so it must be a checkout operation. - $licenseSeat->logCheckout($request->input('note'), $target); + $licenseSeat->logCheckout($request->input('notes'), $target); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); } diff --git a/app/Http/Controllers/Api/LocationsController.php b/app/Http/Controllers/Api/LocationsController.php index 6a312e5bcf..638765928b 100644 --- a/app/Http/Controllers/Api/LocationsController.php +++ b/app/Http/Controllers/Api/LocationsController.php @@ -12,7 +12,9 @@ use App\Http\Transformers\SelectlistTransformer; use App\Models\Accessory; use App\Models\AccessoryCheckout; use App\Models\Asset; +use App\Models\Company; use App\Models\Location; +use App\Models\Setting; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Pagination\LengthAwarePaginator; @@ -46,6 +48,7 @@ class LocationsController extends Controller 'id', 'image', 'ldap_ou', + 'company_id', 'manager_id', 'name', 'rtd_assets_count', @@ -74,8 +77,10 @@ class LocationsController extends Controller 'locations.image', 'locations.ldap_ou', 'locations.currency', + 'locations.company_id', 'locations.notes', ]) + ->withCount('assignedAssets as assigned_assets_count') ->withCount('assignedAssets as assigned_assets_count') ->withCount('assets as assets_count') ->withCount('assignedAccessories as assigned_accessories_count') @@ -84,6 +89,11 @@ class LocationsController extends Controller ->withCount('children as children_count') ->withCount('users as users_count'); + // Only scope locations if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $locations = Company::scopeCompanyables($locations); + } + if ($request->filled('search')) { $locations = $locations->TextSearch($request->input('search')); } @@ -116,6 +126,10 @@ class LocationsController extends Controller $locations->where('locations.manager_id', '=', $request->input('manager_id')); } + if ($request->filled('company_id')) { + $locations->where('locations.company_id', '=', $request->input('company_id')); + } + // Make sure the offset and limit are actually integers and do not exceed system limits $offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value'); $limit = app('api_limit_value'); @@ -132,6 +146,9 @@ class LocationsController extends Controller case 'manager': $locations->OrderManager($order); break; + case 'company': + $locations->OrderCompany($order); + break; default: $locations->orderBy($sort, $order); break; @@ -159,6 +176,15 @@ class LocationsController extends Controller $location->fill($request->all()); $location = $request->handleImages($location); + // Only scope location if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $location->company_id = Company::getIdForCurrentUser($request->get('company_id')); + // check if parent is set and has a different company + if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) { + response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent')); + } + } + if ($location->save()) { return response()->json(Helper::formatStandardApiResponse('success', (new LocationsTransformer)->transformLocation($location), trans('admin/locations/message.create.success'))); } @@ -176,7 +202,7 @@ class LocationsController extends Controller public function show($id) : JsonResponse | array { $this->authorize('view', Location::class); - $location = Location::with('parent', 'manager', 'children') + $location = Location::with('parent', 'manager', 'children', 'company') ->select([ 'locations.id', 'locations.name', @@ -220,6 +246,19 @@ class LocationsController extends Controller $location->fill($request->all()); $location = $request->handleImages($location); + if ($request->filled('company_id')) { + // Only scope location if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $location->company_id = Company::getIdForCurrentUser($request->get('company_id')); + // check if there are related objects with different company + if (Helper::test_locations_fmcs(false, $id, $location->company_id)) { + return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations')); + } + } else { + $location->company_id = $request->get('company_id'); + } + } + if ($location->isValid()) { $location->save(); @@ -340,6 +379,11 @@ class LocationsController extends Controller 'locations.image', ]); + // Only scope locations if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $locations = Company::scopeCompanyables($locations); + } + $page = 1; if ($request->filled('page')) { $page = $request->input('page'); diff --git a/app/Http/Controllers/Assets/AssetsController.php b/app/Http/Controllers/Assets/AssetsController.php index 9578acea63..8356899777 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\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')); } diff --git a/app/Http/Controllers/BulkAssetModelsController.php b/app/Http/Controllers/BulkAssetModelsController.php index 36b21178b0..5f64ea0838 100644 --- a/app/Http/Controllers/BulkAssetModelsController.php +++ b/app/Http/Controllers/BulkAssetModelsController.php @@ -71,20 +71,28 @@ class BulkAssetModelsController extends Controller if (($request->filled('manufacturer_id') && ($request->input('manufacturer_id') != 'NC'))) { $update_array['manufacturer_id'] = $request->input('manufacturer_id'); } + if (($request->filled('category_id') && ($request->input('category_id') != 'NC'))) { $update_array['category_id'] = $request->input('category_id'); } + if ($request->input('fieldset_id') != 'NC') { $update_array['fieldset_id'] = $request->input('fieldset_id'); } + if ($request->input('depreciation_id') != 'NC') { $update_array['depreciation_id'] = $request->input('depreciation_id'); } - if ($request->filled('requestable') != '') { + if ($request->input('requestable') != '') { $update_array['requestable'] = $request->input('requestable'); } + if ($request->filled('min_amt')) { + $update_array['min_amt'] = $request->input('min_amt'); + } + + if (count($update_array) > 0) { AssetModel::whereIn('id', $models_raw_array)->update($update_array); diff --git a/app/Http/Controllers/CustomFieldsController.php b/app/Http/Controllers/CustomFieldsController.php index 7c0bdefa29..4c63179d59 100644 --- a/app/Http/Controllers/CustomFieldsController.php +++ b/app/Http/Controllers/CustomFieldsController.php @@ -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')); diff --git a/app/Http/Controllers/GroupsController.php b/app/Http/Controllers/GroupsController.php index 5240043357..577030390b 100755 --- a/app/Http/Controllers/GroupsController.php +++ b/app/Http/Controllers/GroupsController.php @@ -83,6 +83,10 @@ class GroupsController extends Controller { $permissions = config('permissions'); $groupPermissions = $group->decodePermissions(); + + if ((!is_array($groupPermissions)) || (!$groupPermissions)) { + $groupPermissions = []; + } $selected_array = Helper::selectedPermissionsArray($permissions, $groupPermissions); return view('groups.edit', compact('group', 'permissions', 'selected_array', 'groupPermissions')); } diff --git a/app/Http/Controllers/LabelsController.php b/app/Http/Controllers/LabelsController.php index 8e6ba5e2cd..ce5bf7d249 100755 --- a/app/Http/Controllers/LabelsController.php +++ b/app/Http/Controllers/LabelsController.php @@ -38,6 +38,7 @@ class LabelsController extends Controller $exampleAsset->order_number = '12345'; $exampleAsset->purchase_date = '2023-01-01'; $exampleAsset->status_id = 1; + $exampleAsset->location_id = 1; $exampleAsset->company = new Company([ 'name' => trans('admin/labels/table.example_company'), diff --git a/app/Http/Controllers/LocationsController.php b/app/Http/Controllers/LocationsController.php index a018b8a68a..9e0bd6bfb1 100755 --- a/app/Http/Controllers/LocationsController.php +++ b/app/Http/Controllers/LocationsController.php @@ -2,10 +2,13 @@ namespace App\Http\Controllers; +use App\Helpers\Helper; use App\Http\Requests\ImageUploadRequest; use App\Models\Actionlog; use App\Models\Asset; +use App\Models\Company; use App\Models\Location; +use App\Models\Setting; use App\Models\User; use Illuminate\Support\Facades\Storage; use Illuminate\Http\Request; @@ -79,6 +82,18 @@ class LocationsController extends Controller $location->phone = request('phone'); $location->fax = request('fax'); $location->notes = $request->input('notes'); + $location->company_id = Company::getIdForCurrentUser($request->input('company_id')); + + // Only scope the location if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $location->company_id = Company::getIdForCurrentUser($request->input('company_id')); + // check if parent is set and has a different company + if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) { + return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent'); + } + } else { + $location->company_id = $request->input('company_id'); + } $location = $request->handleImages($location); @@ -131,6 +146,17 @@ class LocationsController extends Controller $location->manager_id = $request->input('manager_id'); $location->notes = $request->input('notes'); + // Only scope the location if the setting is enabled + if (Setting::getSettings()->scope_locations_fmcs) { + $location->company_id = Company::getIdForCurrentUser($request->input('company_id')); + // check if there are related objects with different company + if (Helper::test_locations_fmcs(false, $locationId, $location->company_id)) { + return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations'); + } + } else { + $location->company_id = $request->input('company_id'); + } + $location = $request->handleImages($location); if ($location->save()) { @@ -203,20 +229,22 @@ class LocationsController extends Controller public function print_assigned($id) : View | RedirectResponse { - if ($location = Location::where('id', $id)->first()) { $parent = Location::where('id', $location->parent_id)->first(); $manager = User::where('id', $location->manager_id)->first(); + $company = Company::where('id', $location->company_id)->first(); $users = User::where('location_id', $id)->with('company', 'department', 'location')->get(); $assets = Asset::where('assigned_to', $id)->where('assigned_type', Location::class)->with('model', 'model.category')->get(); - return view('locations/print')->with('assets', $assets)->with('users', $users)->with('location', $location)->with('parent', $parent)->with('manager', $manager); - + return view('locations/print') + ->with('assets', $assets) + ->with('users',$users) + ->with('location', $location) + ->with('parent', $parent) + ->with('manager', $manager) + ->with('company', $company); } return redirect()->route('locations.index')->with('error', trans('admin/locations/message.does_not_exist')); - - - } @@ -288,10 +316,16 @@ class LocationsController extends Controller if ($location = Location::where('id', $id)->first()) { $parent = Location::where('id', $location->parent_id)->first(); $manager = User::where('id', $location->manager_id)->first(); + $company = Company::where('id', $location->company_id)->first(); $users = User::where('location_id', $id)->with('company', 'department', 'location')->get(); $assets = Asset::where('location_id', $id)->with('model', 'model.category')->get(); - return view('locations/print')->with('assets', $assets)->with('users', $users)->with('location', $location)->with('parent', $parent)->with('manager', $manager); - + return view('locations/print') + ->with('assets', $assets) + ->with('users',$users) + ->with('location', $location) + ->with('parent', $parent) + ->with('manager', $manager) + ->with('company', $company); } return redirect()->route('locations.index')->with('error', trans('admin/locations/message.does_not_exist')); } diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index bd22562a62..33afac5312 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -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'), diff --git a/app/Http/Controllers/SettingsController.php b/app/Http/Controllers/SettingsController.php index 2300f2f682..a6de4466dd 100644 --- a/app/Http/Controllers/SettingsController.php +++ b/app/Http/Controllers/SettingsController.php @@ -314,7 +314,23 @@ class SettingsController extends Controller $setting->modellist_displays = implode(',', $request->input('show_in_model_list')); } + $old_locations_fmcs = $setting->scope_locations_fmcs; $setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0'); + $setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '0'); + + // Backward compatibility for locations makes no sense without FullMultipleCompanySupport + if (!$setting->full_multiple_companies_support) { + $setting->scope_locations_fmcs = '0'; + } + + // check for inconsistencies when activating scoped locations + if ($old_locations_fmcs == '0' && $setting->scope_locations_fmcs == '1') { + $mismatched = Helper::test_locations_fmcs(false); + if (count($mismatched) != 0) { + return redirect()->back()->withInput()->with('error', trans_choice('admin/settings/message.location_scoping.mismatch', count($mismatched)).' '.trans('admin/settings/message.location_scoping.not_saved')); + } + } + $setting->unique_serial = $request->input('unique_serial', '0'); $setting->shortcuts_enabled = $request->input('shortcuts_enabled', '0'); $setting->show_images_in_email = $request->input('show_images_in_email', '0'); diff --git a/app/Http/Requests/Traits/MayContainCustomFields.php b/app/Http/Requests/Traits/MayContainCustomFields.php index bbdf62893d..2b12ccff6e 100644 --- a/app/Http/Requests/Traits/MayContainCustomFields.php +++ b/app/Http/Requests/Traits/MayContainCustomFields.php @@ -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) { diff --git a/app/Http/Transformers/CustomFieldsTransformer.php b/app/Http/Transformers/CustomFieldsTransformer.php index d6401a3e5e..501d264b55 100644 --- a/app/Http/Transformers/CustomFieldsTransformer.php +++ b/app/Http/Transformers/CustomFieldsTransformer.php @@ -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'), ]; diff --git a/app/Http/Transformers/LocationsTransformer.php b/app/Http/Transformers/LocationsTransformer.php index 354af4acc5..b1553c69f4 100644 --- a/app/Http/Transformers/LocationsTransformer.php +++ b/app/Http/Transformers/LocationsTransformer.php @@ -63,6 +63,10 @@ class LocationsTransformer 'name'=> e($location->parent->name), ] : null, 'manager' => ($location->manager) ? (new UsersTransformer)->transformUser($location->manager) : null, + 'company' => ($location->company) ? [ + 'id' => (int) $location->company->id, + 'name'=> e($location->company->name) + ] : null, 'children' => $children_arr, ]; diff --git a/app/Models/Accessory.php b/app/Models/Accessory.php index fc1bb36ab4..039f8692f6 100755 --- a/app/Models/Accessory.php +++ b/app/Models/Accessory.php @@ -61,6 +61,7 @@ class Accessory extends SnipeModel 'qty' => 'required|integer|min:1', 'category_id' => 'required|integer|exists:categories,id', '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_date' => 'date_format:Y-m-d|nullable', diff --git a/app/Models/Actionlog.php b/app/Models/Actionlog.php index 3a8fcf1cb9..dd86ae25c6 100755 --- a/app/Models/Actionlog.php +++ b/app/Models/Actionlog.php @@ -57,7 +57,9 @@ class Actionlog extends SnipeModel 'user_agent', 'item_type', 'target_type', - 'action_source' + 'action_source', + 'created_at', + 'action_date', ]; /** @@ -69,7 +71,25 @@ class Actionlog extends SnipeModel 'company' => ['name'], 'adminuser' => ['first_name','last_name','username', 'email'], 'user' => ['first_name','last_name','username', 'email'], - 'assets' => ['asset_tag','name', 'serial'], + 'assets' => ['asset_tag','name', 'serial', 'order_number', 'notes', 'purchase_date'], + 'assets.model' => ['name', 'model_number', 'eol', 'notes'], + 'assets.model.category' => ['name', 'notes'], + 'assets.model.manufacturer' => ['name', 'notes'], + 'licenses' => ['name', 'serial', 'notes', 'order_number', 'license_email', 'license_name', 'purchase_order', 'purchase_date'], + 'licenses.category' => ['name', 'notes'], + 'licenses.supplier' => ['name'], + 'consumables' => ['name', 'notes', 'order_number', 'model_number', 'item_no', 'purchase_date'], + 'consumables.category' => ['name', 'notes'], + 'consumables.location' => ['name', 'notes'], + 'consumables.supplier' => ['name', 'notes'], + 'components' => ['name', 'notes', 'purchase_date'], + 'components.category' => ['name', 'notes'], + 'components.location' => ['name', 'notes'], + 'components.supplier' => ['name', 'notes'], + 'accessories' => ['name', 'purchase_date'], + 'accessories.category' => ['name'], + 'accessories.location' => ['name', 'notes'], + 'accessories.supplier' => ['name', 'notes'], ]; /** @@ -134,6 +154,54 @@ class Actionlog extends SnipeModel return $this->hasMany(\App\Models\Asset::class, 'id', 'item_id'); } + /** + * Establishes the actionlog -> license relationship + * + * @author [A. Gianotto] [] + * @since [v3.0] + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function licenses() + { + return $this->hasMany(\App\Models\License::class, 'id', 'item_id'); + } + + /** + * Establishes the actionlog -> consumable relationship + * + * @author [A. Gianotto] [] + * @since [v3.0] + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function consumables() + { + return $this->hasMany(\App\Models\Consumable::class, 'id', 'item_id'); + } + + /** + * Establishes the actionlog -> consumable relationship + * + * @author [A. Gianotto] [] + * @since [v3.0] + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function accessories() + { + return $this->hasMany(\App\Models\Accessory::class, 'id', 'item_id'); + } + + /** + * Establishes the actionlog -> components relationship + * + * @author [A. Gianotto] [] + * @since [v3.0] + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function components() + { + return $this->hasMany(\App\Models\Component::class, 'id', 'item_id'); + } + /** * Establishes the actionlog -> item type relationship * diff --git a/app/Models/Asset.php b/app/Models/Asset.php index 673012cf68..ac4fecac34 100644 --- a/app/Models/Asset.php +++ b/app/Models/Asset.php @@ -108,8 +108,8 @@ class Asset extends Depreciable 'expected_checkin' => ['nullable', 'date'], 'last_audit_date' => ['nullable', 'date_format:Y-m-d H:i:s'], 'next_audit_date' => ['nullable', 'date'], - 'location_id' => ['nullable', 'exists:locations,id'], - 'rtd_location_id' => ['nullable', 'exists:locations,id'], + 'location_id' => ['nullable', 'exists:locations,id', 'fmcs_location'], + 'rtd_location_id' => ['nullable', 'exists:locations,id', 'fmcs_location'], 'purchase_date' => ['nullable', 'date', 'date_format:Y-m-d'], 'serial' => ['nullable', 'unique_undeleted:assets,serial'], 'purchase_cost' => ['nullable', 'numeric', 'gte:0', 'max:9999999999999'], @@ -122,7 +122,7 @@ class Asset extends Depreciable 'assigned_to' => ['nullable', 'integer'], 'requestable' => ['nullable', 'boolean'], 'assigned_user' => ['nullable', 'exists:users,id,deleted_at,NULL'], - 'assigned_location' => ['nullable', 'exists:locations,id,deleted_at,NULL'], + 'assigned_location' => ['nullable', 'exists:locations,id,deleted_at,NULL', 'fmcs_location'], 'assigned_asset' => ['nullable', 'exists:assets,id,deleted_at,NULL'] ]; @@ -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] [] + * @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 * diff --git a/app/Models/CompanyableTrait.php b/app/Models/CompanyableTrait.php index 04a620d8e3..c4b9b89fbf 100644 --- a/app/Models/CompanyableTrait.php +++ b/app/Models/CompanyableTrait.php @@ -13,6 +13,13 @@ trait CompanyableTrait */ public static function bootCompanyableTrait() { - static::addGlobalScope(new CompanyableScope); + // In Version 7.0 and before locations weren't scoped by companies, so add a check for the backward compatibility setting + if (__CLASS__ != 'App\Models\Location') { + static::addGlobalScope(new CompanyableScope); + } else { + if (Setting::getSettings()->scope_locations_fmcs == 1) { + static::addGlobalScope(new CompanyableScope); + } + } } } diff --git a/app/Models/Component.php b/app/Models/Component.php index d9277d7da7..0208fb9f68 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -36,6 +36,7 @@ class Component extends SnipeModel 'category_id' => 'required|integer|exists:categories,id', 'supplier_id' => 'nullable|integer|exists:suppliers,id', 'company_id' => 'integer|nullable|exists:companies,id', + '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', diff --git a/app/Models/Consumable.php b/app/Models/Consumable.php index f8c8e83892..c83aa6106e 100644 --- a/app/Models/Consumable.php +++ b/app/Models/Consumable.php @@ -49,6 +49,7 @@ class Consumable extends SnipeModel 'qty' => 'required|integer|min:0|max:99999', 'category_id' => 'required|integer', '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_date' => 'date_format:Y-m-d|nullable', diff --git a/app/Models/LicenseSeat.php b/app/Models/LicenseSeat.php index 397a141468..d4f2b13ad5 100755 --- a/app/Models/LicenseSeat.php +++ b/app/Models/LicenseSeat.php @@ -30,6 +30,7 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild protected $fillable = [ 'assigned_to', 'asset_id', + 'notes', ]; use Acceptable; diff --git a/app/Models/Location.php b/app/Models/Location.php index 63cb2de39d..69c79cfaeb 100755 --- a/app/Models/Location.php +++ b/app/Models/Location.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Http\Traits\UniqueUndeletedTrait; use App\Models\Asset; +use App\Models\Setting; use App\Models\SnipeModel; use App\Models\Traits\Searchable; use App\Models\User; @@ -18,6 +19,7 @@ use Watson\Validating\ValidatingTrait; class Location extends SnipeModel { use HasFactory; + use CompanyableTrait; protected $presenter = \App\Presenters\LocationPresenter::class; use Presentable; @@ -34,11 +36,13 @@ class Location extends SnipeModel 'zip' => 'max:10|nullable', 'manager_id' => 'exists:users,id|nullable', 'parent_id' => 'nullable|exists:locations,id|non_circular:locations,id', + 'company_id' => 'integer|nullable|exists:companies,id', ]; protected $casts = [ 'parent_id' => 'integer', 'manager_id' => 'integer', + 'company_id' => 'integer', ]; /** @@ -72,6 +76,7 @@ class Location extends SnipeModel 'currency', 'manager_id', 'image', + 'company_id', 'notes', ]; protected $hidden = ['user_id']; @@ -91,7 +96,8 @@ class Location extends SnipeModel * @var array */ protected $searchableRelations = [ - 'parent' => ['name'], + 'parent' => ['name'], + 'company' => ['name'] ]; @@ -215,6 +221,17 @@ class Location extends SnipeModel ->with('parent'); } + /** + * Establishes the locations -> company relationship + * + * @author [T. Regnery] [] + * @since [v7.0] + * @return \Illuminate\Database\Eloquent\Relations\Relation + */ + public function company() + { + return $this->belongsTo(\App\Models\Company::class, 'company_id'); + } /** * Find the manager of a location @@ -326,4 +343,17 @@ class Location extends SnipeModel { return $query->leftJoin('users as location_user', 'locations.manager_id', '=', 'location_user.id')->orderBy('location_user.first_name', $order)->orderBy('location_user.last_name', $order); } + + /** + * Query builder scope to order on company + * + * @param \Illuminate\Database\Query\Builder $query Query builder instance + * @param text $order Order + * + * @return \Illuminate\Database\Query\Builder Modified query builder + */ + public function scopeOrderCompany($query, $order) + { + return $query->leftJoin('companies as company_sort', 'locations.company_id', '=', 'company_sort.id')->orderBy('company_sort.name', $order); + } } diff --git a/app/Models/Loggable.php b/app/Models/Loggable.php index 1e4a912027..93b82f8407 100644 --- a/app/Models/Loggable.php +++ b/app/Models/Loggable.php @@ -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'); diff --git a/app/Models/User.php b/app/Models/User.php index 69e3e33e68..a686f14b9c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -94,7 +94,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo 'locale' => 'max:10|nullable', 'website' => 'url|nullable|max:191', 'manager_id' => 'nullable|exists:users,id|cant_manage_self', - 'location_id' => 'exists:locations,id|nullable', + 'location_id' => 'exists:locations,id|nullable|fmcs_location', 'start_date' => 'nullable|date_format:Y-m-d', 'end_date' => 'nullable|date_format:Y-m-d|after_or_equal:start_date', 'autoassign_licenses' => 'boolean', diff --git a/app/Presenters/AccessoryPresenter.php b/app/Presenters/AccessoryPresenter.php index 6d423bed78..86b4d1bc0f 100644 --- a/app/Presenters/AccessoryPresenter.php +++ b/app/Presenters/AccessoryPresenter.php @@ -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', ], diff --git a/app/Presenters/ActionlogPresenter.php b/app/Presenters/ActionlogPresenter.php index 834b88e753..4b7aefc87a 100644 --- a/app/Presenters/ActionlogPresenter.php +++ b/app/Presenters/ActionlogPresenter.php @@ -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'; } diff --git a/app/Presenters/AssetPresenter.php b/app/Presenters/AssetPresenter.php index a9d08884de..2a4d09d131 100644 --- a/app/Presenters/AssetPresenter.php +++ b/app/Presenters/AssetPresenter.php @@ -555,7 +555,7 @@ class AssetPresenter extends Presenter */ public function statusMeta() { - if ($this->model->assigned) { + if ($this->model->assigned_to) { return 'deployed'; } diff --git a/app/Presenters/LocationPresenter.php b/app/Presenters/LocationPresenter.php index 42f56a309d..073918ce1c 100644 --- a/app/Presenters/LocationPresenter.php +++ b/app/Presenters/LocationPresenter.php @@ -25,7 +25,17 @@ class LocationPresenter extends Presenter 'switchable' => true, 'title' => trans('general.id'), 'visible' => false, - ], [ + ], + [ + 'field' => 'company', + 'searchable' => true, + 'sortable' => true, + 'switchable' => true, + 'title' => trans('general.company'), + 'visible' => false, + 'formatter' => 'locationCompanyObjFilterFormatter' + ], + [ 'field' => 'name', 'searchable' => true, 'sortable' => true, @@ -262,7 +272,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', ], diff --git a/app/Providers/ValidationServiceProvider.php b/app/Providers/ValidationServiceProvider.php index 76ba1b629a..a08a035fbb 100644 --- a/app/Providers/ValidationServiceProvider.php +++ b/app/Providers/ValidationServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Models\CustomField; use App\Models\Department; +use App\Models\Location; use App\Models\Setting; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; @@ -353,6 +354,20 @@ class ValidationServiceProvider extends ServiceProvider return in_array($value, $options); }); + + // Validates that the company of the validated object matches the company of the location in case of scoped locations + Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator){ + $settings = Setting::getSettings(); + if ($settings->full_multiple_companies_support == '1' && $settings->scope_locations_fmcs == '1') { + $company_id = array_get($validator->getData(), 'company_id'); + $location = Location::find($value); + + if ($company_id != $location->company_id) { + return false; + } + } + return true; + }); } /** diff --git a/app/View/Label.php b/app/View/Label.php index 52a53a0437..6dbad39a34 100644 --- a/app/View/Label.php +++ b/app/View/Label.php @@ -139,6 +139,9 @@ class Label implements View case 'plain_serial_number': $barcode2DTarget = $asset->serial; break; + case 'location': + $barcode2DTarget = route('locations.show', $asset->location_id); + break; case 'hardware_id': default: $barcode2DTarget = route('hardware.show', $asset); diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index bb89d35241..d8b1ac141c 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -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) { diff --git a/database/migrations/2022_02_10_110210_add_company_id_to_locations.php b/database/migrations/2022_02_10_110210_add_company_id_to_locations.php new file mode 100644 index 0000000000..8ef5822812 --- /dev/null +++ b/database/migrations/2022_02_10_110210_add_company_id_to_locations.php @@ -0,0 +1,35 @@ +integer('company_id')->unsigned()->nullable(); + $table->index(['company_id']); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('locations', function (Blueprint $table) { + $table->dropIndex(['company_id']); + $table->dropColumn('company_id'); + }); + } +} + diff --git a/database/migrations/2023_02_27_092130_add_scope_locations_setting.php b/database/migrations/2023_02_27_092130_add_scope_locations_setting.php new file mode 100644 index 0000000000..c1e2ff83e9 --- /dev/null +++ b/database/migrations/2023_02_27_092130_add_scope_locations_setting.php @@ -0,0 +1,32 @@ +boolean('scope_locations_fmcs')->default('0')->after('full_multiple_companies_support'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('settings', function (Blueprint $table) { + $table->dropColumn('scope_locations_fmcs'); + }); + } +} \ No newline at end of file diff --git a/database/migrations/2025_04_07_145418_add_custom_fields_to_audit.php b/database/migrations/2025_04_07_145418_add_custom_fields_to_audit.php new file mode 100644 index 0000000000..d5594fc035 --- /dev/null +++ b/database/migrations/2025_04_07_145418_add_custom_fields_to_audit.php @@ -0,0 +1,28 @@ +boolean('display_audit')->default(0); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('custom_fields', function (Blueprint $table) { + $table->dropColumn('display_audit'); + }); + } +}; diff --git a/public/css/build/app.css b/public/css/build/app.css index aaa9ed4033..060421ac6f 100644 --- a/public/css/build/app.css +++ b/public/css/build/app.css @@ -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 { @@ -1427,6 +1428,7 @@ th.text-right.text-padding-number-footer-cell { white-space: nowrap; } code.single-line { + white-space: pre-wrap; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; diff --git a/public/css/build/overrides.css b/public/css/build/overrides.css index 6d0687c74e..fb5c19221c 100644 --- a/public/css/build/overrides.css +++ b/public/css/build/overrides.css @@ -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 { @@ -1058,6 +1059,7 @@ th.text-right.text-padding-number-footer-cell { white-space: nowrap; } code.single-line { + white-space: pre-wrap; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; diff --git a/public/css/dist/all.css b/public/css/dist/all.css index 9669ae81a6..2708d3e646 100644 --- a/public/css/dist/all.css +++ b/public/css/dist/all.css @@ -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 { @@ -22762,6 +22763,7 @@ th.text-right.text-padding-number-footer-cell { white-space: nowrap; } code.single-line { + white-space: pre-wrap; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; @@ -24060,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 { @@ -24312,6 +24315,7 @@ th.text-right.text-padding-number-footer-cell { white-space: nowrap; } code.single-line { + white-space: pre-wrap; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; diff --git a/public/mix-manifest.json b/public/mix-manifest.json index 363faf57f3..2951264696 100644 --- a/public/mix-manifest.json +++ b/public/mix-manifest.json @@ -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=01e77f7a486fd578a14760d045dcd80f", - "/css/build/app.css": "/css/build/app.css?id=ad2974ecfed16a76dadd2f4ec34f8dac", + "/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=524d6fe45db04b3258c818dfb391fe2c", + "/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", diff --git a/resources/assets/less/overrides.less b/resources/assets/less/overrides.less index 7c7bff4d66..8c8b80e462 100644 --- a/resources/assets/less/overrides.less +++ b/resources/assets/less/overrides.less @@ -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; } @@ -1176,6 +1176,7 @@ th.text-right.text-padding-number-footer-cell { } code.single-line { + white-space: pre-wrap; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 1; diff --git a/resources/lang/de-DE/admin/settings/general.php b/resources/lang/de-DE/admin/settings/general.php index 99f79520ae..1723b958a4 100644 --- a/resources/lang/de-DE/admin/settings/general.php +++ b/resources/lang/de-DE/admin/settings/general.php @@ -147,6 +147,8 @@ return [ 'logo_print_assets_help' => 'Firmenlogo anzeigen beim Drucken der Asset-Liste ', 'full_multiple_companies_support_help_text' => 'Beschränkung von Benutzern (inklusive Administratoren) die einer Firma zugewiesen sind zu den Assets der Firma.', 'full_multiple_companies_support_text' => 'Volle Mehrmandanten-Unterstützung für Firmen', + 'scope_locations_fmcs_support_text' => 'Beschränke Standorte mit voller Mehrmandanten-Unterstützung für Firmen', + 'scope_locations_fmcs_support_help_text' => 'Bis zu Version 7.0 waren Standorte nicht auf die Firma des Benutzers beschränkt. Wenn diese Einstellung deaktiviert ist, wird die Kompatibilität zu älteren Versionen gewahrt und die Standorte nicht beschränkt. Wenn diese Einstellung aktiviert ist, werden Standorte ebenfalls auf die Firma des Benutzers beschränkt.', 'show_in_model_list' => 'In Modell-Dropdown-Liste anzeigen', 'optional' => 'optional', 'per_page' => 'Ergebnisse pro Seite', diff --git a/resources/lang/en-US/admin/custom_fields/general.php b/resources/lang/en-US/admin/custom_fields/general.php index f17eedda11..c9ae61c963 100644 --- a/resources/lang/en-US/admin/custom_fields/general.php +++ b/resources/lang/en-US/admin/custom_fields/general.php @@ -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', ]; diff --git a/resources/lang/en-US/admin/hardware/message.php b/resources/lang/en-US/admin/hardware/message.php index 6806682f58..c19bc63647 100644 --- a/resources/lang/en-US/admin/hardware/message.php +++ b/resources/lang/en-US/admin/hardware/message.php @@ -100,9 +100,10 @@ return [ ], 'requests' => [ - 'error' => 'Asset was not requested, please try again', - 'success' => 'Asset requested successfully.', - 'canceled' => 'Checkout request successfully canceled', + 'error' => 'Request was not successful, please try again.', + 'success' => 'Request successfully submitted.', + 'canceled' => 'Request successfully canceled.', + 'cancel' => 'Cancel this item request', ], ]; diff --git a/resources/lang/en-US/admin/settings/general.php b/resources/lang/en-US/admin/settings/general.php index ed148cf81f..c345952386 100644 --- a/resources/lang/en-US/admin/settings/general.php +++ b/resources/lang/en-US/admin/settings/general.php @@ -149,6 +149,8 @@ return [ 'logo_print_assets_help' => 'Use branding on printable asset lists ', 'full_multiple_companies_support_help_text' => 'Restricting users (including admins) assigned to companies to their company\'s assets.', 'full_multiple_companies_support_text' => 'Full Multiple Companies Support', + 'scope_locations_fmcs_support_text' => 'Scope Locations with Full Multiple Companies Support', + 'scope_locations_fmcs_support_help_text' => 'Up until Version 7.0 locations were not restricted to the users company. If this setting is disabled, this preserves backward compatibility with older versions and locations are not restricted. If this setting is enabled, locations are also restricted to the users company', 'show_in_model_list' => 'Show in Model Dropdowns', 'optional' => 'optional', 'per_page' => 'Results Per Page', @@ -394,6 +396,19 @@ return [ 'due_checkin_days_help' => 'How many days before the expected checkin of an asset should it be listed in the "Due for checkin" page?', 'no_groups' => 'No groups have been created yet. Visit Admin Settings > Permission Groups to add one.', 'text' => 'Text', + 'firstname_lastname_format' => 'First Name Last Name (jane.smith)', + 'first_name_format' => 'First Name (jane)', + 'filastname_format' => 'First Initial Last Name (jsmith)', + 'lastnamefirstinitial_format' => 'Last Name First Initial (smithj)', + 'firstname_lastname_underscore_format' => 'First Name Last Name (jane_smith)', + 'firstinitial.lastname' => 'First Initial Last Name (j.smith)', + 'lastname_firstinitial' => 'Last Name First Initial (smith_j)', + 'lastname_dot_firstinitial_format' => 'Last Name First Initial (smith.j)', + 'firstnamelastname' => 'First Name Last Name (janesmith)', + 'firstnamelastinitial' => 'First Name Last Initial (janes)', + 'lastnamefirstname' => 'Last Name.First Name (smith.jane)', + + 'logo_labels' => [ 'acceptance_pdf_logo' => 'PDF Logo', diff --git a/resources/lang/en-US/admin/settings/message.php b/resources/lang/en-US/admin/settings/message.php index 9be2901747..8d9b40e956 100644 --- a/resources/lang/en-US/admin/settings/message.php +++ b/resources/lang/en-US/admin/settings/message.php @@ -50,5 +50,11 @@ return [ 'error_misc' => 'Something went wrong. :( ', 'webhook_fail' => ' webhook notification failed: Check to make sure the URL is still valid.', 'webhook_channel_not_found' => ' webhook channel not found.' - ] + ], + + 'location_scoping' => [ + 'not_saved' => 'Your settings were not saved.', + 'mismatch' => 'There is 1 item in the database that need your attention before you can enable location scoping.|There are :count items in the database that need your attention before you can enable location scoping.', + ], + ]; diff --git a/resources/lang/en-US/general.php b/resources/lang/en-US/general.php index 5fd7dfb1f5..753638bcf4 100644 --- a/resources/lang/en-US/general.php +++ b/resources/lang/en-US/general.php @@ -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', diff --git a/resources/macros/macros.php b/resources/macros/macros.php index 19ef05fb05..a763c1f482 100644 --- a/resources/macros/macros.php +++ b/resources/macros/macros.php @@ -189,7 +189,7 @@ Form::macro('barcode_types', function ($name = 'barcode_type', $selected = null, return $select; }); -Form::macro('username_format', function ($name = 'username_format', $selected = null, $class = null) { +Form::macro('email_format', function ($name = 'email_format', $selected = null, $class = null) { $formats = [ 'firstname.lastname' => trans('general.firstname_lastname_format'), 'firstname' => trans('general.first_name_format'), @@ -215,6 +215,31 @@ Form::macro('username_format', function ($name = 'username_format', $selected = return $select; }); +Form::macro('username_format', function ($name = 'username_format', $selected = null, $class = null) { + $formats = [ + 'firstname.lastname' => trans('admin/settings/general.firstname_lastname_format'), + 'firstname' => trans('admin/settings/general.first_name_format'), + 'filastname' => trans('admin/settings/general.filastname_format'), + 'lastnamefirstinitial' => trans('admin/settings/general.lastnamefirstinitial_format'), + 'firstname_lastname' => trans('admin/settings/general.firstname_lastname_underscore_format'), + 'firstinitial.lastname' => trans('admin/settings/general.firstinitial.lastname'), + 'lastname_firstinitial' => trans('admin/settings/general.lastname_firstinitial'), + 'lastname.firstinitial' => trans('admin/settings/general.lastname_dot_firstinitial_format'), + 'firstnamelastname' => trans('admin/settings/general.firstnamelastname'), + 'firstnamelastinitial' => trans('admin/settings/general.firstnamelastinitial'), + 'lastname.firstname' => trans('admin/settings/general.lastnamefirstname'), + ]; + + $select = ''; + + return $select; +}); + Form::macro('two_factor_options', function ($name = 'two_factor_enabled', $selected = null, $class = null) { $formats = [ '' => trans('admin/settings/general.two_factor_disabled'), diff --git a/resources/views/accessories/view.blade.php b/resources/views/accessories/view.blade.php index a4f4656bf7..41ffbb95fe 100644 --- a/resources/views/accessories/view.blade.php +++ b/resources/views/accessories/view.blade.php @@ -125,7 +125,7 @@ {{ trans('general.record_created') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('general.action') }} {{ trans('general.file_name') }} {{ trans('general.item') }} diff --git a/resources/views/consumables/view.blade.php b/resources/views/consumables/view.blade.php index 93031b63cf..4ef82efc8e 100644 --- a/resources/views/consumables/view.blade.php +++ b/resources/views/consumables/view.blade.php @@ -421,7 +421,7 @@ {{ trans('general.date') }} {{ trans('general.notes') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} @@ -472,7 +472,7 @@ {{ trans('admin/hardware/table.icon') }} {{ trans('general.date') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('general.action') }} {{ trans('general.file_name') }} {{ trans('general.item') }} diff --git a/resources/views/custom_fields/fields/edit.blade.php b/resources/views/custom_fields/fields/edit.blade.php index 8ff41b7a41..4969899a97 100644 --- a/resources/views/custom_fields/fields/edit.blade.php +++ b/resources/views/custom_fields/fields/edit.blade.php @@ -224,6 +224,14 @@ + +
+ +
+
diff --git a/resources/views/custom_fields/index.blade.php b/resources/views/custom_fields/index.blade.php index 5b9338bafa..ee2778d774 100644 --- a/resources/views/custom_fields/index.blade.php +++ b/resources/views/custom_fields/index.blade.php @@ -196,6 +196,14 @@ + + + + {{ trans('admin/custom_fields/general.display_audit') }} + + + {{ trans('admin/custom_fields/general.field_element_short') }} @@ -227,6 +235,7 @@ {!! ($field->is_unique=='1') ? '' : '' !!} {!! ($field->display_checkin=='1') ? '' : '' !!} {!! ($field->display_checkout=='1') ? '' : '' !!} + {!! ($field->display_audit=='1') ? '' : '' !!} {{ $field->element }} @foreach($field->fieldset as $fieldset) diff --git a/resources/views/hardware/audit.blade.php b/resources/views/hardware/audit.blade.php index baeb56d59f..72be0e833b 100644 --- a/resources/views/hardware/audit.blade.php +++ b/resources/views/hardware/audit.blade.php @@ -115,6 +115,12 @@
+ + @include("models/custom_fields_form", [ + 'model' => $asset->model, + 'show_display_checkout_fields' => 'true' + ]) +
diff --git a/resources/views/hardware/quickscan.blade.php b/resources/views/hardware/quickscan.blade.php index e81981ed68..0dd3fb570b 100644 --- a/resources/views/hardware/quickscan.blade.php +++ b/resources/views/hardware/quickscan.blade.php @@ -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', diff --git a/resources/views/hardware/view.blade.php b/resources/views/hardware/view.blade.php index e7bd3460e5..ba6556c41c 100755 --- a/resources/views/hardware/view.blade.php +++ b/resources/views/hardware/view.blade.php @@ -111,6 +111,22 @@ @endif + @if ($asset->audits->count() > 0) +
  • + + + + + +
  • + @endif +
  • -
    + +
    + +
    +
    + + + + + + + + + + + + + + + +
    {{ trans('general.date') }}{{ trans('general.created_by') }}{{ trans('general.file_name') }}{{ trans('general.notes') }}{{ trans('general.download') }}{{ trans('admin/hardware/table.changed')}}{{ trans('admin/settings/general.login_ip') }}{{ trans('admin/settings/general.login_user_agent') }}{{ trans('general.action_source') }}
    +
    +
    +
    + + +
    @@ -1419,7 +1481,7 @@ {{ trans('admin/hardware/table.icon') }} {{ trans('general.date') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('general.action') }} {{ trans('general.file_name') }} {{ trans('general.item') }} diff --git a/resources/views/layouts/default.blade.php b/resources/views/layouts/default.blade.php index 82f7eca501..10340a6c1d 100644 --- a/resources/views/layouts/default.blade.php +++ b/resources/views/layouts/default.blade.php @@ -526,7 +526,7 @@ dir="{{ Helper::determineLanguageDirection() }}"> @can('audit', \App\Models\Asset::class) - + {{ trans('general.audit_due') }} {{ (isset($total_due_and_overdue_for_audit)) ? $total_due_and_overdue_for_audit : '' }} diff --git a/resources/views/licenses/view.blade.php b/resources/views/licenses/view.blade.php index ffcc94621b..6d7b406df9 100755 --- a/resources/views/licenses/view.blade.php +++ b/resources/views/licenses/view.blade.php @@ -498,7 +498,7 @@ {{ trans('general.record_created') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('general.action') }} {{ trans('general.file_name') }} {{ trans('general.item') }} diff --git a/resources/views/locations/edit.blade.php b/resources/views/locations/edit.blade.php index 004053d8ab..5b83b36ad5 100755 --- a/resources/views/locations/edit.blade.php +++ b/resources/views/locations/edit.blade.php @@ -17,6 +17,9 @@ @include ('partials.forms.edit.user-select', ['translated_name' => trans('admin/users/table.manager'), 'fieldname' => 'manager_id']) + +@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id']) + @include ('partials.forms.edit.phone') @include ('partials.forms.edit.fax') diff --git a/resources/views/locations/index.blade.php b/resources/views/locations/index.blade.php index 90abda5a2d..24f8430bd5 100755 --- a/resources/views/locations/index.blade.php +++ b/resources/views/locations/index.blade.php @@ -38,7 +38,7 @@ data-sort-order="asc" id="locationTable" class="table table-striped snipe-table" - data-url="{{ route('api.locations.index') }}" + data-url="{{ route('api.locations.index', array('company_id'=>e(Request::get('company_id')))) }}" data-export-options='{ "fileName": "export-locations-{{ date('Y-m-d') }}", "ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"] diff --git a/resources/views/locations/print.blade.php b/resources/views/locations/print.blade.php index a047f02279..156eb04265 100644 --- a/resources/views/locations/print.blade.php +++ b/resources/views/locations/print.blade.php @@ -53,7 +53,11 @@ @if ($parent) {{ $parent->present()->fullName() }} @endif - +
    +@if ($company) + {{ trans('admin/companies/table.name') }}: {{ $company->present()->Name() }} +
    +@endif @if ($manager) {{ trans('general.manager') }} {{ $manager->present()->fullName() }}
    @endif diff --git a/resources/views/locations/view.blade.php b/resources/views/locations/view.blade.php index 6ee75d6bff..3d3b6a524d 100644 --- a/resources/views/locations/view.blade.php +++ b/resources/views/locations/view.blade.php @@ -157,11 +157,39 @@
    + @can('view', \App\Models\User::class) +
    $location->users->count() > 0 ]) > + @endcan +

    {{ trans('general.users') }}

    + @include('partials.users-bulk-actions') + +
    +
    +
    $location->users->count() == 0]) > -

    {{ trans('admin/locations/message.current_location') }}

    -
    @include('partials.asset-bulk-actions')
    - -
    - - -
    -

    {{ trans('general.users') }}

    -
    - @include('partials.users-bulk-actions') - - -
    -
    -
    - -

    {{ trans('admin/locations/message.assigned_assets') }}

    -
    @include('partials.asset-bulk-actions', ['id_divname' => 'AssignedAssetsBulkEditToolbar', 'id_formname' => 'assignedAssetsBulkForm', 'id_button' => 'AssignedbulkAssetEditButton'])
    - -

    {{ trans('admin/hardware/form.default_location') }}

    -
    @include('partials.asset-bulk-actions', ['id_divname' => 'RTDassetsBulkEditToolbar', 'id_formname' => 'RTDassets', 'id_button' => 'RTDbulkAssetEditButton'])
    - -

    {{ trans('general.accessories') }}

    -
    -
    - -
    -

    {{ trans('general.accessories_assigned') }}

    @@ -345,15 +326,11 @@ "ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"] }'> - -

    {{ trans('general.consumables') }}

    - -
    - -

    {{ trans('general.components') }}

    -
    - name) }}-components-{{ date('Y-m-d') }}", "ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"] }'> -
    -
    @@ -434,7 +405,7 @@ {{ trans('admin/hardware/table.icon') }} {{ trans('general.date') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('general.action') }} {{ trans('general.item') }} {{ trans('general.target') }} @@ -490,6 +461,9 @@ @if ($location->manager)
  • {{ trans('admin/users/table.manager') }}: {!! $location->manager->present()->nameUrl() !!}
  • @endif + @if ($location->company) +
  • {{ trans('admin/companies/table.name') }}: {!! $location->company->present()->nameUrl() !!}
  • + @endif @if ($location->parent)
  • {{ trans('admin/locations/table.parent') }}: {!! $location->parent->present()->nameUrl() !!}
  • @endif diff --git a/resources/views/modals/location.blade.php b/resources/views/modals/location.blade.php index 9580f4bbf9..6816f32869 100644 --- a/resources/views/modals/location.blade.php +++ b/resources/views/modals/location.blade.php @@ -11,6 +11,16 @@
    @include('modals.partials.name', ['item' => new \App\Models\Location(), 'required' => 'true']) + + @if (($snipeSettings->scope_locations_fmcs == '1') && ($user->company)) + + @endif + + +
    + @include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id']) +
    +
    diff --git a/resources/views/models/bulk-edit.blade.php b/resources/views/models/bulk-edit.blade.php index 8dd0f5ba53..8131a470bc 100644 --- a/resources/views/models/bulk-edit.blade.php +++ b/resources/views/models/bulk-edit.blade.php @@ -90,6 +90,9 @@
    + @include ('partials.forms.edit.minimum_quantity') + +
    diff --git a/resources/views/models/custom_fields_form.blade.php b/resources/views/models/custom_fields_form.blade.php index 3297c15c9e..2346218a38 100644 --- a/resources/views/models/custom_fields_form.blade.php +++ b/resources/views/models/custom_fields_form.blade.php @@ -101,10 +101,4 @@ @endif - + diff --git a/resources/views/partials/bootstrap-table.blade.php b/resources/views/partials/bootstrap-table.blade.php index 4f3c1d5ff9..1e3264e0d5 100644 --- a/resources/views/partials/bootstrap-table.blade.php +++ b/resources/views/partials/bootstrap-table.blade.php @@ -341,7 +341,7 @@ function hardwareAuditFormatter(value, row) { - return '{{ trans('general.audit') }}'; + return '{{ trans('general.audit') }}'; } @@ -547,11 +547,11 @@ // This is only used by the requestable assets section function assetRequestActionsFormatter (row, value) { if (value.assigned_to_self == true){ - return ''; + return ''; } else if (value.available_actions.cancel == true) { - return '
    @csrf
    '; + return '
    @csrf
    '; } else if (value.available_actions.request == true) { - return '
    @csrf
    '; + return '
    @csrf
    '; } } @@ -819,6 +819,14 @@ } } + function locationCompanyObjFilterFormatter(value, row) { + if (value) { + return '' + row.company.name + ''; + } else { + return value; + } + } + function employeeNumFormatter(value, row) { if ((row) && (row.assigned_to) && ((row.assigned_to.employee_number))) { diff --git a/resources/views/partials/forms/edit/minimum_quantity.blade.php b/resources/views/partials/forms/edit/minimum_quantity.blade.php index 5c9f5e8588..7a444583d1 100644 --- a/resources/views/partials/forms/edit/minimum_quantity.blade.php +++ b/resources/views/partials/forms/edit/minimum_quantity.blade.php @@ -3,7 +3,7 @@
    - +
    diff --git a/resources/views/reports/asset_maintenances.blade.php b/resources/views/reports/asset_maintenances.blade.php index 3da2e798fd..406375ccd8 100644 --- a/resources/views/reports/asset_maintenances.blade.php +++ b/resources/views/reports/asset_maintenances.blade.php @@ -46,7 +46,7 @@ {{ trans('general.location') }} {{ trans('admin/hardware/form.default_location') }} {{ trans('admin/asset_maintenances/table.is_warranty') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('admin/asset_maintenances/form.notes') }} diff --git a/resources/views/settings/general.blade.php b/resources/views/settings/general.blade.php index 1b123d3519..481fa90921 100644 --- a/resources/views/settings/general.blade.php +++ b/resources/views/settings/general.blade.php @@ -36,7 +36,7 @@
    -
    +
    @@ -54,7 +54,24 @@

    + + +
    +
    + {{ Form::label('scope_locations_fmcs', trans('admin/settings/general.scope_locations_fmcs_support_text')) }} +
    +
    + + {!! $errors->first('scope_locations_fmcs', '') !!} +

    + {{ trans('admin/settings/general.scope_locations_fmcs_support_help_text') }} +

    +
    +
    @@ -93,7 +110,7 @@
    - {!! Form::username_format('email_format', old('email_format', $setting->email_format), 'select2') !!} + {!! Form::email_format('email_format', old('email_format', $setting->email_format), 'select2') !!} {!! $errors->first('email_format', '') !!}
    @@ -506,6 +523,5 @@ }); }); - @stop diff --git a/resources/views/settings/labels.blade.php b/resources/views/settings/labels.blade.php index 92838d72ac..6b3442420b 100644 --- a/resources/views/settings/labels.blade.php +++ b/resources/views/settings/labels.blade.php @@ -301,7 +301,10 @@ {{ Helper::getFormattedDateObject($asset->last_checkout, 'datetime', false) }} - @if (($asset->assetlog->first()) && ($asset->assetlog->first()->accept_signature!='')) - + @if (($asset->assetlog->firstWhere('action_type', 'accepted')) && ($asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature!='')) + @endif @@ -174,8 +174,8 @@ {{ Helper::getFormattedDateObject($asset->last_checkout, 'datetime', false) }} - @if (($asset->assetlog->first()) && ($asset->assetlog->first()->accept_signature!='')) - + @if (($asset->assetlog->firstWhere('action_type', 'accepted')) && ($asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature!='')) + @endif diff --git a/resources/views/users/view.blade.php b/resources/views/users/view.blade.php index 7ee3c4926c..e77ab3bd23 100755 --- a/resources/views/users/view.blade.php +++ b/resources/views/users/view.blade.php @@ -1003,7 +1003,7 @@ {{ trans('general.signature') }} @endif {{ trans('admin/hardware/table.serial') }} - {{ trans('general.admin') }} + {{ trans('general.created_by') }} {{ trans('admin/settings/general.login_ip') }} {{ trans('admin/settings/general.login_user_agent') }} {{ trans('general.action_source') }} diff --git a/routes/api.php b/routes/api.php index 9adc83af23..5724990c7e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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' diff --git a/routes/web.php b/routes/web.php index 38a22ab02d..14326f160c 100644 --- a/routes/web.php +++ b/routes/web.php @@ -422,10 +422,6 @@ Route::group(['prefix' => 'account', 'middleware' => ['auth']], function () { $trail->parent('home') ->push(trans('general.requestable_items'), route('requestable-assets'))); - Route::post( - 'request-asset/{assetId}', - [ViewAssetsController::class, 'getRequestAsset'] - )->name('account/request-asset'); Route::post('request-asset/{asset}', [ViewAssetsController::class, 'store']) ->name('account.request-asset'); diff --git a/routes/web/hardware.php b/routes/web/hardware.php index 3b65f730ed..21f4f40d41 100644 --- a/routes/web/hardware.php +++ b/routes/web/hardware.php @@ -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'); diff --git a/tests/Feature/Assets/Api/AuditAssetTest.php b/tests/Feature/Assets/Api/AuditAssetTest.php new file mode 100644 index 0000000000..f93a65ae6d --- /dev/null +++ b/tests/Feature/Assets/Api/AuditAssetTest.php @@ -0,0 +1,78 @@ +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); + + } + + +} diff --git a/tests/Feature/Assets/Ui/AuditAssetTest.php b/tests/Feature/Assets/Ui/AuditAssetTest.php new file mode 100644 index 0000000000..a966e6f458 --- /dev/null +++ b/tests/Feature/Assets/Ui/AuditAssetTest.php @@ -0,0 +1,34 @@ +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'); + } +} diff --git a/tests/Feature/Assets/Ui/DeleteAssetTest.php b/tests/Feature/Assets/Ui/DeleteAssetTest.php new file mode 100644 index 0000000000..a12e9e2cd0 --- /dev/null +++ b/tests/Feature/Assets/Ui/DeleteAssetTest.php @@ -0,0 +1,86 @@ +actingAs(User::factory()->create()) + ->delete(route('hardware.destroy', Asset::factory()->create())) + ->assertForbidden(); + } + + public function testCanDeleteAsset() + { + $asset = Asset::factory()->create(); + + $this->actingAs(User::factory()->deleteAssets()->create()) + ->delete(route('hardware.destroy', $asset)) + ->assertRedirectToRoute('hardware.index') + ->assertSessionHas('success'); + + $this->assertSoftDeleted($asset); + } + + public function testActionLogEntryMadeWhenAssetDeleted() + { + $actor = User::factory()->deleteAssets()->create(); + + $asset = Asset::factory()->create(); + + $this->actingAs($actor)->delete(route('hardware.destroy', $asset)); + + $this->assertDatabaseHas('action_logs', [ + 'created_by' => $actor->id, + 'action_type' => 'delete', + 'target_id' => null, + 'target_type' => null, + 'item_type' => Asset::class, + 'item_id' => $asset->id, + ]); + } + + public function testAssetIsCheckedInWhenDeleted() + { + Event::fake(); + + $assignedUser = User::factory()->create(); + $asset = Asset::factory()->assignedToUser($assignedUser)->create(); + + $this->assertTrue($assignedUser->assets->contains($asset)); + + $this->actingAs(User::factory()->deleteAssets()->create()) + ->delete(route('hardware.destroy', $asset)); + + $this->assertFalse( + $assignedUser->fresh()->assets->contains($asset), + 'Asset still assigned to user after deletion' + ); + + Event::assertDispatched(CheckoutableCheckedIn::class); + } + + public function testImageIsDeletedWhenAssetDeleted() + { + Storage::fake('public'); + + $asset = Asset::factory()->create(['image' => 'image.jpg']); + + Storage::disk('public')->put('assets/image.jpg', 'content'); + + Storage::disk('public')->assertExists('assets/image.jpg'); + + $this->actingAs(User::factory()->deleteAssets()->create()) + ->delete(route('hardware.destroy', $asset)); + + Storage::disk('public')->assertMissing('assets/image.jpg'); + } +} diff --git a/tests/Feature/Checkins/Api/LicenseCheckInTest.php b/tests/Feature/Checkins/Api/LicenseCheckInTest.php new file mode 100644 index 0000000000..385933655c --- /dev/null +++ b/tests/Feature/Checkins/Api/LicenseCheckInTest.php @@ -0,0 +1,45 @@ +superuser()->create(); + $this->actingAsForApi($authUser); + + $license = License::factory()->create(); + $oldUser = User::factory()->create(); + + $licenseSeat = LicenseSeat::factory()->for($license)->create([ + 'assigned_to' => $oldUser->id, + 'notes' => 'Previously checked out', + ]); + + $payload = [ + 'assigned_to' => null, + 'asset_id' => null, + 'notes' => 'Checking in the seat', + ]; + + $response = $this->patchJson( + route('api.licenses.seats.update', [$license->id, $licenseSeat->id]), + $payload); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'status' => 'success', + ]); + + $licenseSeat->refresh(); + + $this->assertNull($licenseSeat->assigned_to); + $this->assertNull($licenseSeat->asset_id); + + $this->assertEquals('Checking in the seat', $licenseSeat->notes); + } +} \ No newline at end of file diff --git a/tests/Feature/Checkouts/Api/LicenseCheckOutTest.php b/tests/Feature/Checkouts/Api/LicenseCheckOutTest.php new file mode 100644 index 0000000000..7a069b7d28 --- /dev/null +++ b/tests/Feature/Checkouts/Api/LicenseCheckOutTest.php @@ -0,0 +1,41 @@ +superuser()->create(); + $this->actingAsForApi($authUser); + + $license = License::factory()->create(); + $licenseSeat = LicenseSeat::factory()->for($license)->create([ + 'assigned_to' => null, + ]); + + $targetUser = User::factory()->create(); + + $payload = [ + 'assigned_to' => $targetUser->id, + 'notes' => 'Checking out the seat to a user', + ]; + + $response = $this->patchJson( + route('api.licenses.seats.update', [$license->id, $licenseSeat->id]), + $payload); + + $response->assertStatus(200) + ->assertJsonFragment([ + 'status' => 'success', + ]); + + $licenseSeat->refresh(); + + $this->assertEquals($targetUser->id, $licenseSeat->assigned_to); + $this->assertEquals('Checking out the seat to a user', $licenseSeat->notes); + } +} \ No newline at end of file diff --git a/tests/Feature/Users/Ui/EmailAssignedToUserTest.php b/tests/Feature/Users/Ui/EmailAssignedToUserTest.php new file mode 100644 index 0000000000..a6403679c0 --- /dev/null +++ b/tests/Feature/Users/Ui/EmailAssignedToUserTest.php @@ -0,0 +1,40 @@ +settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $superuser = User::factory()->superuser()->create(); + $user = User::factory()->for($companyB)->create(); + + $this->actingAs(User::factory()->viewUsers()->for($companyA)->create()) + ->post(route('users.email', ['userId' => $user->id])) + ->assertStatus(403); + + $this->actingAs(User::factory()->viewUsers()->for($companyB)->create()) + ->post(route('users.email', ['userId' => $user->id])) + ->assertStatus(302); + + $this->actingAs($superuser) + ->post(route('users.email', ['userId' => $user->id])) + ->assertStatus(302); + + Notification::assertSentTo( + [$user], CurrentInventory::class + ); + } +} diff --git a/tests/Feature/Users/Ui/PrintUserInventoryTest.php b/tests/Feature/Users/Ui/PrintUserInventoryTest.php new file mode 100644 index 0000000000..4de7c7cddd --- /dev/null +++ b/tests/Feature/Users/Ui/PrintUserInventoryTest.php @@ -0,0 +1,41 @@ +actingAs(User::factory()->create()) + ->get(route('users.print', User::factory()->create())) + ->assertStatus(403); + } + + public function testCanPrintUserInventory() + { + $actor = User::factory()->viewUsers()->create(); + + $this->actingAs($actor) + ->get(route('users.print', User::factory()->create())) + ->assertOk() + ->assertStatus(200); + } + + public function testCannotPrintUserInventoryFromAnotherCompany() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $actor = User::factory()->for($companyA)->viewUsers()->create(); + $user = User::factory()->for($companyB)->create(); + + $this->actingAs($actor) + ->get(route('users.print', $user)) + ->assertStatus(302); + } +} diff --git a/tests/Feature/Users/Ui/ViewUserTest.php b/tests/Feature/Users/Ui/ViewUserTest.php index cb21a1e9e2..ca45601ffb 100644 --- a/tests/Feature/Users/Ui/ViewUserTest.php +++ b/tests/Feature/Users/Ui/ViewUserTest.php @@ -4,80 +4,38 @@ namespace Tests\Feature\Users\Ui; use App\Models\Company; use App\Models\User; -use App\Notifications\CurrentInventory; -use Illuminate\Support\Facades\Notification; use Tests\TestCase; class ViewUserTest extends TestCase { - public function testPermissionsForUserDetailPage() + public function testRequiresPermissionToViewUser() { - $this->settings->enableMultipleFullCompanySupport(); - - [$companyA, $companyB] = Company::factory()->count(2)->create(); - - $superuser = User::factory()->superuser()->create(); - $user = User::factory()->for($companyB)->create(); - - $this->actingAs(User::factory()->editUsers()->for($companyA)->create()) - ->get(route('users.show', $user)) - ->assertStatus(302); - - $this->actingAs($superuser) - ->get(route('users.show', $user)) - ->assertOk() - ->assertStatus(200); - } - - public function testPermissionsForPrintAllInventoryPage() - { - $this->settings->enableMultipleFullCompanySupport(); - - [$companyA, $companyB] = Company::factory()->count(2)->create(); - - $superuser = User::factory()->superuser()->create(); - $user = User::factory()->for($companyB)->create(); - - $this->actingAs(User::factory()->viewUsers()->for($companyA)->create()) - ->get(route('users.print', ['userId' => $user->id])) - ->assertStatus(302); - - $this->actingAs(User::factory()->viewUsers()->for($companyB)->create()) - ->get(route('users.print', ['userId' => $user->id])) - ->assertStatus(200); - - $this->actingAs($superuser) - ->get(route('users.print', ['userId' => $user->id])) - ->assertOk() - ->assertStatus(200); - } - - public function testUserWithoutCompanyPermissionsCannotSendInventory() - { - - Notification::fake(); - - $this->settings->enableMultipleFullCompanySupport(); - - [$companyA, $companyB] = Company::factory()->count(2)->create(); - - $superuser = User::factory()->superuser()->create(); - $user = User::factory()->for($companyB)->create(); - - $this->actingAs(User::factory()->viewUsers()->for($companyA)->create()) - ->post(route('users.email', ['userId' => $user->id])) + $this->actingAs(User::factory()->create()) + ->get(route('users.show', User::factory()->create())) ->assertStatus(403); + } - $this->actingAs(User::factory()->viewUsers()->for($companyB)->create()) - ->post(route('users.email', ['userId' => $user->id])) + public function testCanViewUser() + { + $actor = User::factory()->viewUsers()->create(); + + $this->actingAs($actor) + ->get(route('users.show', User::factory()->create())) + ->assertOk() + ->assertStatus(200); + } + + public function testCannotViewUserFromAnotherCompany() + { + $this->settings->enableMultipleFullCompanySupport(); + + [$companyA, $companyB] = Company::factory()->count(2)->create(); + + $actor = User::factory()->for($companyA)->viewUsers()->create(); + $user = User::factory()->for($companyB)->create(); + + $this->actingAs($actor) + ->get(route('users.show', $user)) ->assertStatus(302); - - $this->actingAs($superuser) - ->post(route('users.email', ['userId' => $user->id])) - ->assertStatus(302); - - Notification::assertSentTo( - [$user], CurrentInventory::class - ); } } diff --git a/tests/Unit/UserTest.php b/tests/Unit/UserTest.php index ed4b403498..0d2b41a780 100644 --- a/tests/Unit/UserTest.php +++ b/tests/Unit/UserTest.php @@ -30,6 +30,13 @@ class UserTest extends TestCase $expected_username = 'allanovna-romanova-oshostakova'; $user = User::generateFormattedNameFromFullName($fullname, 'lastname'); $this->assertEquals($expected_username, $user['username']); + + public function testFirstNameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'natalia@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); } public function testFirstNameDotLastName() @@ -40,6 +47,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testFirstNameDotLastNameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'natalia.allanovna-romanova-oshostakova@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstname.lastname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testLastNameFirstInitial() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -48,6 +63,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testLastNameFirstInitialEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'allanovna-romanova-oshostakovan@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'lastnamefirstinitial'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testFirstInitialLastName() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -56,6 +79,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testFirstInitialLastNameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'nallanovna-romanova-oshostakova@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'filastname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testFirstInitialUnderscoreLastName() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -64,6 +95,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testFirstInitialUnderscoreLastNameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'nallanovna-romanova-oshostakova@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstinitial_lastname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testSingleName() { $fullname = 'Natalia'; @@ -72,6 +111,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testSingleNameEmail() + { + $fullname = 'Natalia'; + $expected_email = 'natalia@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstname_lastname',); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testFirstInitialDotLastname() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -80,6 +127,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testFirstInitialDotLastnameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'nallanovna-romanova-oshostakova@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstinitial.lastname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testLastNameDotFirstInitial() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -88,6 +143,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testLastNameDotFirstInitialEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'allanovna-romanova-oshostakova.n@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'lastname.firstinitial'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testLastNameUnderscoreFirstInitial() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -96,6 +159,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testLastNameUnderscoreFirstInitialEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'allanovna-romanova-oshostakova_n@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'lastname_firstinitial'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testFirstNameLastName() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -104,6 +175,14 @@ class UserTest extends TestCase $this->assertEquals($expected_username, $user['username']); } + public function testFirstNameLastNameEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'nataliaallanovna-romanova-oshostakova@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstnamelastname'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } + public function testFirstNameLastInitial() { $fullname = "Natalia Allanovna Romanova-O'Shostakova"; @@ -111,4 +190,12 @@ class UserTest extends TestCase $user = User::generateFormattedNameFromFullName($fullname, 'firstnamelastinitial'); $this->assertEquals($expected_username, $user['username']); } + + public function testFirstNameLastInitialEmail() + { + $fullname = "Natalia Allanovna Romanova-O'Shostakova"; + $expected_email = 'nataliaa@example.com'; + $user = User::generateFormattedNameFromFullName($fullname, 'firstnamelastinitial'); + $this->assertEquals($expected_email, $user['username'] . '@example.com'); + } }