Merge branch 'develop' into lastnameemail

This commit is contained in:
akemidx 2025-04-08 13:10:26 -04:00 committed by GitHub
commit f659b7631d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
85 changed files with 1689 additions and 320 deletions

View file

@ -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!

View file

@ -0,0 +1,151 @@
<?php
namespace App\Console\Commands;
use App\Models\Accessory;
use App\Models\Actionlog;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Model;
class FixBulkAccessoryCheckinActionLogEntries extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:fix-bulk-accessory-action-log-entries {--dry-run : Run the sync process but don\'t update the database} {--skip-backup : Skip pre-execution backup}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This script attempts to fix timestamps and missing created_by values for bulk checkin entries in the log table';
private bool $dryrun = false;
private bool $skipBackup = false;
/**
* Execute the console command.
*/
public function handle()
{
$this->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;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Console\Commands;
use App\Helpers\Helper;
use Illuminate\Console\Command;
class TestLocationsFMCS extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:test-locations-fmcs {--location_id=}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Test for company ID inconsistencies if FullMultipleCompanySupport with scoped locations will be used.';
/**
* Execute the console command.
*/
public function handle()
{
$this->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);
}
}

View file

@ -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 <tobias.regnery@gmail.com>
* @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;
}
}

View file

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

View file

@ -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')));
}

View file

@ -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');

View file

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

View file

@ -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);

View file

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

View file

@ -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'));
}

View file

@ -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'),

View file

@ -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'));
}

View file

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

View file

@ -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');

View file

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

View file

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

View file

@ -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,
];

View file

@ -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',

View file

@ -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] [<snipe@snipe.net>]
* @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] [<snipe@snipe.net>]
* @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] [<snipe@snipe.net>]
* @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] [<snipe@snipe.net>]
* @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
*

View file

@ -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] [<snipe@snipe.net>]
* @since [v2.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function audits()
{
return $this->assetlog()->where('action_type', '=', 'audit')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* Get the list of checkins for this asset
*

View file

@ -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);
}
}
}
}

View file

@ -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',

View file

@ -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',

View file

@ -30,6 +30,7 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild
protected $fillable = [
'assigned_to',
'asset_id',
'notes',
];
use Acceptable;

View file

@ -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] [<tobias.regnery@gmail.com>]
* @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);
}
}

View file

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

View file

@ -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',

View file

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

View file

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

View file

@ -555,7 +555,7 @@ class AssetPresenter extends Presenter
*/
public function statusMeta()
{
if ($this->model->assigned) {
if ($this->model->assigned_to) {
return 'deployed';
}

View file

@ -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',
],

View file

@ -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;
});
}
/**

View file

@ -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);

View file

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

View file

@ -0,0 +1,35 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddCompanyIdToLocations extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('locations', function (Blueprint $table) {
$table->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');
});
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddScopeLocationsSetting extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('settings', function (Blueprint $table) {
$table->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');
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -2,8 +2,8 @@
"/js/build/app.js": "/js/build/app.js?id=607de09b70b83ef82a427e4b36341682",
"/css/dist/skins/skin-black-dark.css": "/css/dist/skins/skin-black-dark.css?id=06c13e817cc022028b3f4a33c0ca303a",
"/css/dist/skins/_all-skins.css": "/css/dist/skins/_all-skins.css?id=79aa889a1a6691013be6c342ca7391cd",
"/css/build/overrides.css": "/css/build/overrides.css?id=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",

View file

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

View file

@ -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',

View file

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

View file

@ -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',
],
];

View file

@ -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 <code>Admin Settings > Permission Groups</code> 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',

View file

@ -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.',
],
];

View file

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

View file

@ -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 = '<select name="'.$name.'" class="'.$class.'" style="width: 100%" aria-label="'.$name.'">';
foreach ($formats as $format => $label) {
$select .= '<option value="'.$format.'"'.($selected == $format ? ' selected="selected" role="option" aria-selected="true"' : ' aria-selected="false"').'>'.$label.'</option> '."\n";
}
$select .= '</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'),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,9 @@
<!-- Manager-->
@include ('partials.forms.edit.user-select', ['translated_name' => trans('admin/users/table.manager'), 'fieldname' => 'manager_id'])
<!-- Company -->
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id'])
@include ('partials.forms.edit.phone')
@include ('partials.forms.edit.fax')

View file

@ -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"]

View file

@ -53,7 +53,11 @@
@if ($parent)
{{ $parent->present()->fullName() }}
@endif
<br>
@if ($company)
<b>{{ trans('admin/companies/table.name') }}:</b> {{ $company->present()->Name() }}</b>
<br>
@endif
@if ($manager)
<b>{{ trans('general.manager') }}</b> {{ $manager->present()->fullName() }}<br>
@endif

View file

@ -157,11 +157,39 @@
<div class="tab-content">
@can('view', \App\Models\User::class)
<div id="users" @class(['tab-pane','active' => $location->users->count() > 0 ]) >
@endcan
<h2 class="box-title">{{ trans('general.users') }}</h2>
@include('partials.users-bulk-actions')
<table
data-columns="{{ \App\Presenters\UserPresenter::dataTableLayout() }}"
data-cookie-id-table="usersTable"
data-pagination="true"
data-id-table="usersTable"
data-search="true"
data-side-pagination="server"
data-show-columns="true"
data-show-export="true"
data-show-refresh="true"
data-sort-order="asc"
data-toolbar="#userBulkEditToolbar"
data-bulk-button-id="#bulkUserEditButton"
data-bulk-form-id="#usersBulkForm"
data-click-to-select="true"
id="usersTable"
class="table table-striped snipe-table"
data-url="{{route('api.users.index', ['location_id' => $location->id])}}"
data-export-options='{
"fileName": "export-locations-{{ str_slug($location->name) }}-users-{{ date('Y-m-d') }}",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.tab-pane -->
<div id="assets" @class(['tab-pane', 'active' => $location->users->count() == 0]) >
<div class="tab-pane active" id="assets">
<h2 class="box-title">{{ trans('admin/locations/message.current_location') }}</h2>
<div class="table table-responsive">
@include('partials.asset-bulk-actions')
<table
data-columns="{{ \App\Presenters\AssetPresenter::dataTableLayout() }}"
@ -186,50 +214,13 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="users">
<h2 class="box-title">{{ trans('general.users') }}</h2>
<div class="table table-responsive">
@include('partials.users-bulk-actions')
<table
data-columns="{{ \App\Presenters\UserPresenter::dataTableLayout() }}"
data-cookie-id-table="usersTable"
data-pagination="true"
data-id-table="usersTable"
data-search="true"
data-side-pagination="server"
data-show-columns="true"
data-show-export="true"
data-show-refresh="true"
data-sort-order="asc"
data-toolbar="#userBulkEditToolbar"
data-bulk-button-id="#bulkUserEditButton"
data-bulk-form-id="#usersBulkForm"
data-click-to-select="true"
id="usersTable"
class="table table-striped snipe-table"
data-url="{{route('api.users.index', ['location_id' => $location->id])}}"
data-export-options='{
"fileName": "export-locations-{{ str_slug($location->name) }}-users-{{ date('Y-m-d') }}",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="assets_assigned">
<h2 class="box-title">
{{ trans('admin/locations/message.assigned_assets') }}
</h2>
<div class="table table-responsive">
@include('partials.asset-bulk-actions', ['id_divname' => 'AssignedAssetsBulkEditToolbar', 'id_formname' => 'assignedAssetsBulkForm', 'id_button' => 'AssignedbulkAssetEditButton'])
<table
data-columns="{{ \App\Presenters\AssetPresenter::dataTableLayout() }}"
@ -254,14 +245,11 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="rtd_assets">
<h2 class="box-title">{{ trans('admin/hardware/form.default_location') }}</h2>
<div class="table table-responsive">
@include('partials.asset-bulk-actions', ['id_divname' => 'RTDassetsBulkEditToolbar', 'id_formname' => 'RTDassets', 'id_button' => 'RTDbulkAssetEditButton'])
<table
data-columns="{{ \App\Presenters\AssetPresenter::dataTableLayout() }}"
@ -286,15 +274,12 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="accessories">
<h2 class="box-title">{{ trans('general.accessories') }}</h2>
<div class="table table-responsive">
<table
data-columns="{{ \App\Presenters\AccessoryPresenter::dataTableLayout() }}"
data-cookie-id-table="accessoriesListingTable"
@ -314,13 +299,9 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="accessories_assigned">
<div class="table table-responsive">
<h2 class="box-title" style="float:left">
{{ trans('general.accessories_assigned') }}
</h2>
@ -345,15 +326,11 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="consumables">
<h2 class="box-title">{{ trans('general.consumables') }}</h2>
<div class="table table-responsive">
<table
data-columns="{{ \App\Presenters\ConsumablePresenter::dataTableLayout() }}"
data-cookie-id-table="consumablesListingTable"
@ -373,14 +350,10 @@
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="components">
<h2 class="box-title">{{ trans('general.components') }}</h2>
<div class="table table-responsive">
<table
data-columns="{{ \App\Presenters\ComponentPresenter::dataTableLayout() }}"
data-cookie-id-table="componentsTable"
@ -399,9 +372,7 @@
"fileName": "export-locations-{{ str_slug($location->name) }}-components-{{ date('Y-m-d') }}",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
</table>
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane" id="history">
@ -434,7 +405,7 @@
<tr>
<th data-visible="true" data-field="icon" style="width: 40px;" class="hidden-xs" data-formatter="iconFormatter">{{ trans('admin/hardware/table.icon') }}</th>
<th class="col-sm-2" data-visible="true" data-field="action_date" data-formatter="dateDisplayFormatter">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.admin') }}</th>
<th class="col-sm-1" data-visible="true" data-field="admin" data-formatter="usersLinkObjFormatter">{{ trans('general.created_by') }}</th>
<th class="col-sm-1" data-visible="true" data-field="action_type">{{ trans('general.action') }}</th>
<th class="col-sm-2" data-visible="true" data-field="item" data-formatter="polymorphicItemFormatter">{{ trans('general.item') }}</th>
<th class="col-sm-2" data-visible="true" data-field="target" data-formatter="polymorphicItemFormatter">{{ trans('general.target') }}</th>
@ -490,6 +461,9 @@
@if ($location->manager)
<li>{{ trans('admin/users/table.manager') }}: {!! $location->manager->present()->nameUrl() !!}</li>
@endif
@if ($location->company)
<li>{{ trans('admin/companies/table.name') }}: {!! $location->company->present()->nameUrl() !!}</li>
@endif
@if ($location->parent)
<li>{{ trans('admin/locations/table.parent') }}: {!! $location->parent->present()->nameUrl() !!}</li>
@endif

View file

@ -11,6 +11,16 @@
</div>
@include('modals.partials.name', ['item' => new \App\Models\Location(), 'required' => 'true'])
<!-- Setup of default company, taken from asset creator if scoped locations are activated in the settings -->
@if (($snipeSettings->scope_locations_fmcs == '1') && ($user->company))
<input type="hidden" name="company_id" id='modal-company' value='{{ $user->company->id }}' class="form-control">
@endif
<!-- Select company, only for users with multicompany access - replace default company -->
<div class="dynamic-form-row">
@include ('partials.forms.edit.company-select', ['translated_name' => trans('general.company'), 'fieldname' => 'company_id'])
</div>
<div class="dynamic-form-row">
<div class="col-md-4 col-xs-12"><label for="modal-city">{{ trans('general.city') }}:</label></div>
<div class="col-md-8 col-xs-12"><input type='text' name="city" id='modal-city' class="form-control"></div>

View file

@ -90,6 +90,9 @@
</div>
</div>
@include ('partials.forms.edit.minimum_quantity')
<!-- requestable -->
<div class="form-group{{ $errors->has('requestable') ? ' has-error' : '' }}">
<div class="col-md-7 col-md-offset-3">

View file

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

View file

@ -341,7 +341,7 @@
function hardwareAuditFormatter(value, row) {
return '<a href="{{ config('app.url') }}/hardware/audit/' + row.id + '/" class="btn btn-sm bg-yellow" data-tooltip="true" title="Audit this item">{{ trans('general.audit') }}</a>';
return '<a href="{{ config('app.url') }}/hardware/' + row.id + '/audit" class="btn btn-sm bg-yellow" data-tooltip="true" title="Audit this item">{{ trans('general.audit') }}</a>';
}
@ -547,11 +547,11 @@
// This is only used by the requestable assets section
function assetRequestActionsFormatter (row, value) {
if (value.assigned_to_self == true){
return '<button class="btn btn-danger btn-sm disabled" data-tooltip="true" title="Cancel this item request">{{ trans('button.cancel') }}</button>';
return '<button class="btn btn-danger btn-sm btn-block disabled" data-tooltip="true" title="{{ trans('admin/hardware/message.requests.cancel') }}">{{ trans('button.cancel') }}</button>';
} else if (value.available_actions.cancel == true) {
return '<form action="{{ config('app.url') }}/account/request-asset/' + value.id + '/cancel" method="POST">@csrf<button class="btn btn-danger btn-sm" data-tooltip="true" title="Cancel this item request">{{ trans('button.cancel') }}</button></form>';
return '<form action="{{ config('app.url') }}/account/request-asset/' + value.id + '/cancel" method="POST">@csrf<button class="btn btn-danger btn-block btn-sm" data-tooltip="true" title="{{ trans('admin/hardware/message.requests.cancel') }}">{{ trans('button.cancel') }}</button></form>';
} else if (value.available_actions.request == true) {
return '<form action="{{ config('app.url') }}/account/request-asset/'+ value.id + '" method="POST">@csrf<button class="btn btn-primary btn-sm" data-tooltip="true" title="{{ trans('general.request_item') }}">{{ trans('button.request') }}</button></form>';
return '<form action="{{ config('app.url') }}/account/request-asset/'+ value.id + '" method="POST">@csrf<button class="btn btn-block btn-primary btn-sm" data-tooltip="true" title="{{ trans('general.request_item') }}">{{ trans('button.request') }}</button></form>';
}
}
@ -819,6 +819,14 @@
}
}
function locationCompanyObjFilterFormatter(value, row) {
if (value) {
return '<a href="{{ url('/') }}/locations/?company_id=' + row.company.id + '">' + row.company.name + '</a>';
} else {
return value;
}
}
function employeeNumFormatter(value, row) {
if ((row) && (row.assigned_to) && ((row.assigned_to.employee_number))) {

View file

@ -3,7 +3,7 @@
<label for="min_amt" class="col-md-3 control-label">{{ trans('general.min_amt') }}</label>
<div class="col-md-9">
<div class="col-md-2" style="padding-left:0px">
<input class="form-control col-md-3" maxlength="5" type="text" name="min_amt" id="min_amt" aria-label="min_amt" value="{{ old('min_amt', $item->min_amt) }}"{{ (Helper::checkIfRequired($item, 'min_amt')) ? ' required' : '' }}/>
<input class="form-control col-md-3" maxlength="5" type="text" name="min_amt" id="min_amt" aria-label="min_amt" value="{{ old('min_amt', ($item->min_amt ?? '')) }}"{{ (isset($item) ?? (Helper::checkIfRequired($item, 'min_amt')) ? ' required' : '') }}/>
</div>
<div class="col-md-7" style="margin-left: -15px;">

View file

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

View file

@ -36,7 +36,7 @@
<div class="box-body">
<div class="col-md-12">
<div class="col-md-11">
<!-- Full Multiple Companies Support -->
<div class="form-group {{ $errors->has('full_multiple_companies_support') ? 'error' : '' }}">
@ -54,7 +54,24 @@
</p>
</div>
</div>
<!-- /.form-group -->
<!-- Scope Locations with Full Multiple Companies Support -->
<div class="form-group {{ $errors->has('scope_locations_fmcs') ? 'error' : '' }}">
<div class="col-md-3">
{{ Form::label('scope_locations_fmcs', trans('admin/settings/general.scope_locations_fmcs_support_text')) }}
</div>
<div class="col-md-9">
<label class="form-control">
{{ Form::checkbox('scope_locations_fmcs', '1', old('scope_locations_fmcs', $setting->scope_locations_fmcs),array('class' => 'minimal', 'aria-label'=>'scope_locations_fmcs')) }}
{{ trans('admin/settings/general.scope_locations_fmcs_support_text') }}
</label>
{!! $errors->first('scope_locations_fmcs', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
<p class="help-block">
{{ trans('admin/settings/general.scope_locations_fmcs_support_help_text') }}
</p>
</div>
</div>
<!-- /.form-group -->
<!-- Require signature for acceptance -->
@ -93,7 +110,7 @@
<label for="email_format">{{ trans('general.email_format') }}</label>
</div>
<div class="col-md-9">
{!! 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', '<span class="alert-msg" aria-hidden="true">:message</span>') !!}
</div>
</div>
@ -506,6 +523,5 @@
});
});
</script>
@stop

View file

@ -301,7 +301,10 @@
<x-input.select
name="label2_2d_target"
id="label2_2d_target"
:options="['hardware_id'=>'/hardware/{id} ('.trans('admin/settings/general.default').')', 'ht_tag'=>'/ht/{asset_tag}']"
:options="['hardware_id'=>'/hardware/{id} ('.trans('admin/settings/general.default').')',
'ht_tag'=>'/ht/{asset_tag}',
'location' => '/location/{location_id}',
]"
:selected="old('label2_2d_target', $setting->label2_2d_target)"
class="col-md-4"
aria-label="label2_2d_target"

View file

@ -147,8 +147,8 @@
<td>
{{ Helper::getFormattedDateObject($asset->last_checkout, 'datetime', false) }}</td>
<td>
@if (($asset->assetlog->first()) && ($asset->assetlog->first()->accept_signature!=''))
<img style="width:auto;height:100px;" src="{{ asset('/') }}display-sig/{{ $asset->assetlog->first()->accept_signature }}">
@if (($asset->assetlog->firstWhere('action_type', 'accepted')) && ($asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature!=''))
<img style="width:auto;height:100px;" src="{{ asset('/') }}display-sig/{{ $asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature }}">
@endif
</td>
</tr>
@ -174,8 +174,8 @@
<td>
{{ Helper::getFormattedDateObject($asset->last_checkout, 'datetime', false) }}</td>
<td>
@if (($asset->assetlog->first()) && ($asset->assetlog->first()->accept_signature!=''))
<img style="width:auto;height:100px;" src="{{ asset('/') }}display-sig/{{ $asset->assetlog->first()->accept_signature }}">
@if (($asset->assetlog->firstWhere('action_type', 'accepted')) && ($asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature!=''))
<img style="width:auto;height:100px;" src="{{ asset('/') }}display-sig/{{ $asset->assetlog->firstWhere('action_type', 'accepted')->accept_signature }}">
@endif
</td>
</tr>

View file

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

View file

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

View file

@ -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');

View file

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

View file

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

View file

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

View file

@ -0,0 +1,86 @@
<?php
namespace Tests\Feature\Assets\Ui;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class DeleteAssetTest extends TestCase
{
public function testPermissionNeededToDeleteAsset()
{
$this->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');
}
}

View file

@ -0,0 +1,45 @@
<?php
namespace Tests\Feature\Checkins\Api;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tests\TestCase;
class LicenseCheckInTest extends TestCase {
public function testLicenseCheckin()
{
$authUser = User::factory()->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);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Tests\Feature\Checkouts\Api;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tests\TestCase;
class LicenseCheckOutTest extends TestCase {
public function testLicenseCheckout()
{
$authUser = User::factory()->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);
}
}

View file

@ -0,0 +1,40 @@
<?php
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 EmailAssignedToUserTest extends TestCase
{
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]))
->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
);
}
}

View file

@ -0,0 +1,41 @@
<?php
namespace Tests\Feature\Users\Ui;
use App\Models\Company;
use App\Models\User;
use Tests\TestCase;
class PrintUserInventoryTest extends TestCase
{
public function testPermissionRequiredToPrintUserInventory()
{
$this->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);
}
}

View file

@ -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
);
}
}

View file

@ -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');
}
}