Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b540a5afc0 | |||
| 663bab1f9d | |||
| 8b1e312292 | |||
| 9004211a59 | |||
| 053eb91457 | |||
| 3810513224 | |||
| e50e0f0e34 | |||
| cdf73f9c89 | |||
| bc808cbe46 | |||
| fdc65fb1b2 | |||
| 3db9a15dd3 | |||
| 42bf43d68d | |||
| 4548ed8a45 | |||
| 407e2d0246 | |||
| 2af7367480 | |||
| 29b9a78f54 | |||
| 382a164b9d | |||
| 9216a7550f | |||
| 4f9ba7c6cc | |||
| afb7c69ac3 | |||
| 5f232c0584 | |||
| ed931d497a | |||
| 59278c3f70 | |||
| 179d031bb2 | |||
| dc1410aa70 | |||
| 0f595a8854 | |||
| 70e1dcf1b4 | |||
| 780e3e1cd9 | |||
| 339c93ebbf | |||
| b4bb1556be | |||
| ba96aa5a61 | |||
| 2171556ec4 | |||
| 2d33368063 | |||
| 93a2f74f9e | |||
| ee61084ac8 | |||
| 8d8a1889cd | |||
| f275cb6928 | |||
| db8de1f794 | |||
| d901e821cc | |||
| 34a533b2d6 | |||
| d3d37c70ab | |||
| 475e674fc6 | |||
| 01436d0532 | |||
| 96bf7d0c2b | |||
| 529973aa77 | |||
| f4cd090ac6 | |||
| 6d5e68274d | |||
| 3e002cb940 | |||
| b7ea9a959c | |||
| dc3a16c437 | |||
| 608af84253 |
@@ -7,7 +7,7 @@
|
||||
|
||||
This is a FOSS project for asset management in IT Operations. Knowing who has which laptop, when it was purchased in order to depreciate it correctly, handling software licenses, etc.
|
||||
|
||||
It is built on [Laravel 11](http://laravel.com).
|
||||
It is built on [Laravel 12](http://laravel.com).
|
||||
|
||||
Snipe-IT is actively developed and we [release quite frequently](https://github.com/grokability/snipe-it/releases). ([Check out the live demo here](https://snipeitapp.com/demo/).)
|
||||
|
||||
|
||||
@@ -13,8 +13,13 @@ final class PreserveUnauthorizedPrivilegedPermissionsAction
|
||||
* @param array<string, mixed> $originalPermissions
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): array
|
||||
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = [], ?User $targetUser = null): array
|
||||
{
|
||||
// Disallow non-admin/superuser users from modifying their own permissions, but allow them to modify other users' permissions (except for admin/superuser keys).
|
||||
if ($targetUser && ! $authenticatedUser->isSuperUser() && $authenticatedUser->id === $targetUser->id) {
|
||||
return $originalPermissions;
|
||||
}
|
||||
|
||||
if (! $authenticatedUser->isSuperUser()) {
|
||||
if (array_key_exists('superuser', $originalPermissions)) {
|
||||
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
|
||||
|
||||
@@ -78,6 +78,7 @@ class IconHelper
|
||||
case 'angle-right':
|
||||
return 'fas fa-angle-right';
|
||||
case 'warning':
|
||||
case 'alert':
|
||||
return 'fas fa-exclamation-triangle';
|
||||
case 'kits':
|
||||
return 'fas fa-object-group';
|
||||
@@ -126,6 +127,7 @@ class IconHelper
|
||||
case 'dashboard':
|
||||
return 'fas fa-tachometer-alt';
|
||||
case 'info-circle':
|
||||
case 'info':
|
||||
return 'fas fa-info-circle';
|
||||
case 'caret-right':
|
||||
return 'fa fa-caret-right';
|
||||
@@ -156,6 +158,7 @@ class IconHelper
|
||||
case 'remote':
|
||||
return 'fa-solid fa-house-laptop';
|
||||
case 'more-info':
|
||||
case 'help':
|
||||
case 'support':
|
||||
return 'far fa-life-ring';
|
||||
case 'plus':
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Accessories;
|
||||
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Http\Request;
|
||||
|
||||
class AccessoryCheckoutController extends Controller
|
||||
{
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
/**
|
||||
* Return the form to checkout an Accessory to a user.
|
||||
|
||||
@@ -149,6 +149,9 @@ class AcceptanceController extends Controller
|
||||
|
||||
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
|
||||
|
||||
$username_slug = Str::slug($assignedUser->username);
|
||||
$asset_tag_slug = ($item instanceof Asset && $item->asset_tag) ? '-'.Str::slug($item->asset_tag) : '';
|
||||
|
||||
// If signatures are required, make sure we have one
|
||||
if ($requiresSignature) {
|
||||
|
||||
@@ -234,7 +237,7 @@ class AcceptanceController extends Controller
|
||||
|
||||
if ($request->input('asset_acceptance') === 'accepted') {
|
||||
|
||||
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
$pdf_filename = 'accepted-'.$username_slug.$asset_tag_slug.'-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
|
||||
// Generate the PDF content
|
||||
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreAccessoryRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Http\Transformers\AccessoriesTransformer;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -25,7 +25,7 @@ use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AccessoriesController extends Controller
|
||||
{
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
|
||||
@@ -38,6 +38,7 @@ class MaintenancesController extends Controller
|
||||
$this->authorize('view', Asset::class);
|
||||
|
||||
$maintenances = Maintenance::select('maintenances.*')
|
||||
->whereHas('asset')
|
||||
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
|
||||
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
|
||||
@@ -569,6 +569,7 @@ class UsersController extends Controller
|
||||
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
|
||||
authenticatedUser: $authenticatedUser,
|
||||
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
|
||||
targetUser: $user,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -133,14 +133,17 @@ class AssetCheckinController extends Controller
|
||||
|
||||
$this->migrateLegacyLocations($asset);
|
||||
|
||||
$asset->location_id = $asset->rtd_location_id;
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
Log::debug('NEW Location ID: '.$request->input('location_id'));
|
||||
$asset->location_id = $request->input('location_id');
|
||||
if ($request->has('location_id')) {
|
||||
if ($request->filled('location_id')) {
|
||||
Log::debug('NEW Location ID: ' . $request->input('location_id'));
|
||||
$asset->location_id = $request->input('location_id');
|
||||
|
||||
if ($request->input('update_default_location') == 0) {
|
||||
$asset->rtd_location_id = $request->input('location_id');
|
||||
if ($request->input('update_default_location') == 0) {
|
||||
$asset->rtd_location_id = $request->input('location_id');
|
||||
}
|
||||
} else {
|
||||
$asset->location_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
|
||||
|
||||
use App\Exceptions\CheckoutNotAllowed;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Setting;
|
||||
@@ -17,7 +17,7 @@ use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class AssetCheckoutController extends Controller
|
||||
{
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
/**
|
||||
* Returns a view that presents a form to check an asset out to a
|
||||
|
||||
@@ -4,9 +4,9 @@ namespace App\Http\Controllers\Assets;
|
||||
|
||||
use App\Events\CheckoutablesCheckedOutInBulk;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Company;
|
||||
@@ -27,7 +27,7 @@ use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BulkAssetsController extends Controller
|
||||
{
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
/**
|
||||
* Display the bulk edit page.
|
||||
|
||||
@@ -4,7 +4,8 @@ namespace App\Http\Controllers\Components;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreComponentRequest;
|
||||
use App\Http\Requests\UpdateComponentRequest;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -12,7 +13,6 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* This class controls all actions related to Components for
|
||||
@@ -74,7 +74,7 @@ class ComponentsController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function store(ImageUploadRequest $request)
|
||||
public function store(StoreComponentRequest $request)
|
||||
{
|
||||
$this->authorize('create', Component::class);
|
||||
$component = new Component;
|
||||
@@ -148,21 +148,10 @@ class ComponentsController extends Controller
|
||||
*
|
||||
* @since [v3.0]
|
||||
*/
|
||||
public function update(ImageUploadRequest $request, Component $component)
|
||||
public function update(UpdateComponentRequest $request, Component $component)
|
||||
{
|
||||
$min = $component->numCheckedOut();
|
||||
$validator = Validator::make($request->all(), [
|
||||
'qty' => "required|numeric|min:$min",
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return redirect()->back()
|
||||
->withErrors($validator)
|
||||
->withInput();
|
||||
}
|
||||
|
||||
$this->authorize('update', $component);
|
||||
|
||||
|
||||
// Update the component data
|
||||
$component->name = $request->input('name');
|
||||
$component->category_id = $request->input('category_id');
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers\Kits;
|
||||
|
||||
use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Asset;
|
||||
use App\Models\PredefinedKit;
|
||||
use App\Models\User;
|
||||
@@ -23,7 +23,7 @@ class CheckoutKitController extends Controller
|
||||
{
|
||||
public $kitService;
|
||||
|
||||
use CheckInOutRequest;
|
||||
use CheckInOutTrait;
|
||||
|
||||
public function __construct(PredefinedKitCheckoutService $kitService)
|
||||
{
|
||||
|
||||
@@ -1236,6 +1236,9 @@ class ReportsController extends Controller
|
||||
public function getAssetAcceptanceReport($deleted = false): View
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
|
||||
$this->disableDebugbar();
|
||||
|
||||
$showDeleted = $deleted == 'deleted';
|
||||
|
||||
$query = CheckoutAcceptance::Pending()
|
||||
|
||||
@@ -315,6 +315,7 @@ class UsersController extends Controller
|
||||
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
|
||||
authenticatedUser: $authenticatedUser,
|
||||
originalPermissions: $orig_permissions_array,
|
||||
targetUser: $user,
|
||||
));
|
||||
|
||||
// Only save groups if the user is a superuser
|
||||
@@ -534,54 +535,76 @@ class UsersController extends Controller
|
||||
// Open output stream
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
$headers = [
|
||||
// strtolower to prevent Excel from trying to open it as a SYLK file
|
||||
strtolower(trans('general.id')),
|
||||
trans('admin/companies/table.title'),
|
||||
trans('admin/users/table.title'),
|
||||
trans('general.employee_number'),
|
||||
trans('admin/users/table.first_name'),
|
||||
trans('admin/users/table.last_name'),
|
||||
trans('admin/users/table.name'),
|
||||
trans('admin/users/table.display_name'),
|
||||
trans('admin/users/table.username'),
|
||||
trans('admin/users/table.email'),
|
||||
trans('admin/users/table.phone'),
|
||||
trans('admin/users/table.mobile'),
|
||||
trans('general.website'),
|
||||
trans('general.address'),
|
||||
trans('general.city'),
|
||||
trans('general.state'),
|
||||
trans('general.country'),
|
||||
trans('general.zip'),
|
||||
trans('admin/users/table.manager'),
|
||||
trans('admin/users/table.location'),
|
||||
trans('general.department'),
|
||||
trans('general.assets'),
|
||||
trans('general.licenses'),
|
||||
trans('general.accessories'),
|
||||
trans('general.consumables'),
|
||||
trans('general.groups'),
|
||||
trans('general.permissions'),
|
||||
trans('general.notes'),
|
||||
trans('admin/users/table.activated'),
|
||||
trans('general.created_at'),
|
||||
trans('general.importer.vip'),
|
||||
trans('admin/users/general.remote'),
|
||||
trans('general.language'),
|
||||
trans('general.autoassign_licenses'),
|
||||
trans('general.ldap_sync'),
|
||||
trans('admin/users/general.two_factor_enrolled'),
|
||||
trans('admin/users/general.two_factor_active'),
|
||||
trans('admin/users/table.managed_users'),
|
||||
trans('admin/users/table.managed_locations'),
|
||||
trans('admin/users/general.department_manager'),
|
||||
trans('general.created_by'),
|
||||
trans('general.updated_at'),
|
||||
trans('general.start_date'),
|
||||
trans('general.end_date'),
|
||||
trans('admin/users/table.last_login'),
|
||||
trans('admin/licenses/table.deleted_at'),
|
||||
];
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$users = User::with(
|
||||
'assets',
|
||||
'accessories',
|
||||
'consumables',
|
||||
'department',
|
||||
'department.manager',
|
||||
'licenses',
|
||||
'manager',
|
||||
'groups',
|
||||
'userloc',
|
||||
'company'
|
||||
)->orderBy('created_at', 'DESC')
|
||||
'company',
|
||||
'createdBy'
|
||||
)->withCount(['managesUsers as manages_users_count', 'managedLocations as manages_locations_count'])
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($users) use ($handle) {
|
||||
$headers = [
|
||||
// strtolower to prevent Excel from trying to open it as a SYLK file
|
||||
strtolower(trans('general.id')),
|
||||
trans('admin/companies/table.title'),
|
||||
trans('admin/users/table.title'),
|
||||
trans('general.employee_number'),
|
||||
trans('admin/users/table.first_name'),
|
||||
trans('admin/users/table.last_name'),
|
||||
trans('admin/users/table.name'),
|
||||
trans('admin/users/table.username'),
|
||||
trans('admin/users/table.email'),
|
||||
trans('admin/users/table.manager'),
|
||||
trans('admin/users/table.location'),
|
||||
trans('general.department'),
|
||||
trans('general.assets'),
|
||||
trans('general.licenses'),
|
||||
trans('general.accessories'),
|
||||
trans('general.consumables'),
|
||||
trans('general.groups'),
|
||||
trans('general.permissions'),
|
||||
trans('general.notes'),
|
||||
trans('admin/users/table.activated'),
|
||||
trans('general.created_at'),
|
||||
];
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user_groups = '';
|
||||
|
||||
foreach ($user->groups as $user_group) {
|
||||
$user_groups .= $user_group->name.', ';
|
||||
}
|
||||
|
||||
$permissionstring = '';
|
||||
|
||||
if ($user->isSuperUser()) {
|
||||
@@ -600,9 +623,18 @@ class UsersController extends Controller
|
||||
$user->employee_num,
|
||||
$user->first_name,
|
||||
$user->last_name,
|
||||
$user->display_name,
|
||||
$user->getFullNameAttribute(),
|
||||
$user->getRawOriginal('display_name'),
|
||||
$user->username,
|
||||
$user->email,
|
||||
$user->phone,
|
||||
$user->mobile,
|
||||
$user->website,
|
||||
$user->address,
|
||||
$user->city,
|
||||
$user->state,
|
||||
$user->country,
|
||||
$user->zip,
|
||||
($user->manager) ? $user->manager->display_name : '',
|
||||
($user->userloc) ? $user->userloc->name : '',
|
||||
($user->department) ? $user->department->name : '',
|
||||
@@ -610,11 +642,27 @@ class UsersController extends Controller
|
||||
$user->licenses->count(),
|
||||
$user->accessories->count(),
|
||||
$user->consumables->count(),
|
||||
$user_groups,
|
||||
$user->groups->pluck('name')->implode(', '),
|
||||
$permissionstring,
|
||||
$user->notes,
|
||||
($user->activated == '1') ? trans('general.yes') : trans('general.no'),
|
||||
$user->created_at,
|
||||
($user->vip == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->remote == '1') ? trans('general.yes') : trans('general.no'),
|
||||
$user->locale,
|
||||
($user->autoassign_licenses == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->ldap_import == '1') ? trans('general.yes') : trans('general.no'),
|
||||
($user->two_factor_active_and_enrolled()) ? trans('general.yes') : trans('general.no'),
|
||||
($user->two_factor_active()) ? trans('general.yes') : trans('general.no'),
|
||||
$user->manages_users_count,
|
||||
$user->manages_locations_count,
|
||||
($user->department && $user->department->manager) ? $user->department->manager->display_name : '',
|
||||
($user->createdBy) ? $user->createdBy->display_name : '',
|
||||
$user->updated_at,
|
||||
$user->start_date,
|
||||
$user->end_date,
|
||||
$user->last_login,
|
||||
$user->deleted_at,
|
||||
];
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Requests\Traits\MayContainCustomFields;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
@@ -26,6 +27,10 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
|
||||
{
|
||||
parent::prepareForValidation();
|
||||
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
|
||||
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
|
||||
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
@@ -21,6 +22,10 @@ class StoreAccessoryRequest extends ImageUploadRequest
|
||||
{
|
||||
parent::prepareForValidation();
|
||||
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
|
||||
if ($this->category_id) {
|
||||
if ($category = Category::find($this->category_id)) {
|
||||
$this->merge([
|
||||
@@ -28,7 +33,6 @@ class StoreAccessoryRequest extends ImageUploadRequest
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Requests\Traits\MayContainCustomFields;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\Setting;
|
||||
use App\Rules\AssetCannotBeCheckedOutToNondeployableStatus;
|
||||
use Carbon\Carbon;
|
||||
use Carbon\Exceptions\InvalidFormatException;
|
||||
@@ -39,6 +39,9 @@ class StoreAssetRequest extends ImageUploadRequest
|
||||
$this->merge([
|
||||
'asset_tag' => $this->asset_tag ?? Asset::autoincrement_asset(),
|
||||
'company_id' => $idForCurrentUser,
|
||||
'purchase_cost' => $this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))
|
||||
? Helper::ParseCurrency($this->input('purchase_cost'))
|
||||
: $this->input('purchase_cost'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -49,15 +52,6 @@ class StoreAssetRequest extends ImageUploadRequest
|
||||
{
|
||||
$modelRules = (new Asset)->getRules();
|
||||
|
||||
if (Setting::getSettings()->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
|
||||
// If purchase_cost was submitted as a string with a comma separator
|
||||
// then we need to ignore the normal numeric rules.
|
||||
// Since the original rules still live on the model they will be run
|
||||
// right before saving (and after purchase_cost has been
|
||||
// converted to a float via setPurchaseCostAttribute).
|
||||
$modelRules = $this->removeNumericRulesFromPurchaseCost($modelRules);
|
||||
}
|
||||
|
||||
return array_merge(
|
||||
$modelRules,
|
||||
['status_id' => [new AssetCannotBeCheckedOutToNondeployableStatus]],
|
||||
@@ -81,20 +75,4 @@ class StoreAssetRequest extends ImageUploadRequest
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function removeNumericRulesFromPurchaseCost(array $rules): array
|
||||
{
|
||||
$purchaseCost = $rules['purchase_cost'];
|
||||
|
||||
// If rule is in "|" format then turn it into an array
|
||||
if (is_string($purchaseCost)) {
|
||||
$purchaseCost = explode('|', $purchaseCost);
|
||||
}
|
||||
|
||||
$rules['purchase_cost'] = array_filter($purchaseCost, function ($rule) {
|
||||
return $rule !== 'numeric' && $rule !== 'gte:0';
|
||||
});
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Component;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class StoreComponentRequest extends ImageUploadRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return Gate::allows('create', Component::class);
|
||||
}
|
||||
|
||||
public function prepareForValidation(): void
|
||||
{
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
}
|
||||
|
||||
public function response(array $errors)
|
||||
{
|
||||
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Category;
|
||||
use App\Models\Consumable;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
@@ -21,6 +22,10 @@ class StoreConsumableRequest extends ImageUploadRequest
|
||||
{
|
||||
parent::prepareForValidation();
|
||||
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
|
||||
if ($this->category_id) {
|
||||
if ($category = Category::find($this->category_id)) {
|
||||
$this->merge([
|
||||
@@ -28,7 +33,6 @@ class StoreConsumableRequest extends ImageUploadRequest
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Requests\Traits\MayContainCustomFields;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
@@ -22,6 +23,13 @@ class UpdateAssetRequest extends ImageUploadRequest
|
||||
return Gate::allows('update', $this->asset);
|
||||
}
|
||||
|
||||
public function prepareForValidation(): void
|
||||
{
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
@@ -51,12 +59,6 @@ class UpdateAssetRequest extends ImageUploadRequest
|
||||
],
|
||||
);
|
||||
|
||||
// if the purchase cost is passed in as a string **and** the digit_separator is ',' (as is common in the EU)
|
||||
// then we tweak the purchase_cost rule to make it a string
|
||||
if ($setting->digit_separator === '1.234,56' && is_string($this->input('purchase_cost'))) {
|
||||
$rules['purchase_cost'] = ['nullable', 'string'];
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class UpdateComponentRequest extends ImageUploadRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return Gate::allows('update', $this->component);
|
||||
}
|
||||
|
||||
public function prepareForValidation(): void
|
||||
{
|
||||
if ($this->filled('purchase_cost') && ! is_float($this->input('purchase_cost')) && preg_match('/^[\d.,]+$/', (string) $this->input('purchase_cost'))) {
|
||||
$this->merge(['purchase_cost' => Helper::ParseCurrency($this->input('purchase_cost'))]);
|
||||
}
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$min = $this->component->numCheckedOut();
|
||||
|
||||
return array_merge(parent::rules(), [
|
||||
'qty' => "required|numeric|min:{$min}",
|
||||
]);
|
||||
}
|
||||
|
||||
public function response(array $errors)
|
||||
{
|
||||
return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
namespace App\Http\Traits;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Location;
|
||||
use App\Models\SnipeModel;
|
||||
use App\Models\User;
|
||||
|
||||
trait CheckInOutRequest
|
||||
trait CheckInOutTrait
|
||||
{
|
||||
/**
|
||||
* Find target for checkout
|
||||
@@ -9,8 +9,24 @@ class DatatablesTransformer
|
||||
**/
|
||||
public function transformDatatables($objects, $total = null)
|
||||
{
|
||||
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
|
||||
$objects_array['rows'] = $objects;
|
||||
$objects_array = [
|
||||
'total' => $total ?? count($objects),
|
||||
'rows' => $objects,
|
||||
];
|
||||
$current_page = app('api_current_page');
|
||||
$limit = (int) app('api_limit_value');
|
||||
$total_pages = $limit > 0 ? (int) ceil($objects_array['total'] / $limit) : 1;
|
||||
|
||||
$objects_array['current_page'] = $current_page;
|
||||
$objects_array['per_page'] = $limit;
|
||||
$objects_array['total_pages'] = $total_pages;
|
||||
|
||||
$objects_array['prev_page_url'] = $current_page > 1
|
||||
? request()->fullUrlWithQuery(['page' => $current_page - 1])
|
||||
: null;
|
||||
$objects_array['next_page_url'] = $current_page < $total_pages
|
||||
? request()->fullUrlWithQuery(['page' => $current_page + 1])
|
||||
: null;
|
||||
|
||||
return $objects_array;
|
||||
}
|
||||
@@ -20,8 +36,10 @@ class DatatablesTransformer
|
||||
**/
|
||||
public function transformBulkResponseWithStatusAndObjects($objects, $total)
|
||||
{
|
||||
(isset($total)) ? $objects_array['total'] = $total : $objects_array['total'] = count($objects);
|
||||
$objects_array['rows'] = $objects;
|
||||
$objects_array = [
|
||||
'total' => $total ?? count($objects),
|
||||
'rows' => $objects,
|
||||
];
|
||||
|
||||
return $objects_array;
|
||||
}
|
||||
|
||||
@@ -37,7 +37,8 @@ class AccessoryImporter extends ItemImporter
|
||||
$this->log('Updating Accessory');
|
||||
$this->item['model_number'] = trim($this->findCsvMatch($row, 'model_number'));
|
||||
$accessory->update($this->sanitizeItemForUpdating($accessory));
|
||||
$accessory->save();
|
||||
// update() already saves the model, no need to call save() again while Model::unguard() is active
|
||||
$accessory->setImported(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -176,35 +176,55 @@ class AssetImporter extends ItemImporter
|
||||
|
||||
if ($editingAsset) {
|
||||
$asset->update($item);
|
||||
$asset->setImported(true);
|
||||
} else {
|
||||
$asset->fill($item);
|
||||
$asset->setImported(true);
|
||||
}
|
||||
|
||||
// If we're updating, we don't want to overwrite old fields.
|
||||
// Apply custom fields to asset attributes if they exist
|
||||
$customFieldsToSave = [];
|
||||
if (array_key_exists('custom_fields', $this->item)) {
|
||||
foreach ($this->item['custom_fields'] as $custom_field => $val) {
|
||||
$asset->{$custom_field} = $val;
|
||||
$customFieldsToSave[$custom_field] = $val;
|
||||
}
|
||||
}
|
||||
|
||||
// This sets an attribute on the Loggable trait for the action log
|
||||
$asset->setImported(true);
|
||||
// For existing assets that have custom fields, update them.
|
||||
// This avoids the issue of calling save() twice with Model::unguard() active.
|
||||
if ($editingAsset && ! empty($customFieldsToSave)) {
|
||||
$asset->update($customFieldsToSave);
|
||||
$success = true;
|
||||
} elseif (! $editingAsset) {
|
||||
// For new assets, save with all changes (custom fields included via direct attribute assignment above)
|
||||
$success = $asset->save();
|
||||
} else {
|
||||
// For existing assets without custom fields, update() already saved everything
|
||||
$success = true;
|
||||
}
|
||||
|
||||
if ($asset->save()) {
|
||||
if ($success) {
|
||||
|
||||
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
|
||||
$this->log('Asset '.$this->item['name'].' with serial number '.$this->item['serial'].' created or updated');
|
||||
|
||||
// If we have a target to checkout to, lets do so.
|
||||
// -- created_by is a property of the abstract class Importer, which this class inherits from and it's set by
|
||||
// -- the class that needs to use it (command importer or GUI importer inside the project).
|
||||
if (isset($target) && ($target !== false)) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
if ($asset->assigned_to != $target->id) {
|
||||
$asset = $asset->fresh();
|
||||
$targetType = get_class($target);
|
||||
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
|
||||
|
||||
// Skip duplicate checkout noise when update mode keeps the same assignment target.
|
||||
if (! $alreadyCheckedOutToTarget) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
||||
}
|
||||
}
|
||||
|
||||
$asset->fresh()->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -42,7 +42,8 @@ class ComponentImporter extends ItemImporter
|
||||
}
|
||||
$this->log('Updating Component');
|
||||
$component->update($this->sanitizeItemForUpdating($component));
|
||||
$component->save();
|
||||
// update() already saves the model, no need to call save() again while Model::unguard() is active
|
||||
$component->setImported(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,8 @@ class ConsumableImporter extends ItemImporter
|
||||
}
|
||||
$this->log('Updating Consumable');
|
||||
$consumable->update($this->sanitizeItemForUpdating($consumable));
|
||||
$consumable->save();
|
||||
// update() already saves the model, no need to call save() again while Model::unguard() is active
|
||||
$consumable->setImported(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -88,8 +88,12 @@ class LicenseImporter extends ItemImporter
|
||||
|
||||
// This sets an attribute on the Loggable trait for the action log
|
||||
$license->setImported(true);
|
||||
if ($license->save()) {
|
||||
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created');
|
||||
|
||||
// For new licenses we need to save, for existing ones update() already saved
|
||||
$licenseWasSaved = $editingLicense || $license->save();
|
||||
|
||||
if ($licenseWasSaved) {
|
||||
$this->log('License '.$this->item['name'].' with serial number '.$this->item['serial'].' was created or updated');
|
||||
|
||||
// Lets try to checkout seats if the fields exist and we have seats.
|
||||
if ($license->seats > 0) {
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Helper;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
|
||||
@@ -15,9 +11,10 @@ use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
|
||||
|
||||
function a($name = null): Attribute
|
||||
{
|
||||
@@ -36,11 +33,10 @@ function eloquent($name, $attribute = null): Attribute
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
{
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class MappedTable extends Attribute
|
||||
@@ -70,52 +66,48 @@ class MappedTable extends Attribute
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UpdatableComplex extends Complex
|
||||
{
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey());
|
||||
throw new \Exception("doWrite is not implemented yet for Operation: $operation ".($subop ? "($subop)" : '').'on attribute '.$this->getFullKey());
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$this->doWrite("add", null, $value, $object);
|
||||
$this->doWrite('add', null, $value, $object);
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
|
||||
$this->doWrite('replace', null, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
|
||||
$this->doWrite('patch', $operation, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$this->doWrite("remove", null, null, $object, $path);
|
||||
$this->doWrite('remove', null, null, $object, $path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SnipeSCIMConfig
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
public function __construct() {}
|
||||
|
||||
public function getConfigForResource($name)
|
||||
{
|
||||
$result = $this->getConfig();
|
||||
|
||||
return @$result[$name];
|
||||
}
|
||||
|
||||
@@ -125,6 +117,7 @@ class SnipeSCIMConfig
|
||||
}
|
||||
|
||||
const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
|
||||
|
||||
const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User';
|
||||
|
||||
public function getUserConfig()
|
||||
@@ -140,22 +133,19 @@ class SnipeSCIMConfig
|
||||
'description' => 'User Account',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
new class ('schemas', [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:User",
|
||||
self::ENTERPRISE,
|
||||
self::GROKABILITY
|
||||
]) extends Constant {
|
||||
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:User', self::ENTERPRISE, self::GROKABILITY]) extends Constant
|
||||
{
|
||||
public function replace($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant { // TODO - this 'id' is in the same namespace for objects OR groups?
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
}
|
||||
(new class('id', null) extends Constant // TODO - this 'id' is in the same namespace for objects OR groups?
|
||||
{protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string) $object->id;
|
||||
}
|
||||
|
||||
public function remove($value, &$object, $path = null)
|
||||
{
|
||||
@@ -166,90 +156,97 @@ class SnipeSCIMConfig
|
||||
new Meta('Users'),
|
||||
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
|
||||
eloquent('userName', 'username')->ensure('required'),
|
||||
(new class ('active', 'activated') extends Eloquent {
|
||||
(new class('active', 'activated') extends Eloquent
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (bool)$object->activated; // need this extension to force boolean-ness
|
||||
return (bool) $object->activated; // need this extension to force boolean-ness
|
||||
}
|
||||
}),
|
||||
complex('name')->withSubAttributes(
|
||||
eloquent('givenName', 'first_name')->ensure('required'),
|
||||
eloquent('familyName', 'last_name'),
|
||||
), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not?
|
||||
eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec
|
||||
//eloquent('password')->ensure('nullable')->setReturned('never'),
|
||||
eloquent('displayName', 'display_name'), // yes, this is *not* under 'name' - that's the spec
|
||||
// eloquent('password')->ensure('nullable')->setReturned('never'),
|
||||
eloquent('externalId', 'scim_externalid'),
|
||||
|
||||
// Email chonk
|
||||
(new class ('emails') extends UpdatableComplex {
|
||||
(new class('emails') extends UpdatableComplex
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return collect([$object->email])->map(function ($email) {
|
||||
return [
|
||||
'value' => $email,
|
||||
'type' => 'work', //TODO - is this how we always have done it?
|
||||
'primary' => true
|
||||
'type' => 'work', // TODO - is this how we always have done it?
|
||||
'primary' => true,
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
|
||||
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
|
||||
}
|
||||
} else {
|
||||
$object->email = null;
|
||||
}
|
||||
}
|
||||
})->withSubAttributes(
|
||||
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
|
||||
eloquent('value', 'email')->ensure('email', 'nullable'), // Weird, this 'needs' nullable to work?
|
||||
new Constant('type', 'work'),
|
||||
(new Constant('primary', true))->ensure('boolean')
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
// phone chonk
|
||||
(new class ('phoneNumbers') extends UpdatableComplex {
|
||||
(new class('phoneNumbers') extends UpdatableComplex
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
$phones = [];
|
||||
if ($object->phone) {
|
||||
$phones[] = [
|
||||
'value' => $object->phone,
|
||||
'type' => 'work'
|
||||
'type' => 'work',
|
||||
];
|
||||
|
||||
}
|
||||
if ($object->mobile) {
|
||||
$phones[] = [
|
||||
'value' => $object->mobile,
|
||||
'type' => 'mobile'
|
||||
'type' => 'mobile',
|
||||
];
|
||||
}
|
||||
|
||||
return $phones;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("Phones 'value' is: " . print_r($value, true));
|
||||
\Log::debug("Phones 'value' is: ".print_r($value, true));
|
||||
try {
|
||||
if ($operation == "patch") {
|
||||
if ($operation == 'patch') {
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
if ((string) $path == 'phoneNumbers[type eq "mobile"].value') {
|
||||
$object->mobile = $value; //I don't know why the value is the raw value, but it is?
|
||||
$object->mobile = $value; // I don't know why the value is the raw value, but it is?
|
||||
|
||||
return;
|
||||
}
|
||||
if ((string) $path == 'phoneNumbers[type eq "work"].value') {
|
||||
$object->phone = $value; //similar, don't know why, but it is
|
||||
$object->phone = $value; // similar, don't know why, but it is
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
|
||||
|
||||
return;
|
||||
}
|
||||
foreach ($value as $phone) {
|
||||
@@ -263,15 +260,14 @@ class SnipeSCIMConfig
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
|
||||
throw new SCIMException("Unknown phone type '".@$phone['type']."'", 400);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
|
||||
throw new SCIMException("Unknown phone object(s) '".print_r($value, true)."'", 422);
|
||||
}
|
||||
}
|
||||
|
||||
})->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all
|
||||
(new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored
|
||||
new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored
|
||||
@@ -279,13 +275,14 @@ class SnipeSCIMConfig
|
||||
->setMultiValued(true),
|
||||
|
||||
// addresses chonk
|
||||
(new class ('addresses') extends UpdatableComplex {
|
||||
static $addressmap = [
|
||||
(new class('addresses') extends UpdatableComplex
|
||||
{
|
||||
public static $addressmap = [
|
||||
'streetAddress' => 'address',
|
||||
'locality' => 'city',
|
||||
'region' => 'state',
|
||||
'postalCode' => 'zip',
|
||||
'country' => 'country'
|
||||
'country' => 'country',
|
||||
];
|
||||
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
@@ -300,10 +297,11 @@ class SnipeSCIMConfig
|
||||
$address['type'] = 'work';
|
||||
$address['primary'] = true;
|
||||
}
|
||||
|
||||
return $address;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
// TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
@@ -311,7 +309,7 @@ class SnipeSCIMConfig
|
||||
// get the part of the $path that we actually care about - something like:
|
||||
// addresses[type eq "work"]
|
||||
$matches = null;
|
||||
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
|
||||
if (! preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string) $path, $matches)) {
|
||||
throw new SCIMException("Unknown path type '$path'", 422);
|
||||
}
|
||||
$type = $matches[1];
|
||||
@@ -321,14 +319,13 @@ class SnipeSCIMConfig
|
||||
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
|
||||
if (array_key_exists($attribute, self::$addressmap)) {
|
||||
$object->{self::$addressmap[$attribute]} = $value;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new SCIMException("Could not handle path for update $path", 422);
|
||||
}
|
||||
}
|
||||
|
||||
})->withSubAttributes(
|
||||
eloquent('streetAddress', 'address'),
|
||||
eloquent('locality', 'city'),
|
||||
@@ -344,14 +341,15 @@ class SnipeSCIMConfig
|
||||
eloquent('preferredLanguage', 'locale'),
|
||||
(new Collection('groups'))->withSubAttributes(
|
||||
eloquent('value', 'id'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
(new class('$ref') extends Eloquent
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Group',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
'resourceObject' => $object->id ?? 'not-saved',
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -368,14 +366,16 @@ class SnipeSCIMConfig
|
||||
(new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes(
|
||||
eloquent('employeeNumber', 'employee_num')->ensure('nullable'),
|
||||
new MappedTable('department', 'department', Department::class, 'department_id', 'name'),
|
||||
(new class('manager') extends UpdatableComplex {
|
||||
(new class('manager') extends UpdatableComplex
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
if (!$object->manager) {
|
||||
if (! $object->manager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
|
||||
'value' => $object->manager->id, // TODO - ID's aren't unique like they're supposed to be :/
|
||||
'$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]),
|
||||
'displayName' => $object->manager->display_name,
|
||||
];
|
||||
@@ -383,10 +383,10 @@ class SnipeSCIMConfig
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("What type of value is value? " . gettype($value));
|
||||
\Log::debug('What type of value is value? '.gettype($value));
|
||||
$manager_id = null;
|
||||
if (is_scalar($value)) {
|
||||
\Log::debug("Weird Microsoft mode - set manager to the \$value and move on with life?");
|
||||
\Log::debug('Weird Microsoft mode - set manager to the $value and move on with life?');
|
||||
$manager_id = $value;
|
||||
} elseif (array_key_exists('$ref', $value)) {
|
||||
// Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3
|
||||
@@ -396,8 +396,8 @@ class SnipeSCIMConfig
|
||||
|
||||
// extract ID from URL, jam it in?
|
||||
$url = $value['$ref'];
|
||||
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
|
||||
if (string_starts_with($url, $users_prefix)) {
|
||||
$users_prefix = route('scim.resources', ['resourceType' => 'User']).'/';
|
||||
if (str_starts_with($url, $users_prefix)) {
|
||||
$manager_id = substr($url, strlen($users_prefix));
|
||||
}
|
||||
} elseif (array_key_exists('value', $value)) {
|
||||
@@ -407,9 +407,10 @@ class SnipeSCIMConfig
|
||||
// that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one!
|
||||
$manager_id = $value['value'];
|
||||
}
|
||||
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true));
|
||||
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: ".print_r($value, true));
|
||||
if ($manager_id && User::find($manager_id)) {
|
||||
$object->manager_id = $manager_id;
|
||||
|
||||
return;
|
||||
}
|
||||
throw new SCIMException("No manager given, or manager doesn't exist", 400);
|
||||
@@ -431,24 +432,24 @@ class SnipeSCIMConfig
|
||||
'class' => $this->getGroupClass(),
|
||||
'singular' => 'Group',
|
||||
|
||||
//eager loading
|
||||
// eager loading
|
||||
'withRelations' => [],
|
||||
'description' => 'Group',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
new class ('schemas', [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
]) extends Constant {
|
||||
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:Group']) extends Constant
|
||||
{
|
||||
public function replace($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant {
|
||||
(new class('id', null) extends Constant
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
return (string) $object->id;
|
||||
}
|
||||
|
||||
public function remove($value, &$object, $path = null)
|
||||
@@ -469,14 +470,15 @@ class SnipeSCIMConfig
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
(new class('$ref') extends Eloquent
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Users',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
'resourceObject' => $object->id ?? 'not-saved',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,11 +44,29 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
return $limit;
|
||||
});
|
||||
|
||||
// Make sure the offset is actually set and is an integer
|
||||
// Make sure the offset is actually set and is an integer.
|
||||
// If 'page' is passed without 'offset', derive the offset from the page number.
|
||||
app()->singleton('api_offset_value', function () {
|
||||
$offset = intval(request('offset'));
|
||||
if (request()->filled('page') && ! request()->filled('offset')) {
|
||||
$page = max(1, intval(request('page')));
|
||||
|
||||
return $offset;
|
||||
return ($page - 1) * (int) app('api_limit_value');
|
||||
}
|
||||
|
||||
return intval(request('offset'));
|
||||
});
|
||||
|
||||
// Resolve the current page number for inclusion in API list responses.
|
||||
// Supports both page= and legacy offset= parameters.
|
||||
app()->singleton('api_current_page', function () {
|
||||
if (request()->filled('page') && ! request()->filled('offset')) {
|
||||
return max(1, intval(request('page')));
|
||||
}
|
||||
|
||||
$limit = (int) app('api_limit_value');
|
||||
$offset = (int) app('api_offset_value');
|
||||
|
||||
return $limit > 0 ? (int) floor($offset / $limit) + 1 : 1;
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Generated
+4
-4
@@ -78,12 +78,12 @@
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/grokability/laravel-scim-server.git",
|
||||
"reference": "dfef93c7344be3c3c255d54694e182c5ec784bab"
|
||||
"reference": "5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/dfef93c7344be3c3c255d54694e182c5ec784bab",
|
||||
"reference": "dfef93c7344be3c3c255d54694e182c5ec784bab",
|
||||
"url": "https://api.github.com/repos/grokability/laravel-scim-server/zipball/5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148",
|
||||
"reference": "5ddb8188dd50e2bdb6f8133b9b7f0a0b54b83148",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -133,7 +133,7 @@
|
||||
"support": {
|
||||
"source": "https://github.com/grokability/laravel-scim-server/tree/scimv2_with_logging"
|
||||
},
|
||||
"time": "2026-05-12T11:17:15+00:00"
|
||||
"time": "2026-05-14T09:39:34+00:00"
|
||||
},
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
|
||||
@@ -59,7 +59,7 @@ class AssetFactory extends Factory
|
||||
// the explicit boolean gets set in the saving() method on the observer
|
||||
$asset->asset_eol_date = $this->faker->boolean(5)
|
||||
? CarbonImmutable::parse($asset->purchase_date)->addMonths(rand(0, 20))->format('Y-m-d')
|
||||
: CarbonImmutable::parse($asset->purchase_date)->addMonths($asset->model->eol)->format('Y-m-d');
|
||||
: CarbonImmutable::parse($asset->purchase_date)->addMonths($asset->model?->eol ?? rand(12, 60))->format('Y-m-d');
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -37,8 +37,9 @@ return [
|
||||
'user_activated' => 'User can login',
|
||||
'activation_status_warning' => 'Do not change activation status',
|
||||
'group_memberships_helpblock' => 'Only superadmins may edit group memberships.',
|
||||
'superadmin_permission_warning' => 'Only superadmins may grant a user superadmin access.',
|
||||
'admin_permission_warning' => 'Only users with admins rights or greater may grant a user admin access.',
|
||||
'superadmin_permission_warning' => 'Only superadmins may grant or revoke superadmin access.',
|
||||
'self_permission_warning' => 'Only superadmins may edit their own permissions.',
|
||||
'admin_permission_warning' => 'Only users with admins rights or greater may grant or revoke admin access.',
|
||||
'remove_group_memberships' => 'Remove Group Memberships',
|
||||
'warning_deletion_information' => 'You are about to checkin ALL items from the :count user(s) listed below.',
|
||||
'update_user_assets_status' => 'Update all assets for these users to this status',
|
||||
|
||||
@@ -255,6 +255,8 @@ return [
|
||||
'processing' => 'Processing',
|
||||
'profile' => 'View Profile',
|
||||
'purchase_cost' => 'Purchase Cost',
|
||||
'purchase_cost_format_help' => 'This should be entered in your system-configured number format, e.g. :format',
|
||||
'purchase_cost_invalid' => 'Please enter a valid number (digits, dots, and commas only)',
|
||||
'purchase_date' => 'Purchase Date',
|
||||
'qty' => 'QTY',
|
||||
'quantity' => 'Quantity',
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
</label>
|
||||
<div class="input-group col-md-3">
|
||||
<span class="input-group-addon">{{ $snipeSettings->default_currency }}</span>
|
||||
<input type="text" class="form-control" maxlength="10" placeholder="{{ trans('admin/hardware/form.cost') }}" name="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost') }}">
|
||||
<input type="text" class="form-control" pattern="^\d+([.,]\d+)?$" maxlength="10" placeholder="{{ trans('admin/hardware/form.cost') }}" name="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost') }}">
|
||||
{!! $errors->first('purchase_cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2344,6 +2344,16 @@
|
||||
email: "{{ trans('validation.generic.email') }}"
|
||||
});
|
||||
|
||||
$.validator.addMethod('pattern', function(value, element, param) {
|
||||
if (this.optional(element)) {
|
||||
return true;
|
||||
}
|
||||
if (typeof param === 'string') {
|
||||
param = new RegExp('^(?:' + param + ')$');
|
||||
}
|
||||
return param.test(value);
|
||||
}, '{{ trans('validation.generic.invalid_value_in_field') }}');
|
||||
|
||||
|
||||
function showHideEncValue(e) {
|
||||
// Use element id to find the text element to hide / show
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
{{ $snipeSettings->default_currency }}
|
||||
@endif
|
||||
</span>
|
||||
<input class="form-control" type="number" name="cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="cost" id="cost" value="{{ old('cost', $item->cost) }}" maxlength="25" />
|
||||
<input class="form-control" type="text" inputmode="decimal" pattern="[\d.,]+" name="cost" aria-label="cost" id="cost" value="{{ old('cost', \App\Helpers\Helper::formatCurrencyOutput($item->cost)) }}" maxlength="25" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
|
||||
{!! $errors->first('cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
|
||||
<p class="help-block">{{ trans('general.purchase_cost_format_help', ['format' => $snipeSettings->digit_separator]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,13 +11,21 @@
|
||||
$section_name = str_slug($main_section);
|
||||
}
|
||||
@endphp
|
||||
|
||||
@if (str_slug($main_section) == 'superuser' && !auth()->user()->isSuperUser())
|
||||
@continue
|
||||
@endif
|
||||
@if (str_slug($main_section) == 'admin' && !auth()->user()->hasAccess('admin'))
|
||||
@continue
|
||||
@endif
|
||||
|
||||
<div class="form-group {{ ($sectionPermission['permission']!='superuser') ? ' nonsuperuser' : '' }}{{ ( ($sectionPermission['permission']!='superuser') && ($sectionPermission['permission']!='admin')) ? ' nonadmin' : '' }}">
|
||||
|
||||
<!-- start callout legend for major sections -->
|
||||
<div class="callout callout-legend col-md-12">
|
||||
|
||||
<!-- start left column with area name and note -->
|
||||
<div class="col-md-10">
|
||||
<div class="col-md-9">
|
||||
|
||||
<h4 id="{{ str_slug($sectionPermission['permission'])}}" class="{{ (count($main_section_permission) > 1) ? 'remember-toggle': '' }}">
|
||||
@if (count($main_section_permission) > 1)
|
||||
@@ -33,7 +41,7 @@
|
||||
<!-- end left column with area name and note -->
|
||||
|
||||
<!-- Handle the checkall ALLOW and DENY radios in the right column -->
|
||||
<div class="col-md-2 text-right header-row">
|
||||
<div class="col-md-3 text-right header-row">
|
||||
<div class="radio-toggle-wrapper">
|
||||
|
||||
<!-- start .radio-slider-inputs allow -->
|
||||
@@ -46,10 +54,6 @@
|
||||
@checked(array_key_exists($section_name, $groupPermissions) && $groupPermissions[$section_name] == '1')
|
||||
type="radio"
|
||||
value="1"
|
||||
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
|
||||
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
|
||||
disabled
|
||||
@endif
|
||||
id="{{ str_slug($main_section) }}_allow"
|
||||
>
|
||||
|
||||
@@ -70,10 +74,6 @@
|
||||
@checked((array_key_exists(str_slug($main_section), $groupPermissions) && $groupPermissions[str_slug($main_section)] == '0') || (!array_key_exists(str_slug($main_section), $groupPermissions)))
|
||||
type="radio"
|
||||
value="0"
|
||||
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
|
||||
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
|
||||
disabled
|
||||
@endif
|
||||
id="{{ str_slug($main_section) }}_inherit"
|
||||
>
|
||||
|
||||
@@ -94,10 +94,6 @@
|
||||
@checked(array_key_exists(str_slug($main_section), $groupPermissions) && $groupPermissions[str_slug($main_section)] == '-1')
|
||||
type="radio"
|
||||
value="-1"
|
||||
{{-- Disable the superuser and admin allow if the user is not a superuser --}}
|
||||
@if (((str_slug($main_section) == 'admin') && (!auth()->user()->hasAccess('admin'))) || ((str_slug($main_section) == 'superuser') && (!auth()->user()->isSuperUser())))
|
||||
disabled
|
||||
@endif
|
||||
id="{{ str_slug($main_section) }}_deny"
|
||||
>
|
||||
|
||||
@@ -128,14 +124,14 @@
|
||||
@endphp
|
||||
|
||||
<div class="form-group" style="border-bottom: 1px solid #eee; padding-right: 13px;">
|
||||
<div class="col-md-10">
|
||||
<div class="col-md-9">
|
||||
<strong>{{ $section_translation }}</strong>
|
||||
@if (\Lang::has('permissions.'.str_slug($this_permission['permission']).'.note'))
|
||||
<p>{{ trans('permissions.'.str_slug($this_permission['permission']).'.note') }}</p>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="form-group col-md-2 text-right">
|
||||
<div class="form-group col-md-3 text-right">
|
||||
<div class="radio-toggle-wrapper">
|
||||
|
||||
<div class="radio-slider-inputs" data-tooltip="true" title="{{ trans('permissions.grant', ['area' => $section_translation]) }}">
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<label for="purchase_cost" class="col-md-3 control-label">{{ $unit_cost ?? trans('general.purchase_cost') }}</label>
|
||||
<div class="col-md-9">
|
||||
<div class="input-group col-md-5" style="padding-left: 0px;">
|
||||
<input class="form-control" type="number" name="purchase_cost" min="0.00" max="99999999999999999.000" step="0.001" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', $item->purchase_cost) }}" maxlength="25" />
|
||||
<input class="form-control" type="text" name="purchase_cost" pattern="[\d.,]+" aria-label="purchase_cost" id="purchase_cost" value="{{ old('purchase_cost', \App\Helpers\Helper::formatCurrencyOutput($item->purchase_cost)) }}" maxlength="25" inputmode="decimal" data-msg-pattern="{{ trans('general.purchase_cost_invalid') }}"/>
|
||||
<span class="input-group-addon">
|
||||
@if (isset($currency_type))
|
||||
{{ $currency_type }}
|
||||
@@ -14,6 +14,7 @@
|
||||
</div>
|
||||
<div class="col-md-9" style="padding-left: 0px;">
|
||||
{!! $errors->first('purchase_cost', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
|
||||
<p class="help-block">{{ trans('general.purchase_cost_format_help', ['format' => $snipeSettings->digit_separator]) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
<div class="row" style="padding-bottom: 10px;">
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
<span href="{{ route('reports.activity') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
|
||||
<a href="{{ route('reports.activity') }}" class="btn btn-theme btn-block" style="margin-bottom: 10px; white-space: normal;">
|
||||
<x-icon type="reports"/> {{ trans('general.activity_report') }}
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="col-md-3 col-sm-6">
|
||||
|
||||
@@ -52,10 +52,14 @@
|
||||
<!-- Custom Tabs -->
|
||||
<div class="nav-tabs-custom">
|
||||
<ul class="nav nav-tabs">
|
||||
<li class="active"><a href="#info" data-toggle="tab">{{ trans('general.information') }} </a></li>
|
||||
@can('admin')
|
||||
<li><a href="#permissions" data-toggle="tab">{{ trans('general.permissions') }} </a></li>
|
||||
@endcan
|
||||
<li class="active">
|
||||
<a href="#info" data-toggle="tab">{{ trans('general.information') }} </a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="#permissions" data-toggle="tab">{{ trans('general.permissions') }} </a>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
@@ -641,28 +645,35 @@
|
||||
</div>
|
||||
</div><!-- /.tab-pane -->
|
||||
|
||||
@can('admin')
|
||||
|
||||
<div class="tab-pane" id="permissions">
|
||||
@if (!Auth::user()->isSuperUser())
|
||||
<p class="alert alert-warning">{{ trans('admin/users/general.superadmin_permission_warning') }}</p>
|
||||
@endif
|
||||
|
||||
@if (!Auth::user()->hasAccess('admin'))
|
||||
<p class="alert alert-warning">{{ trans('admin/users/general.admin_permission_warning') }}</p>
|
||||
@endif
|
||||
<x-form.legend help_text="{{ trans('permissions.use_groups') }}"/>
|
||||
|
||||
@if (auth()->user()->isAdmin() && !auth()->user()->isSuperUser())
|
||||
<p class="alert alert-info">
|
||||
{{ trans('permissions.use_groups') }}
|
||||
<x-icon type="info"/>
|
||||
{{ trans('admin/users/general.superadmin_permission_warning') }}
|
||||
</p>
|
||||
@elseif (!auth()->user()->isAdmin() && !auth()->user()->isSuperUser() && auth()->id() === $user->id)
|
||||
<p class="alert alert-danger">
|
||||
<x-icon type="alert"/>
|
||||
{{ trans('admin/users/general.self_permission_warning') }}
|
||||
</p>
|
||||
@elseif (!auth()->user()->isAdmin() && !auth()->user()->isSuperUser() && auth()->id() !== $user->id)
|
||||
<p class="alert alert-danger">
|
||||
<x-icon type="warning"/>
|
||||
{{ trans('admin/users/general.admin_permission_warning') }}
|
||||
</p>
|
||||
@endif
|
||||
|
||||
@if (auth()->user()->isSuperUser() || auth()->user()->isAdmin() || (auth()->id() !== $user->id && !$user->isSuperUser()))
|
||||
<div class="col-md-12">
|
||||
@include('partials.forms.edit.permissions-base', ['use_inherit' => true, 'groupPermissions' => $userPermissions])
|
||||
@include('partials.forms.edit.permissions-base', ['use_inherit' => true, 'groupPermissions' => $userPermissions])
|
||||
</div>
|
||||
|
||||
|
||||
@endif
|
||||
|
||||
</div><!-- /.tab-pane -->
|
||||
@endcan
|
||||
</div><!-- /.tab-content -->
|
||||
<x-redirect_submit_options
|
||||
index_route="users.index"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
Company,Name,Asset Tag,Category,Supplier,Manufacturer,Location,Order Number,Model,Model Notes,Model Number,Asset Notes,Purchase Date,Purchase Cost,Checkout Type,Checked Out To: Username,Checked Out To: First Name,Checked Out To: Last Name,Checked Out To: Email,Checked Out To: Location,Asset EOL Date
|
||||
Abshire and Sons,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28
|
||||
Abshire and Sons and Sons and Sons and Sons!,Backhoe,ICC-2065556,Ornamental Railings,"Kunde, Doyle and Kozey",Berge Inc,"Wilkinson, Waters and Kerluke",3271901481,"Macbook Pro 13""",,1786VM80X07,at nulla suspendisse potenti cras in purus eu magna vulputate luctus cum sociis natoque penatibus et magnis dis,1/23/23,2266.13,,,,,,,10/27/28
|
||||
"Quitzon, Oberbrunner and Dibbert",Dragline,WBH-2841795,Structural and Misc Steel (Fabrication),Krajcik LLC,"Botsford, Boyle and Herzog",Lindgren-Marquardt,5504512275,"Macbook Pro 13""",ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae mauris viverra diam vitae quam suspendisse potenti nullam,9351IS25A51,aliquam convallis nunc proin at turpis a pede posuere nonummy integer,11/14/22,1292.94,User,gmccrackem2a,Gage,McCrackem,gmccrackem2a@bing.com,,10/27/28
|
||||
Boyer and Sons,Excavator,NNH-3656031,Soft Flooring and Base,"Heaney, Altenwerth and Emmerich",Pollich LLC,Pacocha-Kiehn,4861125177,"Macbook Pro 13""",,9929FR08W85,,3/1/23,2300.71,Location,,,,,Pacocha-Kiehn,10/27/28
|
||||
Hayes-Rippin,Trencher,BOL-0305383,Prefabricated Aluminum Metal Canopies,"Botsford, Boyle and Herzog",Walker-Towne,Fritsch-Abernathy,2416994639,"Macbook Pro 13""",neque vestibulum eget vulputate ut ultrices vel augue vestibulum ante ipsum primis in faucibus orci luctus,9139KQ78G81,,10/26/22,1777.56,User,ksennett6,Katerina,Sennett,ksennett6@ibm.com,,10/27/28
|
||||
|
||||
|
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Assets\Api;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AssetPaginationTest extends TestCase
|
||||
{
|
||||
private User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->user = User::factory()->superuser()->create();
|
||||
}
|
||||
|
||||
public function test_response_includes_pagination_fields()
|
||||
{
|
||||
Asset::factory()->count(3)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index'))
|
||||
->assertOk()
|
||||
->assertJsonStructure(['total', 'rows', 'current_page', 'per_page', 'total_pages']);
|
||||
}
|
||||
|
||||
public function test_default_request_returns_page_one()
|
||||
{
|
||||
Asset::factory()->count(3)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index'))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 1);
|
||||
}
|
||||
|
||||
public function test_offset_zero_returns_page_one()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['offset' => 0, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 1);
|
||||
}
|
||||
|
||||
public function test_offset_derives_correct_page_number()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['offset' => 5, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 2);
|
||||
}
|
||||
|
||||
public function test_page_one_returns_current_page_one()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 1);
|
||||
}
|
||||
|
||||
public function test_page_two_returns_current_page_two()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 2);
|
||||
}
|
||||
|
||||
public function test_page_one_returns_first_items()
|
||||
{
|
||||
foreach (range(1, 10) as $i) {
|
||||
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
|
||||
}
|
||||
|
||||
$tags = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
|
||||
->assertOk()
|
||||
->json('rows.*.asset_tag');
|
||||
|
||||
$this->assertEquals(
|
||||
['PAG-TEST-001', 'PAG-TEST-002', 'PAG-TEST-003', 'PAG-TEST-004', 'PAG-TEST-005'],
|
||||
$tags
|
||||
);
|
||||
}
|
||||
|
||||
public function test_page_two_returns_second_set_of_items()
|
||||
{
|
||||
foreach (range(1, 10) as $i) {
|
||||
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
|
||||
}
|
||||
|
||||
$tags = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
|
||||
->assertOk()
|
||||
->json('rows.*.asset_tag');
|
||||
|
||||
$this->assertEquals(
|
||||
['PAG-TEST-006', 'PAG-TEST-007', 'PAG-TEST-008', 'PAG-TEST-009', 'PAG-TEST-010'],
|
||||
$tags
|
||||
);
|
||||
}
|
||||
|
||||
public function test_offset_returns_correct_items()
|
||||
{
|
||||
foreach (range(1, 10) as $i) {
|
||||
Asset::factory()->create(['asset_tag' => sprintf('PAG-TEST-%03d', $i)]);
|
||||
}
|
||||
|
||||
$tags = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['offset' => 5, 'limit' => 5, 'sort' => 'asset_tag', 'order' => 'asc']))
|
||||
->assertOk()
|
||||
->json('rows.*.asset_tag');
|
||||
|
||||
$this->assertEquals(
|
||||
['PAG-TEST-006', 'PAG-TEST-007', 'PAG-TEST-008', 'PAG-TEST-009', 'PAG-TEST-010'],
|
||||
$tags
|
||||
);
|
||||
}
|
||||
|
||||
public function test_page_param_respects_limit()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$response = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 4]))
|
||||
->assertOk();
|
||||
|
||||
$this->assertCount(4, $response->json('rows'));
|
||||
}
|
||||
|
||||
public function test_page_beyond_results_returns_empty_rows()
|
||||
{
|
||||
Asset::factory()->count(5)->create();
|
||||
|
||||
$response = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 99, 'limit' => 5]))
|
||||
->assertOk();
|
||||
|
||||
$this->assertCount(0, $response->json('rows'));
|
||||
$this->assertEquals(5, $response->json('total'));
|
||||
}
|
||||
|
||||
public function test_offset_takes_precedence_over_page_when_both_provided()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
// offset=0 should win over page=3, giving current_page=1
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['offset' => 0, 'page' => 3, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('current_page', 1);
|
||||
}
|
||||
|
||||
public function test_per_page_reflects_the_limit_parameter_as_an_integer()
|
||||
{
|
||||
Asset::factory()->count(3)->create();
|
||||
|
||||
$response = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['limit' => 25]))
|
||||
->assertOk()
|
||||
->assertJsonPath('per_page', 25);
|
||||
|
||||
$this->assertIsInt($response->json('per_page'));
|
||||
}
|
||||
|
||||
public function test_prev_page_url_is_null_on_first_page()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('prev_page_url', null);
|
||||
}
|
||||
|
||||
public function test_next_page_url_is_null_on_last_page()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('next_page_url', null);
|
||||
}
|
||||
|
||||
public function test_next_page_url_contains_correct_page_number()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$url = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->json('next_page_url');
|
||||
|
||||
$this->assertStringContainsString('page=2', $url);
|
||||
$this->assertStringContainsString('limit=5', $url);
|
||||
}
|
||||
|
||||
public function test_prev_page_url_contains_correct_page_number()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$url = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 2, 'limit' => 5]))
|
||||
->assertOk()
|
||||
->json('prev_page_url');
|
||||
|
||||
$this->assertStringContainsString('page=1', $url);
|
||||
$this->assertStringContainsString('limit=5', $url);
|
||||
}
|
||||
|
||||
public function test_page_urls_do_not_include_limit_when_not_in_original_request()
|
||||
{
|
||||
Asset::factory()->count(600)->create();
|
||||
|
||||
$response = $this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1]))
|
||||
->assertOk();
|
||||
|
||||
$this->assertStringNotContainsString('limit=', $response->json('next_page_url'));
|
||||
}
|
||||
|
||||
public function test_both_page_urls_null_when_single_page()
|
||||
{
|
||||
Asset::factory()->count(3)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['page' => 1, 'limit' => 50]))
|
||||
->assertOk()
|
||||
->assertJsonPath('prev_page_url', null)
|
||||
->assertJsonPath('next_page_url', null);
|
||||
}
|
||||
|
||||
public function test_total_pages_is_correct_for_even_division()
|
||||
{
|
||||
Asset::factory()->count(10)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total_pages', 2);
|
||||
}
|
||||
|
||||
public function test_total_pages_rounds_up_for_uneven_division()
|
||||
{
|
||||
Asset::factory()->count(11)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total_pages', 3);
|
||||
}
|
||||
|
||||
public function test_total_pages_is_one_when_results_fit_in_single_page()
|
||||
{
|
||||
Asset::factory()->count(3)->create();
|
||||
|
||||
$this->actingAsForApi($this->user)
|
||||
->getJson(route('api.assets.index', ['limit' => 5]))
|
||||
->assertOk()
|
||||
->assertJsonPath('total_pages', 1);
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,22 @@ class AssetCheckinTest extends TestCase
|
||||
$this->assertHasTheseActionLogs($asset, ['create', 'checkin from']);
|
||||
}
|
||||
|
||||
public function test_location_is_nulled_when_empty_location_id_submitted_on_checkin()
|
||||
{
|
||||
$rtdLocation = Location::factory()->create();
|
||||
$asset = Asset::factory()->assignedToUser()->create([
|
||||
'location_id' => Location::factory()->create()->id,
|
||||
'rtd_location_id' => $rtdLocation->id,
|
||||
]);
|
||||
|
||||
$this->actingAs(User::factory()->checkinAssets()->create())
|
||||
->post(route('hardware.checkin.store', [$asset]), [
|
||||
'location_id' => '',
|
||||
]);
|
||||
|
||||
$this->assertNull($asset->refresh()->location_id);
|
||||
}
|
||||
|
||||
public function test_default_location_can_be_updated_upon_checkin()
|
||||
{
|
||||
$location = Location::factory()->create();
|
||||
|
||||
@@ -305,6 +305,49 @@ class ImportAccessoriesTest extends ImportDataTestCase implements TestsPermissio
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_accessory_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
|
||||
$initialImport = Import::factory()->accessory()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$accessory = Accessory::query()->where('name', $initialRow['itemName'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->accessory()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$accessory->refresh();
|
||||
$this->assertEquals($updatedRow['orderNumber'], $accessory->order_number);
|
||||
|
||||
$updateLog = Actionlog::query()
|
||||
->where('item_type', Accessory::class)
|
||||
->where('item_id', $accessory->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after accessory importer update mode.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function when_import_file_contains_empty_values(): void
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Importing\Api;
|
||||
|
||||
use App\Models\Actionlog as ActionLog;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Import;
|
||||
@@ -131,4 +132,46 @@ class ImportAssetModelsTest extends ImportDataTestCase implements TestsPermissio
|
||||
$this->assertEquals($row['name'], $updatedAssetmodel->name);
|
||||
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_asset_model_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
$initialImport = Import::factory()->assetmodel()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$assetModel = AssetModel::query()->where('name', $initialRow['name'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'model_number' => Str::random(),
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->assetmodel()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$assetModel->refresh();
|
||||
$this->assertEquals($updatedRow['model_number'], $assetModel->model_number);
|
||||
|
||||
$updateLog = ActionLog::query()
|
||||
->where('item_type', AssetModel::class)
|
||||
->where('item_id', $assetModel->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after asset model importer update mode.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,58 @@ class ImportAssetsTest extends ImportDataTestCase implements TestsPermissionsReq
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_asset_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
|
||||
$initialImport = Import::factory()->asset()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$asset = Asset::query()->where('asset_tag', $initialRow['tag'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'itemName' => $initialRow['itemName'].' Updated',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->asset()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$asset->refresh();
|
||||
$this->assertEquals($updatedRow['itemName'], $asset->name);
|
||||
|
||||
$updateLog = ActionLog::query()
|
||||
->where('item_type', Asset::class)
|
||||
->where('item_id', $asset->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after importer update mode.');
|
||||
$this->assertStringContainsString('name', (string) $updateLog->log_meta);
|
||||
|
||||
$checkoutLogsCount = ActionLog::query()
|
||||
->where('item_type', Asset::class)
|
||||
->where('item_id', $asset->id)
|
||||
->where('action_type', 'checkout')
|
||||
->count();
|
||||
|
||||
$this->assertSame(1, $checkoutLogsCount, 'Re-import update should not create a duplicate checkout log for the same assignment.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function custom_column_mapping(): void
|
||||
{
|
||||
|
||||
@@ -249,6 +249,52 @@ class ImportComponentsTest extends ImportDataTestCase implements TestsPermission
|
||||
$this->assertEquals($component->notes, $updatedComponent->notes);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_component_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
|
||||
$initialImport = Import::factory()->component()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$component = Component::query()
|
||||
->where('name', $initialRow['itemName'])
|
||||
->where('serial', $initialRow['serialNumber'])
|
||||
->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->component()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$component->refresh();
|
||||
$this->assertEquals($updatedRow['orderNumber'], $component->order_number);
|
||||
|
||||
$updateLog = ActionLog::query()
|
||||
->where('item_type', Component::class)
|
||||
->where('item_id', $component->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after component importer update mode.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function custom_column_mapping(): void
|
||||
{
|
||||
|
||||
@@ -243,6 +243,49 @@ class ImportConsumablesTest extends ImportDataTestCase implements TestsPermissio
|
||||
$this->assertEquals($consumable->item_number, $updatedConsumable->item_number);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_consumable_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
|
||||
$initialImport = Import::factory()->consumable()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$consumable = Consumable::query()->where('name', $initialRow['itemName'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->consumable()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$consumable->refresh();
|
||||
$this->assertEquals($updatedRow['orderNumber'], $consumable->order_number);
|
||||
|
||||
$updateLog = ActivityLog::query()
|
||||
->where('item_type', Consumable::class)
|
||||
->where('item_id', $consumable->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after consumable importer update mode.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function custom_column_mapping(): void
|
||||
{
|
||||
|
||||
@@ -277,6 +277,52 @@ class ImportLicenseTest extends ImportDataTestCase implements TestsPermissionsRe
|
||||
$this->assertEquals($license->min_amt, $updatedLicense->min_amt);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_license_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
|
||||
$initialImport = Import::factory()->license()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$license = License::query()
|
||||
->where('name', $initialRow['licenseName'])
|
||||
->where('serial', $initialRow['serialNumber'])
|
||||
->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'orderNumber' => (string) $initialRow['orderNumber'].'-UPD',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->license()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$license->refresh();
|
||||
$this->assertEquals($updatedRow['orderNumber'], $license->order_number);
|
||||
|
||||
$updateLog = ActivityLog::query()
|
||||
->where('item_type', License::class)
|
||||
->where('item_id', $license->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after license importer update mode.');
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function custom_column_mapping(): void
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Importing\Api;
|
||||
|
||||
use App\Models\Actionlog as ActionLog;
|
||||
use App\Models\Import;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
@@ -129,4 +130,46 @@ class ImportLocationsTest extends ImportDataTestCase implements TestsPermissions
|
||||
Arr::except($updatedLocation->attributesToArray(), array_merge($updatedAttributes, $location->getDates())),
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_location_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
$initialImport = Import::factory()->locations()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$location = Location::query()->where('name', $initialRow['name'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'notes' => 'Importer update notes',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->locations()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$location->refresh();
|
||||
$this->assertEquals($updatedRow['notes'], $location->notes);
|
||||
|
||||
$updateLog = ActionLog::query()
|
||||
->where('item_type', Location::class)
|
||||
->where('item_id', $location->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after location importer update mode.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Tests\Feature\Importing\Api;
|
||||
|
||||
use App\Models\Actionlog as ActionLog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Import;
|
||||
use App\Models\Location;
|
||||
@@ -260,6 +261,48 @@ class ImportUsersTest extends ImportDataTestCase implements TestsPermissionsRequ
|
||||
);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function update_mode_logs_user_update_in_actionlog(): void
|
||||
{
|
||||
$this->actingAsForApi(User::factory()->superuser()->create());
|
||||
|
||||
$initialFile = ImportFileBuilder::new();
|
||||
$initialRow = $initialFile->firstRow();
|
||||
$initialImport = Import::factory()->users()->create([
|
||||
'file_path' => $initialFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse(['import' => $initialImport->id])->assertOk();
|
||||
|
||||
$user = User::query()->where('username', $initialRow['username'])->sole();
|
||||
|
||||
$updatedRow = array_merge($initialRow, [
|
||||
'position' => $initialRow['position'].' Updated',
|
||||
]);
|
||||
|
||||
$updateFile = new ImportFileBuilder([$updatedRow]);
|
||||
$updateImport = Import::factory()->users()->create([
|
||||
'file_path' => $updateFile->saveToImportsDirectory(),
|
||||
]);
|
||||
|
||||
$this->importFileResponse([
|
||||
'import' => $updateImport->id,
|
||||
'import-update' => true,
|
||||
])->assertOk();
|
||||
|
||||
$user->refresh();
|
||||
$this->assertEquals($updatedRow['position'], $user->jobtitle);
|
||||
|
||||
$updateLog = ActionLog::query()
|
||||
->where('item_type', User::class)
|
||||
->where('item_id', $user->id)
|
||||
->where('action_type', 'update')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$this->assertNotNull($updateLog, 'Expected an update action log entry after user importer update mode.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Some of these should mismatch on purpose to ensure the mapping is working
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Importing;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Category;
|
||||
use App\Models\Company;
|
||||
use App\Models\Import;
|
||||
use App\Models\Location;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AssetImportCreatedAtTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Test that importing assets doesn't modify created_at timestamps on existing assets
|
||||
* This test addresses the reported bug where large imports caused random created_at changes
|
||||
*/
|
||||
#[Test]
|
||||
public function existing_asset_created_at_not_modified_on_import_update()
|
||||
{
|
||||
// Create test data
|
||||
$category = Category::factory()->create(['category_type' => 'asset']);
|
||||
$location = Location::factory()->create();
|
||||
$statusLabel = Statuslabel::factory()->create();
|
||||
$company = Company::factory()->create();
|
||||
$assetModel = AssetModel::factory()->for($category, 'category')->create();
|
||||
|
||||
// Create an existing asset with a known created_at date
|
||||
$originalCreatedAt = now()->subDays(30)->toDateTimeString();
|
||||
$asset = Asset::factory()
|
||||
->for($assetModel, 'model')
|
||||
->for($statusLabel, 'status')
|
||||
->for($location, 'defaultLoc')
|
||||
->for($company, 'company')
|
||||
->create([
|
||||
'asset_tag' => 'TEST-001',
|
||||
'name' => 'Test Asset',
|
||||
'created_at' => $originalCreatedAt,
|
||||
]);
|
||||
|
||||
// Create CSV content that updates the existing asset
|
||||
$csv = "asset tag,item name,category,status\n";
|
||||
$csv .= "TEST-001,Test Asset Updated,{$category->name},{$statusLabel->name}\n";
|
||||
|
||||
// Perform import with update flag
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.store'), [
|
||||
'files' => [
|
||||
$this->createFakeUploadedFile('test.csv', $csv),
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
// Import the file
|
||||
$import = Import::latest()->first();
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.importFile', $import->id), [
|
||||
'import-type' => 'asset',
|
||||
'import-update' => true,
|
||||
'column-mappings' => [
|
||||
'asset tag' => 'asset_tag',
|
||||
'item name' => 'item_name',
|
||||
'category' => 'category',
|
||||
'status' => 'status',
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify the asset's created_at timestamp wasn't modified
|
||||
$asset->refresh();
|
||||
$this->assertEquals(
|
||||
$originalCreatedAt,
|
||||
$asset->created_at->toDateTimeString(),
|
||||
'Asset created_at timestamp was modified during import update, which should not happen'
|
||||
);
|
||||
|
||||
// Verify the asset was updated correctly
|
||||
$this->assertEquals('Test Asset Updated', $asset->name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that multiple successive imports don't cause timestamp drift
|
||||
*/
|
||||
#[Test]
|
||||
public function successive_imports_maintain_created_at_consistency()
|
||||
{
|
||||
// Create test data
|
||||
$category = Category::factory()->create(['category_type' => 'asset']);
|
||||
$location = Location::factory()->create();
|
||||
$statusLabel = Statuslabel::factory()->create();
|
||||
$company = Company::factory()->create();
|
||||
$assetModel = AssetModel::factory()->for($category, 'category')->create();
|
||||
|
||||
// Create multiple existing assets
|
||||
$assets = collect();
|
||||
for ($i = 1; $i <= 5; $i++) {
|
||||
$assets->push(Asset::factory()
|
||||
->for($assetModel, 'model')
|
||||
->for($statusLabel, 'status')
|
||||
->for($location, 'defaultLoc')
|
||||
->for($company, 'company')
|
||||
->create([
|
||||
'asset_tag' => "TEST-{$i}",
|
||||
'created_at' => now()->subDays(30 - $i),
|
||||
]));
|
||||
}
|
||||
|
||||
$originalCreatedAts = $assets->mapWithKeys(fn ($asset) => [$asset->id => $asset->created_at->toDateTimeString()])->toArray();
|
||||
|
||||
// Perform first import update
|
||||
$csv = "asset tag,item name,category,status\n";
|
||||
foreach ($assets as $asset) {
|
||||
$csv .= "{$asset->asset_tag},{$asset->name},{$category->name},{$statusLabel->name}\n";
|
||||
}
|
||||
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.store'), [
|
||||
'files' => [
|
||||
$this->createFakeUploadedFile('test1.csv', $csv),
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$import1 = Import::latest()->first();
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.importFile', $import1->id), [
|
||||
'import-type' => 'asset',
|
||||
'import-update' => true,
|
||||
'column-mappings' => [
|
||||
'asset tag' => 'asset_tag',
|
||||
'item name' => 'item_name',
|
||||
'category' => 'category',
|
||||
'status' => 'status',
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
// Perform second import update
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.store'), [
|
||||
'files' => [
|
||||
$this->createFakeUploadedFile('test2.csv', $csv),
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
$import2 = Import::latest()->first();
|
||||
$this->actingAsForApi(User::factory()->canImport()->create())
|
||||
->postJson(route('api.imports.importFile', $import2->id), [
|
||||
'import-type' => 'asset',
|
||||
'import-update' => true,
|
||||
'column-mappings' => [
|
||||
'asset tag' => 'asset_tag',
|
||||
'item name' => 'item_name',
|
||||
'category' => 'category',
|
||||
'status' => 'status',
|
||||
],
|
||||
])
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify all assets' created_at timestamps remain unchanged
|
||||
foreach ($assets as $asset) {
|
||||
$asset->refresh();
|
||||
$this->assertEquals(
|
||||
$originalCreatedAts[$asset->id],
|
||||
$asset->created_at->toDateTimeString(),
|
||||
"Asset {$asset->asset_tag} created_at changed between imports"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to create a fake uploaded file
|
||||
*/
|
||||
protected function createFakeUploadedFile(string $filename, string $content)
|
||||
{
|
||||
$path = tempnam(sys_get_temp_dir(), 'csv');
|
||||
file_put_contents($path, $content);
|
||||
|
||||
return new UploadedFile(
|
||||
$path,
|
||||
$filename,
|
||||
'text/csv',
|
||||
null,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Users\Ui;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Group;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExportUsersTest extends TestCase
|
||||
{
|
||||
public function test_requires_permission()
|
||||
{
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get(route('users.export'))
|
||||
->assertForbidden();
|
||||
}
|
||||
|
||||
public function test_can_export_users_to_csv()
|
||||
{
|
||||
$creator = User::factory()->create(['first_name' => 'Han', 'last_name' => 'Solo']);
|
||||
$deptManager = User::factory()->create(['first_name' => 'Mace', 'last_name' => 'Windu']);
|
||||
|
||||
$luke = User::factory()
|
||||
->forCompany(['name' => 'Jedi'])
|
||||
->forManager(['first_name' => 'Ben', 'last_name' => 'Kenobi'])
|
||||
->forLocation(['name' => 'Space'])
|
||||
->forDepartment(['name' => 'Lightsaber Fighting Dept', 'manager_id' => $deptManager->id])
|
||||
->create([
|
||||
'jobtitle' => 'Jedi Master',
|
||||
'employee_num' => '789',
|
||||
'first_name' => 'Luke',
|
||||
'last_name' => 'Skywalker',
|
||||
'display_name' => 'Master Luke',
|
||||
'username' => 'lskywalker',
|
||||
'email' => 'skywalker@jedi.com',
|
||||
'phone' => '555-1234',
|
||||
'mobile' => '555-5678',
|
||||
'website' => 'https://jedi.com',
|
||||
'address' => '123 Moisture Farm',
|
||||
'city' => 'Anchorhead',
|
||||
'state' => 'TA',
|
||||
'country' => 'Outer Rim',
|
||||
'zip' => '12345',
|
||||
'notes' => 'Nice guy...',
|
||||
'vip' => 1,
|
||||
'remote' => 1,
|
||||
'autoassign_licenses' => 1,
|
||||
'ldap_import' => 1,
|
||||
'start_date' => '2020-01-01',
|
||||
'end_date' => '2030-12-31',
|
||||
'created_by' => $creator->id,
|
||||
]);
|
||||
|
||||
$luke->groups()->sync(
|
||||
Group::factory()->count(2)->sequence(
|
||||
['name' => 'Jedi'],
|
||||
['name' => 'Jedi Dance Crew'],
|
||||
)->create()
|
||||
);
|
||||
|
||||
Asset::factory()->assignedToUser($luke)->count(2)->create();
|
||||
LicenseSeat::factory()->assignedToUser($luke)->count(2)->create();
|
||||
Accessory::factory()->checkedOutToUser($luke)->count(2)->create();
|
||||
Consumable::factory()->checkedOutToUser($luke)->count(2)->create();
|
||||
User::factory()->count(3)->create(['manager_id' => $luke->id]);
|
||||
Location::factory()->count(2)->create(['manager_id' => $luke->id]);
|
||||
|
||||
$this->actingAs(User::factory()->viewUsers()->create())
|
||||
->get(route('users.export'))
|
||||
->assertOk()
|
||||
->assertCsvHeader()
|
||||
->assertSeeTextInStreamedResponse([
|
||||
'Jedi',
|
||||
'Jedi Master',
|
||||
'789',
|
||||
'Luke',
|
||||
'Skywalker',
|
||||
'Luke Skywalker',
|
||||
'lskywalker',
|
||||
'skywalker@jedi.com',
|
||||
'Ben Kenobi',
|
||||
'Space',
|
||||
'Lightsaber Fighting Dept',
|
||||
'2',
|
||||
'Jedi, Jedi Dance Crew',
|
||||
trans('general.user'),
|
||||
'Nice guy...',
|
||||
trans('general.yes'),
|
||||
])
|
||||
->assertSeePairsInStreamedResponse([
|
||||
trans('admin/users/table.first_name') => 'Luke',
|
||||
trans('admin/users/table.last_name') => 'Skywalker',
|
||||
trans('admin/users/table.display_name') => 'Master Luke',
|
||||
trans('admin/users/table.username') => 'lskywalker',
|
||||
trans('admin/users/table.email') => 'skywalker@jedi.com',
|
||||
trans('admin/companies/table.title') => 'Jedi',
|
||||
trans('general.groups') => 'Jedi, Jedi Dance Crew',
|
||||
trans('admin/users/table.title') => 'Jedi Master',
|
||||
trans('general.employee_number') => '789',
|
||||
trans('admin/users/table.manager') => 'Ben Kenobi',
|
||||
trans('admin/users/table.location') => 'Space',
|
||||
trans('general.department') => 'Lightsaber Fighting Dept',
|
||||
trans('general.assets') => '2',
|
||||
trans('general.accessories') => '2',
|
||||
trans('general.consumables') => '2',
|
||||
trans('general.licenses') => '2',
|
||||
trans('general.notes') => 'Nice guy...',
|
||||
trans('admin/users/table.phone') => '555-1234',
|
||||
trans('admin/users/table.mobile') => '555-5678',
|
||||
trans('general.website') => 'https://jedi.com',
|
||||
trans('general.address') => '123 Moisture Farm',
|
||||
trans('general.city') => 'Anchorhead',
|
||||
trans('general.state') => 'TA',
|
||||
trans('general.country') => 'Outer Rim',
|
||||
trans('general.zip') => '12345',
|
||||
trans('general.importer.vip') => trans('general.yes'),
|
||||
trans('admin/users/general.remote') => trans('general.yes'),
|
||||
trans('general.autoassign_licenses') => trans('general.yes'),
|
||||
trans('general.ldap_sync') => trans('general.yes'),
|
||||
trans('admin/users/table.managed_users') => '3',
|
||||
trans('admin/users/table.managed_locations') => '2',
|
||||
trans('admin/users/general.department_manager') => 'Mace Windu',
|
||||
trans('general.created_by') => 'Han Solo',
|
||||
trans('general.start_date') => '2020-01-01',
|
||||
trans('general.end_date') => '2030-12-31',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,8 @@ trait CustomTestMacros
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
Assert::assertTrue(
|
||||
$records->contains($needle)
|
||||
$records->contains($needle),
|
||||
"Response did not contain the expected value: {$needle}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,12 +181,64 @@ trait CustomTestMacros
|
||||
|
||||
foreach ($needles as $needle) {
|
||||
Assert::assertFalse(
|
||||
$records->contains($needle)
|
||||
$records->contains($needle),
|
||||
"Response contained unexpected value: {$needle}"
|
||||
);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Assert that the streamed CSV response contains a row where all given header→value pairs match simultaneously.
|
||||
*
|
||||
* The first row of the CSV is treated as headers. Each subsequent row is combined with those headers
|
||||
* to produce an associative map, and the assertion passes if at least one row satisfies every pair.
|
||||
*
|
||||
* Unlike assertSeeTextInStreamedResponse, this verifies that the values appear together in the same
|
||||
* row under the correct column — not just anywhere in the file.
|
||||
*
|
||||
* Usage:
|
||||
* ->assertSeePairsInStreamedResponse([
|
||||
* 'First Name' => 'Luke',
|
||||
* 'Last Name' => 'Skywalker',
|
||||
* 'Username' => 'lskywalker',
|
||||
* ])
|
||||
*/
|
||||
TestResponse::macro(
|
||||
'assertSeePairsInStreamedResponse',
|
||||
function (array $pair): self {
|
||||
$records = collect(Reader::createFromString($this->streamedContent())->getRecords());
|
||||
|
||||
$headers = collect($records->shift());
|
||||
|
||||
$combined = $records->map(fn ($record) => $headers->combine($record));
|
||||
|
||||
Assert::assertTrue(
|
||||
$combined->contains(function ($row) use ($pair) {
|
||||
foreach ($pair as $key => $value) {
|
||||
if (($row[$key] ?? null) !== $value) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}),
|
||||
'Response did not contain a row matching the expected pairs: '.json_encode($pair)
|
||||
);
|
||||
|
||||
return $this;
|
||||
}
|
||||
);
|
||||
|
||||
TestResponse::macro(
|
||||
'assertCsvHeader',
|
||||
function () {
|
||||
$this->assertHeader('content-type', 'text/csv; charset=utf-8');
|
||||
|
||||
return $this;
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -82,4 +82,75 @@ class PreserveUnauthorizedPrivilegedPermissionsActionTest extends TestCase
|
||||
$this->assertArrayNotHasKey('superuser', $normalized);
|
||||
$this->assertSame('1', (string) $normalized['users.view']);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_grant_themselves_additional_permissions(): void
|
||||
{
|
||||
$actor = User::factory()->editUsers()->create();
|
||||
|
||||
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: ['users.edit' => '1', 'assets.view' => '1', 'reports.view' => '1'],
|
||||
authenticatedUser: $actor,
|
||||
originalPermissions: ['users.edit' => '1'],
|
||||
targetUser: $actor,
|
||||
);
|
||||
|
||||
$this->assertSame(['users.edit' => '1'], $normalized);
|
||||
}
|
||||
|
||||
public function test_non_admin_cannot_remove_their_own_permissions(): void
|
||||
{
|
||||
$actor = User::factory()->editUsers()->create();
|
||||
|
||||
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: [],
|
||||
authenticatedUser: $actor,
|
||||
originalPermissions: ['users.edit' => '1'],
|
||||
targetUser: $actor,
|
||||
);
|
||||
|
||||
$this->assertSame(['users.edit' => '1'], $normalized);
|
||||
}
|
||||
|
||||
public function test_admin_cannot_modify_their_own_permissions(): void
|
||||
{
|
||||
$actor = User::factory()->admin()->create();
|
||||
|
||||
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: ['admin' => '1', 'assets.view' => '1'],
|
||||
authenticatedUser: $actor,
|
||||
originalPermissions: ['admin' => '1'],
|
||||
targetUser: $actor,
|
||||
);
|
||||
|
||||
$this->assertSame(['admin' => '1'], $normalized);
|
||||
}
|
||||
|
||||
public function test_superuser_can_modify_their_own_permissions(): void
|
||||
{
|
||||
$actor = User::factory()->superuser()->create();
|
||||
|
||||
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: ['superuser' => '1', 'assets.view' => '1'],
|
||||
authenticatedUser: $actor,
|
||||
originalPermissions: ['superuser' => '1'],
|
||||
targetUser: $actor,
|
||||
);
|
||||
|
||||
$this->assertSame('1', (string) $normalized['assets.view']);
|
||||
}
|
||||
|
||||
public function test_non_admin_can_modify_permissions_of_a_different_user(): void
|
||||
{
|
||||
$actor = User::factory()->editUsers()->create();
|
||||
$target = User::factory()->create();
|
||||
|
||||
$normalized = PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: ['assets.view' => '1'],
|
||||
authenticatedUser: $actor,
|
||||
originalPermissions: [],
|
||||
targetUser: $target,
|
||||
);
|
||||
|
||||
$this->assertSame('1', (string) $normalized['assets.view']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ class HelperTest extends TestCase
|
||||
|
||||
public function test_parse_currency_method()
|
||||
{
|
||||
$this->settings->set(['default_currency' => 'USD']);
|
||||
$this->settings->set(['default_currency' => 'USD', 'digit_separator' => '1,234.56']);
|
||||
$this->assertSame(12.34, Helper::ParseCurrency('USD 12.34'));
|
||||
$this->assertSame(8888.0, Helper::ParseCurrency('8,888.00')); // US thousands comma
|
||||
$this->assertSame(8888.0, Helper::ParseCurrency('8888.00')); // US plain
|
||||
|
||||
$this->settings->set(['digit_separator' => '1.234,56']);
|
||||
$this->assertSame(12.34, Helper::ParseCurrency('12,34'));
|
||||
$this->assertSame(8888.0, Helper::ParseCurrency('8.888,00')); // EU thousands dot
|
||||
$this->assertSame(8888.0, Helper::ParseCurrency('8888,00')); // EU plain
|
||||
}
|
||||
|
||||
public function test_get_redirect_option_method()
|
||||
|
||||
@@ -23,13 +23,42 @@ class SnipeModelTest extends TestCase
|
||||
{
|
||||
$c = new SnipeModel;
|
||||
$c->purchase_cost = '';
|
||||
$this->assertTrue($c->purchase_cost == null);
|
||||
$this->assertNull($c->purchase_cost);
|
||||
$c->purchase_cost = null;
|
||||
$this->assertNull($c->purchase_cost);
|
||||
$c->purchase_cost = '0.00';
|
||||
$this->assertTrue($c->purchase_cost == 0.00);
|
||||
$this->assertSame(0.0, (float) $c->purchase_cost);
|
||||
$c->purchase_cost = '9.54';
|
||||
$this->assertTrue($c->purchase_cost == 9.54);
|
||||
$c->purchase_cost = '9.50';
|
||||
$this->assertTrue($c->purchase_cost == 9.5);
|
||||
$this->assertSame(9.54, (float) $c->purchase_cost);
|
||||
$c->purchase_cost = 12.34; // already a float — no ParseCurrency needed
|
||||
$this->assertSame(12.34, (float) $c->purchase_cost);
|
||||
}
|
||||
|
||||
public function test_sets_purchase_cost_from_us_format_with_thousands()
|
||||
{
|
||||
$this->settings->set(['digit_separator' => '1,234.56']);
|
||||
$c = new SnipeModel;
|
||||
|
||||
$c->purchase_cost = '8,888.00';
|
||||
$this->assertSame(8888.0, (float) $c->purchase_cost);
|
||||
|
||||
$c->purchase_cost = '8888.00';
|
||||
$this->assertSame(8888.0, (float) $c->purchase_cost);
|
||||
}
|
||||
|
||||
public function test_sets_purchase_cost_from_eu_format_with_thousands()
|
||||
{
|
||||
$this->settings->set(['digit_separator' => '1.234,56']);
|
||||
$c = new SnipeModel;
|
||||
|
||||
$c->purchase_cost = '8.888,00';
|
||||
$this->assertSame(8888.0, (float) $c->purchase_cost);
|
||||
|
||||
$c->purchase_cost = '8888,00';
|
||||
$this->assertSame(8888.0, (float) $c->purchase_cost);
|
||||
|
||||
$c->purchase_cost = '12,34';
|
||||
$this->assertSame(12.34, (float) $c->purchase_cost);
|
||||
}
|
||||
|
||||
public function test_nulls_blank_location_ids_but_not_others()
|
||||
|
||||
Reference in New Issue
Block a user