3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Http/Controllers/ViewAssetsController.php
Olivier Lambert 409e11dca4 Make accessories requestable
Accessories can now be flagged as requestable and requested by users from
the requestable items page, the same way assets and asset models already
can. No migration is needed since the accessories table already carries a
requestable column.

I kept the semantics deliberately simple: a request just records intent
(and the requested quantity) and notifies the admins. It does not touch or
reserve stock. The admin still performs the normal checkout, which is what
actually decrements quantity. That way requests behave the same for unique
assets and for quantity-based accessories, instead of inventing a separate
"reserved" state.

The admin "Requested" queue already lists every checkout request
polymorphically (it showed asset models too), so accessory requests appear
there as well; I extended that view to render the accessory name, image and
a checkout action so an admin can actually see and fulfil the request.

While wiring this up I also fixed a pre-existing bug in the request flow:
the Requestable trait saved a 'qty' key, but the column is 'quantity' and
wasn't fillable, so requested quantities were being silently dropped (this
affected asset models too). Quantity is now persisted and read back
correctly.

Components and licenses can follow the same pattern; they each just need a
small migration to add the requestable column.
2026-06-10 19:57:53 +02:00

292 lines
11 KiB
PHP
Executable File

<?php
namespace App\Http\Controllers;
use App\Actions\CheckoutRequests\CancelCheckoutRequestAction;
use App\Actions\CheckoutRequests\CreateCheckoutRequestAction;
use App\Enums\ActionType;
use App\Exceptions\AssetNotRequestable;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\RequestAssetCancelation;
use App\Notifications\RequestAssetNotification;
use Exception;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
/**
* This controller handles all actions related to the ability for users
* to view their own assets in the Snipe-IT Asset Management application.
*
* @version v1.0
*/
class ViewAssetsController extends Controller
{
/**
* Extract custom fields that should be displayed in user view.
*/
private function extractCustomFields(User $user): array
{
$fieldArray = [];
foreach ($user->assets as $asset) {
if ($asset->model && $asset->model->fieldset) {
foreach ($asset->model->fieldset->fields as $field) {
if ($field->display_in_user_view == '1') {
$fieldArray[$field->db_column] = $field->name;
}
}
}
}
return array_unique($fieldArray);
}
/**
* Get list of users viewable by the current user.
*/
private function getViewableUsers(User $authUser): Collection
{
// SuperAdmin sees all users
if ($authUser->isSuperUser()) {
return User::select('id', 'first_name', 'last_name', 'username')
->where('activated', 1)
->orderBy('last_name')
->orderBy('first_name')
->get();
}
// Regular manager sees only their subordinates + self
$managedUsers = $authUser->getAllSubordinates();
// If user has subordinates, show them with self at beginning
if ($managedUsers->count() > 0) {
return collect([$authUser])->merge($managedUsers)
->sortBy('last_name')
->sortBy('first_name');
}
// User has no subordinates, only sees themselves
return collect([$authUser]);
}
/**
* Get the selected user ID from request or default to current user.
*/
private function getSelectedUserId(Request $request, Collection $subordinates, int $defaultUserId): int
{
// If no subordinates or no user_id in request, return default
if ($subordinates->count() <= 1 || ! $request->filled('user_id')) {
return $defaultUserId;
}
$requestedUserId = (int) $request->input('user_id');
// Validate if the requested user is allowed
if ($subordinates->contains('id', $requestedUserId)) {
return $requestedUserId;
}
// If invalid ID or not authorized, return default
return $defaultUserId;
}
/**
* Show user's assigned assets with optional manager view functionality.
*/
public function getIndex(Request $request): View|RedirectResponse
{
$authUser = auth()->user();
$settings = Setting::getSettings();
$subordinates = collect();
$selectedUserId = $authUser->id;
// Process manager view if enabled
if ($settings->manager_view_enabled) {
$subordinates = $this->getViewableUsers($authUser);
$selectedUserId = $this->getSelectedUserId($request, $subordinates, $authUser->id);
}
// Load the data for the user to be viewed (either auth user or selected subordinate)
$userToView = User::with([
'assets',
'assets.model',
'assets.model.fieldset.fields',
'consumables',
'accessories',
'licenses',
'companies',
])->find($selectedUserId);
// If the user to view couldn't be found (shouldn't happen with proper logic), redirect with error
if (! $userToView) {
return redirect()->route('view-assets')->with('error', trans('admin/users/message.user_not_found'));
}
// Process custom fields for the user being viewed
$fieldArray = $this->extractCustomFields($userToView);
// Pass the necessary data to the view
return view('account/view-assets', [
'user' => $userToView, // Use 'user' for compatibility with the existing view
'field_array' => $fieldArray,
'settings' => $settings,
'subordinates' => $subordinates,
'selectedUserId' => $selectedUserId,
]);
}
/**
* Returns view of requestable items for a user.
*/
public function getRequestableIndex(): View
{
$assets = Asset::with('model', 'defaultLoc', 'location', 'assignedTo', 'requests')->Hardware()->RequestableAssets();
$models = AssetModel::with([
'category',
'requests',
'assets' => function ($q) {
$q->where('requestable', 1)
->whereHas('status', fn ($s) => $s->where('archived', 0)
->where(fn ($s) => $s->where('deployable', 1)->orWhere('pending', 1)
)
);
},
])->RequestableModels()->get();
$accessories = Accessory::with('category', 'location', 'requests')
->withCount('checkouts as checkouts_count')
->RequestableAccessories()
->get();
return view('account/requestable-assets', compact('assets', 'models', 'accessories'));
}
public function getRequestItem(Request $request, $itemType, $itemId = null, $cancel_by_admin = false, $requestingUser = null): RedirectResponse
{
$data = [];
$item = null;
$fullItemType = 'App\\Models\\'.studly_case($itemType);
if ($itemType == 'asset_model') {
$itemType = 'model';
}
$item = call_user_func([$fullItemType, 'find'], $itemId);
$user = auth()->user();
$logaction = new Actionlog;
$logaction->item_id = $data['asset_id'] = $item->id;
$logaction->item_type = $fullItemType;
$logaction->created_at = $data['requested_date'] = date('Y-m-d H:i:s');
if ($user->location_id) {
$logaction->location_id = $user->location_id;
}
$logaction->target_id = $data['user_id'] = auth()->id();
$logaction->target_type = User::class;
$data['item_quantity'] = $request->has('request-quantity') ? e($request->input('request-quantity')) : 1;
$data['requested_by'] = $user->display_name;
$data['item'] = $item;
$data['item_type'] = $itemType;
$data['target'] = auth()->user();
$data['item_url'] = match ($fullItemType) {
Asset::class => route('hardware.show', $item->id),
AssetModel::class => route('view/model', $item->id),
Accessory::class => route('accessories.show', $item->id),
default => route("view/{$itemType}", $item->id),
};
$settings = Setting::getSettings();
$is_admin = $user->isSuperUser() || $user->isAdmin();
if ($cancel_by_admin && ! $is_admin) {
return redirect()->back()->with('error', trans('general.insufficient_permissions'));
}
if (($item_request = $item->isRequestedBy($user)) || ($is_admin && $cancel_by_admin)) {
$item->cancelRequest($is_admin && $cancel_by_admin ? $requestingUser : null);
$data['item_quantity'] = ($item_request) ? $item_request->quantity : 1;
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
try {
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send request cancellation notification: '.$e->getMessage());
}
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
} else {
if (($fullItemType === Asset::class && is_null(Asset::RequestableAssets()->find($item->id)))
|| ($fullItemType === Accessory::class && is_null(Accessory::RequestableAccessories()->find($item->id)))) {
return redirect()->back()->with('error', trans('admin/hardware/message.requests.error'));
}
$item->request($data['item_quantity']);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
try {
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (Exception $e) {
Log::warning('Could not send asset request notification: '.$e->getMessage());
}
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
}
}
/**
* Process a specific requested asset
*
* @param null $assetId
*/
public function store(Asset $asset): RedirectResponse
{
try {
CreateCheckoutRequestAction::run($asset, auth()->user());
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
} catch (AssetNotRequestable $e) {
return redirect()->back()->with('error', 'Asset is not requestable');
} catch (AuthorizationException $e) {
return redirect()->back()->with('error', trans('admin/hardware/message.requests.error'));
} catch (Exception $e) {
report($e);
return redirect()->back()->with('error', trans('general.something_went_wrong'));
}
}
public function destroy(Asset $asset): RedirectResponse
{
try {
CancelCheckoutRequestAction::run($asset, auth()->user());
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
} catch (Exception $e) {
report($e);
return redirect()->back()->with('error', trans('general.something_went_wrong'));
}
}
public function getRequestedAssets(): View
{
return view('account/requested');
}
}