Merge remote-tracking branch 'origin/develop'

# Conflicts:
#	config/version.php
This commit is contained in:
snipe
2025-11-10 20:58:15 +00:00
23 changed files with 563 additions and 115 deletions
+27
View File
@@ -4235,6 +4235,33 @@
"contributions": [
"code"
]
},
{
"login": "smarsching",
"name": "Sebastian Marsching",
"avatar_url": "https://avatars.githubusercontent.com/u/2880129?v=4",
"profile": "http://sebastian.marsching.com/",
"contributions": [
"code"
]
},
{
"login": "mohammad-ahmadi1",
"name": "Mo",
"avatar_url": "https://avatars.githubusercontent.com/u/40658372?v=4",
"profile": "https://github.com/mohammad-ahmadi1",
"contributions": [
"code"
]
},
{
"login": "MarvelousAnything",
"name": "Owen V. Hayes",
"avatar_url": "https://avatars.githubusercontent.com/u/20994684?v=4",
"profile": "https://github.com/MarvelousAnything",
"contributions": [
"code"
]
}
]
}
+2 -1
View File
@@ -68,7 +68,8 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/181059?v=4" width="110px;"/><br /><sub>Juan Font</sub>](https://github.com/juanfont)<br />[💻](https://github.com/snipe/snipe-it/commits?author=juanfont "Code") | [<img src="https://avatars.githubusercontent.com/u/13137708?v=4" width="110px;"/><br /><sub>Juho Taipale</sub>](https://github.com/juhotaipale)<br />[💻](https://github.com/snipe/snipe-it/commits?author=juhotaipale "Code") | [<img src="https://avatars.githubusercontent.com/u/1007419?v=4" width="110px;"/><br /><sub>Korvin Szanto</sub>](https://github.com/KorvinSzanto)<br />[💻](https://github.com/snipe/snipe-it/commits?author=KorvinSzanto "Code") | [<img src="https://avatars.githubusercontent.com/u/8513053?v=4" width="110px;"/><br /><sub>Lewis Foster</sub>](https://lewisfoster.foo/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=sniff122 "Code") | [<img src="https://avatars.githubusercontent.com/u/33877541?v=4" width="110px;"/><br /><sub>Logan Swartzendruber</sub>](https://github.com/loganswartz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=loganswartz "Code") | [<img src="https://avatars.githubusercontent.com/u/1156208?v=4" width="110px;"/><br /><sub>Lorenzo P.</sub>](https://github.com/lopezio)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lopezio "Code") | [<img src="https://avatars.githubusercontent.com/u/33946590?v=4" width="110px;"/><br /><sub>Lukas Jung</sub>](https://github.com/m4us1ne)<br />[💻](https://github.com/snipe/snipe-it/commits?author=m4us1ne "Code") |
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+26 -10
View File
@@ -3,13 +3,18 @@
namespace App\Console\Commands;
use App\Mail\UnacceptedAssetReminderMail;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\CheckoutAssetNotification;
use App\Notifications\CurrentInventory;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Facades\Mail;
class SendAcceptanceReminder extends Command
@@ -45,19 +50,30 @@ class SendAcceptanceReminder extends Command
*/
public function handle()
{
$pending = CheckoutAcceptance::pending()->where('checkoutable_type', 'App\Models\Asset')
->whereHas('checkoutable', function($query) {
$query->where('accepted_at', null)
->where('declined_at', null);
})
->with(['assignedTo', 'checkoutable.assignedTo', 'checkoutable.model', 'checkoutable.adminuser'])
->get();
$pending = CheckoutAcceptance::query()
->with([
'checkoutable' => function (MorphTo $morph) {
$morph->morphWith([
Asset::class => ['model.category', 'assignedTo', 'adminuser', 'company', 'checkouts'],
Accessory::class => ['category', 'company', 'checkouts'],
LicenseSeat::class => ['user', 'license', 'checkouts'],
Component::class => ['assignedTo', 'company', 'checkouts'],
Consumable::class => ['company', 'checkouts'],
]);
},
'assignedTo',
])
->whereHasMorph(
'checkoutable',
[Asset::class, Accessory::class, LicenseSeat::class, Component::class, Consumable::class],
fn ($q) => $q->whereNull('accepted_at')
->whereNull('declined_at')
)
->pending()
->get();
$count = 0;
$unacceptedAssetGroups = $pending
->filter(function($acceptance) {
return $acceptance->checkoutable_type == 'App\Models\Asset';
})
->map(function($acceptance) {
return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance];
})
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace App\Enums;
enum ActionType: string
{
// General
case Create = 'create';
case Update = 'update';
case Delete = 'delete';
case Restore = 'restore';
// Assets/Accessories/Components/Licenses/Consumables
case Checkout = 'checkout';
case CheckinFrom = 'checkin from';
case Requested = 'requested';
case RequestCanceled = 'request canceled';
case Accepted = 'accepted';
case Declined = 'declined';
case Audit = 'audit';
case NoteAdded = 'note added';
// Users
case TwoFactorReset = '2FA reset';
case Merged = 'merged';
// Licenses
case DeleteSeats = 'delete seats';
case AddSeats = 'add seats';
// File Uploads
case Uploaded = 'uploaded';
case UploadDeleted = 'upload deleted';
}
+4
View File
@@ -30,6 +30,7 @@ use App\Models\Consumable;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
use App\Models\Supplier;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -52,6 +53,7 @@ abstract class Controller extends BaseController
'licenses' => License::class,
'locations' => Location::class,
'models' => AssetModel::class,
'suppliers' => Supplier::class,
'users' => User::class,
];
@@ -66,6 +68,7 @@ abstract class Controller extends BaseController
'licenses' => 'private_uploads/licenses/',
'locations' => 'private_uploads/locations/',
'models' => 'private_uploads/models/',
'suppliers' => 'private_uploads/suppliers/',
'users' => 'private_uploads/users/',
];
@@ -80,6 +83,7 @@ abstract class Controller extends BaseController
'licenses' => 'license',
'locations' => 'location',
'models' => 'model',
'suppliers' => 'supplier',
'users' => 'user',
];
+102 -46
View File
@@ -3,12 +3,21 @@
namespace App\Http\Controllers;
use App\Helpers\Helper;
use App\Mail\CheckoutAccessoryMail;
use App\Mail\CheckoutAssetMail;
use App\Mail\CheckoutComponentMail;
use App\Mail\CheckoutConsumableMail;
use App\Mail\CheckoutLicenseMail;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Category;
use App\Models\Checkoutable;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\LicenseSeat;
use App\Models\Maintenance;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
@@ -18,9 +27,11 @@ use App\Models\License;
use App\Models\ReportTemplate;
use App\Models\Setting;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Mail;
use \Illuminate\Contracts\View\View;
use League\Csv\Reader;
@@ -1117,33 +1128,29 @@ class ReportsController extends Controller
$showDeleted = $deleted == 'deleted';
$query = CheckoutAcceptance::pending()
->where('checkoutable_type', 'App\Models\Asset')
->with([
'checkoutable' => function (MorphTo $query) {
$query->morphWith([
AssetModel::class => ['model'],
Company::class => ['company'],
Asset::class => ['assignedTo'],
])->with('model.category');
Asset::class => ['model.category', 'assignedTo', 'company'],
Accessory::class => ['category','checkouts', 'company'],
LicenseSeat::class => ['user', 'license'],
Component::class => ['assignedTo', 'company'],
Consumable::class => ['company'],
]);
},
'assignedTo' => function($query){
$query->withTrashed();
}
]);
])->orderByDesc('checkout_acceptances.created_at');
if ($showDeleted) {
$query->withTrashed();
}
$assetsForReport = $query->get()
->map(function ($acceptance) {
return [
'assetItem' => $acceptance->checkoutable,
'acceptance' => $acceptance,
];
});
$itemsForReport = $query->get()->map(fn ($unaccepted) => Checkoutable::fromAcceptance($unaccepted));
return view('reports/unaccepted_assets', compact('assetsForReport','showDeleted' ));
return view('reports/unaccepted_assets', compact('itemsForReport','showDeleted' ));
}
/**
@@ -1155,41 +1162,77 @@ class ReportsController extends Controller
public function sentAssetAcceptanceReminder(Request $request) : RedirectResponse
{
$this->authorize('reports.view');
if (!$acceptance = CheckoutAcceptance::pending()->find($request->input('acceptance_id'))) {
$id = $request->input('acceptance_id');
$query = CheckoutAcceptance::query()
->with([
'checkoutable' => function (MorphTo $query) {
$query->morphWith([
Asset::class => ['model.category', 'assignedTo', 'company', 'checkouts'],
Accessory::class => ['category', 'company', 'checkouts'],
LicenseSeat::class => ['user', 'license', 'checkouts'],
Component::class => ['assignedTo', 'company', 'checkouts'],
Consumable::class => ['company', 'checkouts'],
]);
},
'assignedTo' => fn ($q) => $q->withTrashed(),
])
->pending();
$acceptance = $query->find($id);
if (!$acceptance) {
Log::debug('No pending acceptances');
// Redirect to the unaccepted assets report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
}
$item = $acceptance->checkoutable;
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
$email = $assignee?->email;
$locale = $assignee?->locale;
$assetItem = $acceptance->checkoutable;
Log::debug(print_r($assetItem, true));
Log::debug(print_r($acceptance, true));
if (is_null($acceptance->created_at)){
Log::debug('No acceptance created_at');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
} else {
$logItem_res = $assetItem->checkouts()->where('created_at', '=', $acceptance->created_at)->get();
if($item instanceof LicenseSeat){
$logItem_res = $item->license->checkouts()->with('adminuser')->where('created_at', '=', $acceptance->created_at)->get();
}
else{
$logItem_res = $item->checkouts()->with('adminuser')->where('created_at', '=', $acceptance->created_at)->get();
}
if ($logItem_res->isEmpty()){
Log::debug('Acceptance date mismatch');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
}
$logItem = $logItem_res[0];
}
$email = $assetItem->assignedTo?->email;
$locale = $assetItem->assignedTo?->locale;
if (is_null($email) || $email === '') {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.no_email'));
}
Mail::to($email)->send((new CheckoutAssetMail($assetItem, $assetItem->assignedTo, $logItem->user, $acceptance, $logItem->note, firstTimeSending: false))->locale($locale));
$mailable = $this->getCheckoutMailType($acceptance, $logItem);
Mail::to($email)->send($mailable->locale($locale));
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
}
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem) : Mailable
{
$lookup = [
Accessory::class => CheckoutAccessoryMail::class,
Asset::class => CheckoutAssetMail::class,
LicenseSeat::class => CheckoutLicenseMail::class,
Consumable::class => CheckoutConsumableMail::class,
Component::class => CheckoutComponentMail::class,
];
$mailable= $lookup[get_class($acceptance->checkoutable)];
return new $mailable($acceptance->checkoutable,
$acceptance->checkedOutTo ?? $acceptance->assignedTo,
$logItem->adminuser,
$acceptance,
$acceptance->note);
}
/**
* sentAssetAcceptanceReminder
*
@@ -1221,31 +1264,41 @@ class ReportsController extends Controller
public function postAssetAcceptanceReport($deleted = false) : Response
{
$this->authorize('reports.view');
$showDeleted = $deleted == 'deleted';
$showDeleted = request('deleted') === 'deleted';;
/**
* Get all assets with pending checkout acceptances
*/
if($showDeleted) {
$acceptances = CheckoutAcceptance::pending()->where('checkoutable_type', 'App\Models\Asset')->withTrashed()->with(['assignedTo', 'checkoutable.assignedTo', 'checkoutable.model'])->get();
} else {
$acceptances = CheckoutAcceptance::pending()->where('checkoutable_type', 'App\Models\Asset')->with(['assignedTo', 'checkoutable.assignedTo', 'checkoutable.model'])->get();
}
$assetsForReport = $acceptances
->filter(function($acceptance) {
return $acceptance->checkoutable_type == 'App\Models\Asset';
})
->map(function($acceptance) {
return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance];
});
$acceptances = CheckoutAcceptance::pending()
->with([
'checkoutable' => function (MorphTo $acceptance) {
$acceptance->morphWith([
Asset::class => ['model.category', 'assignedTo', 'company'],
Accessory::class => ['category','checkouts', 'company'],
LicenseSeat::class => ['user', 'license'],
Component::class => ['assignedTo', 'company'],
Consumable::class => ['company'],
]);
},
'assignedTo',
])->orderByDesc('checkout_acceptances.created_at');
if ($showDeleted) {
$acceptances->withTrashed();
}
$itemsForReport = $acceptances->get()->map(fn ($unaccepted) => Checkoutable::fromAcceptance($unaccepted));
$rows = [];
$header = [
trans('general.date'),
trans('general.type'),
trans('admin/companies/table.title'),
trans('general.category'),
trans('admin/hardware/form.model'),
trans('admin/hardware/form.name'),
trans('general.name'),
trans('admin/hardware/table.asset_tag'),
trans('admin/hardware/table.checkoutto'),
];
@@ -1253,16 +1306,19 @@ class ReportsController extends Controller
$header = array_map('trim', $header);
$rows[] = implode(',', $header);
foreach ($assetsForReport as $item) {
foreach ($itemsForReport as $item) {
if ($item['assetItem'] != null){
if ($item != null){
$row = [ ];
$row[] = str_replace(',', '', e($item['assetItem']->model->category->name));
$row[] = str_replace(',', '', e($item['assetItem']->model->name));
$row[] = str_replace(',', '', e($item['assetItem']->name));
$row[] = str_replace(',', '', e($item['assetItem']->asset_tag));
$row[] = str_replace(',', '', e(($item['acceptance']->assignedTo) ? $item['acceptance']->assignedTo->display_name : trans('admin/reports/general.deleted_user')));
$row[] = str_replace(',', '', $item->acceptance->created_at);
$row[] = str_replace(',', '', $item->type);
$row[] = str_replace(',', '', $item->plain_text_company);
$row[] = str_replace(',', '', $item->plain_text_category);
$row[] = str_replace(',', '', $item->plain_text_model);
$row[] = str_replace(',', '', $item->plain_text_name);
$row[] = str_replace(',', '', $item->asset_tag);
$row[] = str_replace(',', '', ($item->acceptance->assignedto) ? $item->acceptance->assignedto->display_name : trans('admin/reports/general.deleted_user'));
$rows[] = implode(',', $row);
}
}
@@ -4,6 +4,7 @@ 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\Actionlog;
use App\Models\Asset;
@@ -201,7 +202,7 @@ class ViewAssetsController extends Controller
if (($item_request = $item->isRequestedBy($user)) || $cancel_by_admin) {
$item->cancelRequest($requestingUser);
$data['item_quantity'] = ($item_request) ? $item_request->qty : 1;
$logaction->logaction('request_canceled');
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$settings->notify(new RequestAssetCancelation($data));
+8 -2
View File
@@ -9,6 +9,7 @@ use Carbon\Carbon;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use App\Enums\ActionType;
/**
* Model for the Actionlog (the table that keeps a historical log of
@@ -335,9 +336,12 @@ class Actionlog extends SnipeModel
* @since [v3.0]
* @return bool
*/
public function logaction($actiontype)
public function logaction(string|ActionType $actiontype)
{
$this->action_type = $actiontype;
if (is_string($actiontype)) {
$actiontype = ActionType::from($actiontype);
}
$this->action_type = $actiontype->value;
$this->remote_ip = request()->ip();
$this->user_agent = request()->header('User-Agent');
$this->action_source = $this->determineActionSource();
@@ -518,6 +522,8 @@ class Actionlog extends SnipeModel
return 'private_uploads/locations/'.$this->filename;
case Maintenance::class:
return 'private_uploads/maintenances/'.$this->filename;
case Supplier::class:
return 'private_uploads/suppliers/'.$this->filename;
case User::class:
return 'private_uploads/users/'.$this->filename;
default:
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
class Checkoutable
{
public function __construct(
public int $acceptance_id,
public string $company,
public string $category,
public string $model,
public string $asset_tag,
public string $name,
public string $type,
public object $acceptance,
public object $assignee,
public readonly string $plain_text_category,
public readonly string $plain_text_model,
public readonly string $plain_text_name,
public readonly string $plain_text_company,
){}
public static function fromAcceptance(CheckoutAcceptance $unaccepted): self
{
$unaccepted_row = $unaccepted->checkoutable;
$acceptance = $unaccepted;
$assignee = $acceptance->assignedTo;
$company = optional($unaccepted_row->company)->present()?->nameUrl() ?? '';
$category = $model = $name = $tag = '';
$type = $acceptance->checkoutable_item_type ?? '';
if($unaccepted_row instanceof Asset){
$category = optional($unaccepted_row->model?->category?->present())->nameUrl() ?? '';
$model = optional($unaccepted_row->present())->modelUrl() ?? '';
$name = optional($unaccepted_row->present())->nameUrl() ?? '';
$tag = (string) ($unaccepted_row->asset_tag ?? '');
}
if($unaccepted_row instanceof Accessory){
$category = optional($unaccepted_row->category?->present())->nameUrl() ?? '';
$model = $unaccepted_row->model_number ?? '';
$name = optional($unaccepted_row->present())->nameUrl() ?? '';
}
if($unaccepted_row instanceof LicenseSeat){
$category = optional($unaccepted_row->license->category?->present())->nameUrl() ?? '';
$company = optional($unaccepted_row->license->company?->present())?->nameUrl() ?? '';
$model = '';
$name = $unaccepted_row->license->present()->nameUrl() ?? '';
}
if($unaccepted_row instanceof Consumable){
$category = optional($unaccepted_row->category?->present())->nameUrl() ?? '';
$model = $unaccepted_row->model_number ?? '';
$name = $unaccepted_row?->present()?->nameUrl() ?? '';
}
if($unaccepted_row instanceof Component){
$category = optional($unaccepted_row->category?->present())->nameUrl() ?? '';
$model = $unaccepted_row->model_number ?? '';
$name = $unaccepted_row?->present()?->nameUrl() ?? '';
}
return new self(
acceptance_id: $acceptance->id,
company: $company,
category: $category,
model: $model,
asset_tag: $tag,
name: $name,
type: $type,
acceptance: $acceptance,
assignee: $assignee,
//plain text for CSVs
plain_text_category: ($unaccepted_row->model?->category?->name ?? $unaccepted_row->license->category?->name ?? $unaccepted_row->category?->name ?? ''),
plain_text_model: ($unaccepted_row->model?->name ?? $unaccepted_row->model_number ?? ''),
plain_text_name: ($unaccepted_row->name ?? $unaccepted_row->license?->name ?? ''),
plain_text_company: ($unaccepted_row->company)->name ?? $unaccepted_row->license->company?->name ?? '',
);
}
}
+13 -1
View File
@@ -275,7 +275,19 @@ class Component extends SnipeModel
{
return $this->category?->checkin_email;
}
/**
* Get the list of checkouts for this License
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v2.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function checkouts()
{
return $this->assetlog()->where('action_type', '=', 'checkout')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* Check how many items within a component are remaining
+14
View File
@@ -317,6 +317,20 @@ class Consumable extends SnipeModel
return $this->purchase_cost !== null ? $this->qty * $this->purchase_cost : null;
}
/**
* Get the list of checkouts for this consumable
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v2.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function checkouts()
{
return $this->assetlog()->where('action_type', '=', 'checkout')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* -----------------------------------------------
* BEGIN MUTATORS
+6 -1
View File
@@ -416,7 +416,12 @@ class License extends Depreciable
}
return false;
}
public function checkouts()
{
return $this->assetlog()->where('action_type', '=', 'checkout')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* Determine whether the user should be required to accept the license
*
+24
View File
@@ -135,7 +135,31 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild
return false;
}
/**
* Get the list of checkouts for this License
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v2.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function checkouts()
{
return $this->assetlog()->where('action_type', '=', 'checkout')
->orderBy('created_at', 'desc')
->withTrashed();
}
/**
* Establishes the license -> action logs relationship
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v3.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
*/
public function assetlog()
{
return $this->hasMany(Actionlog::class, 'item_id')->where('item_type', self::class)->orderBy('created_at', 'desc')->withTrashed();
}
/**
* Query builder scope to order on department
*
+4
View File
@@ -3,15 +3,18 @@
namespace App\Models;
use App\Http\Traits\UniqueUndeletedTrait;
use App\Models\Traits\HasUploads;
use App\Models\Traits\Searchable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Watson\Validating\ValidatingTrait;
use \Illuminate\Database\Eloquent\Relations\Relation;
use App\Models\Traits\Loggable;
class Supplier extends SnipeModel
{
use HasFactory;
use SoftDeletes;
use HasUploads;
protected $table = 'suppliers';
@@ -42,6 +45,7 @@ class Supplier extends SnipeModel
use ValidatingTrait;
use UniqueUndeletedTrait;
use Searchable;
use Loggable;
/**
* The attributes that should be included when searching the model.
+1 -1
View File
@@ -102,7 +102,7 @@ class ActionlogPresenter extends Presenter
return 'fa-solid fa-rotate-right';
}
if ($this->action_type == 'note_added') {
if ($this->action_type == 'note added') {
return 'fas fa-sticky-note';
}
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
/**
* We actually *do* have an index on action_type, so this query shouldn't take too terribly long. I don't know
* that we have a ton of people canceling requests (and only certain types of request-cancellation use this
* erroneous action_type), so I feel pretty comfortable with this fixup. Fingers crossed!
* */
DB::table('action_logs')->where('action_type', 'request_canceled')->update(['action_type' => 'request canceled']);
}
/**
* Reverse the migrations.
*/
public function down(): void
{
// no down migration for this one
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('suppliers', function (Blueprint $table) {
$table->text('notes')->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('text', function (Blueprint $table) {
//
});
}
};
@@ -49,54 +49,81 @@
}'>
<thead>
<tr role="row">
<th class="col-sm-1" data-searchable="false" data-field="created_at" data-sortable="true">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-field="created_at" data-searchable="false" data-sortable="true">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.type') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/companies/table.title') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.category') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/form.model') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/form.name') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.name') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/table.asset_tag') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/table.checkoutto') }}</th>
<th class="col-md-1"><span class="line"></span>{{ trans('table.actions') }}</th>
</tr>
</thead>
<tbody>
@if ($assetsForReport)
@foreach ($assetsForReport as $item)
@if ($item['assetItem'])
<tr @if($item['acceptance']->trashed()) style="text-decoration: line-through" @endif>
<td>{{ Helper::getFormattedDateObject($item['acceptance']->created_at, 'datetime', false) }}</td>
<td>{{ ($item['assetItem']->company) ? $item['assetItem']->company->name : '' }}</td>
<td>{!! $item['assetItem']->model->category->present()->nameUrl() !!}</td>
<td>{!! $item['assetItem']->present()->modelUrl() !!}</td>
<td>{!! $item['assetItem']->present()->nameUrl() !!}</td>
<td>{{ $item['assetItem']->asset_tag }}</td>
<td @if($item['acceptance']->assignedTo === null || $item['acceptance']->assignedTo->trashed()) style="text-decoration: line-through" @endif>{!! ($item['acceptance']->assignedTo) ? $item['acceptance']->assignedTo->present()->nameUrl() : trans('admin/reports/general.deleted_user') !!}</td>
<td class="white-space: nowrap;">
<nobr>
@if(!$item['acceptance']->trashed())
<form method="post" class="white-space: nowrap;" action="{{ route('reports/unaccepted_assets_sent_reminder') }}">
@if (($item['acceptance']->assignedTo) && ($item['acceptance']->assignedTo->email))
@csrf
<input type="hidden" name="acceptance_id" value="{{ $item['acceptance']->id }}">
<button class="btn btn-sm btn-warning" data-tooltip="true" data-title="{{ trans('admin/reports/general.send_reminder') }}">
<i class="fa fa-repeat" aria-hidden="true"></i>
</button>
@else
<span data-tooltip="true" data-title="{{ trans('admin/reports/general.cannot_send_reminder') }}">
<a class="btn btn-sm btn-warning disabled" href="#">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
</span>
@endif
<a href="{{ route('reports/unaccepted_assets_delete', ['acceptanceId' => $item['acceptance']->id]) }}" class="btn btn-sm btn-danger delete-asset" data-tooltip="true" data-toggle="modal" data-content="{{ trans('general.delete_confirm', ['item' =>trans('admin/reports/general.acceptance_request')]) }}" data-title="{{ trans('general.delete') }}" onClick="return false;"><i class="fa fa-trash"></i></a>
</form>
@endif
@if ($itemsForReport)
@foreach ($itemsForReport as $item)
<tr @if($item->acceptance->trashed()) style="text-decoration: line-through" @endif>
{{-- Created date --}}
<td>
{{ Helper::getFormattedDateObject($item->acceptance->created_at, 'datetime', false) }}
</td>
{{-- Item Type --}}
<td>{{ $item->type }}</td>
{{-- Company name --}}
<td>{!! $item->company !!}</td>
</nobr>
</td>
</tr>
@endif
@endforeach
{{-- Category --}}
<td>{!! $item->category !!}</td>
{{-- Model --}}
<td>{!! $item->model !!}</td>
{{-- Name --}}
<td>{!! $item->name !!}</td>
{{-- Asset tag or blank --}}
<td>{{ $item->asset_tag }}</td>
{{-- Assigned To (with soft-delete strike if needed) --}}
<td @if(!$item->assignee || (method_exists($item->assignee, 'trashed') && $item->assignee->trashed())) style="text-decoration: line-through" @endif>
{!! $item->assignee
? optional($item->assignee->present())->nameUrl() ?? e($item->assignee->name)
: trans('admin/reports/general.deleted_user') !!}
</td>
{{-- Actions: send reminder / delete --}}
<td class="text-nowrap">
@unless($item->acceptance->trashed())
<form method="post" class="white-space: nowrap;" action="{{ route('reports/unaccepted_assets_sent_reminder') }}">
@csrf
<input type="hidden" name="acceptance_id" value="{{ $item->acceptance_id }}">
@if ($item->assignee && $item->assignee->email)
<button class="btn btn-sm btn-warning" data-tooltip="true" data-title="{{ trans('admin/reports/general.send_reminder') }}">
<i class="fa fa-repeat" aria-hidden="true"></i>
</button>
@else
<span data-tooltip="true" data-title="{{ trans('admin/reports/general.cannot_send_reminder') }}">
<a class="btn btn-sm btn-warning disabled" href="#">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
</span>
@endif
<a href="{{ route('reports/unaccepted_assets_delete', ['acceptanceId' => $item->acceptance_id]) }}"
class="btn btn-sm btn-danger delete-asset"
data-tooltip="true"
data-toggle="modal"
data-content="{{ trans('general.delete_confirm', ['item' => trans('admin/reports/general.acceptance_request')]) }}"
data-title="{{ trans('general.delete') }}"
onClick="return false;">
<i class="fa fa-trash"></i>
</a>
</form>
@endunless
</td>
</tr>
@endforeach
@endif
</tbody>
<tfoot>
+31
View File
@@ -100,6 +100,26 @@
</span>
</a>
</li>
<li>
<a href="#files" data-toggle="tab">
<span class="hidden-lg hidden-md">
<i class="fas fa-barcode fa-2x"></i>
</span>
<span class="hidden-xs hidden-sm">
{{ trans('general.files') }}
{!! ($supplier->uploads->count() > 0 ) ? '<span class="badge badge-secondary">'.number_format($supplier->uploads->count()).'</span>' : '' !!}
</span>
</a>
</li>
<li class="pull-right">
<a href="#" data-toggle="modal" data-target="#uploadFileModal">
<x-icon type="paperclip" />
{{ trans('button.upload') }}
</a>
</li>
</ul>
@@ -243,6 +263,14 @@
</div><!-- /.table-responsive -->
</div><!-- /.tab-pane -->
<div class="tab-pane fade" id="files">
<div class="row">
<div class="col-md-12">
<x-filestable object_type="suppliers" :object="$supplier" />
</div> <!-- /.col-md-12 -->
</div> <!-- /.row -->
</div>
</div><!--/.col-md-9-->
</div><!--/.col-md-9-->
</div><!--/.col-md-9-->
@@ -318,6 +346,9 @@
</div>
</div>
@can('update', \App\Models\Supplier::class)
@include ('modals.upload-file', ['item_type' => 'supplier', 'item_id' => $supplier->id])
@endcan
@stop
@section('moar_scripts')
+4 -4
View File
@@ -1348,7 +1348,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
'index'
]
)->name('api.files.index')
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|users']);
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|suppliers|users']);
// Get a file
Route::get('{object_type}/{id}/files/{file_id}',
@@ -1357,7 +1357,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
'show'
]
)->name('api.files.show')
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|users']);
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|suppliers|users']);
// Upload files(s)
Route::post('{object_type}/{id}/files',
@@ -1366,7 +1366,7 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
'store'
]
)->name('api.files.store')
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|users']);
->where(['object_type' => 'accessories|audits|assets|components|consumables|hardware|licenses|locations|maintenances|models|suppliers|users']);
// Delete files(s)
Route::delete('{object_type}/{id}/files/{file_id}/delete',
@@ -1375,6 +1375,6 @@ Route::group(['prefix' => 'v1', 'middleware' => ['api', 'api-throttle:api']], fu
'destroy'
]
)->name('api.files.destroy')
->where(['object_type' => 'accessories|assets|components|consumables|hardware|licenses|locations|maintenances|models|users']);
->where(['object_type' => 'accessories|assets|components|consumables|hardware|licenses|locations|maintenances|models|suppliers|users']);
}); // end API routes
+3 -3
View File
@@ -716,7 +716,7 @@ Route::group(['middleware' => 'web'], function () {
'show'
]
)->name('ui.files.show')
->where(['object_type' => 'assets|audits|maintenances|hardware|models|users|locations|accessories|consumables|licenses|components']);
->where(['object_type' => 'assets|audits|maintenances|hardware|models|users|locations|accessories|consumables|licenses|suppliers|components']);
// Upload files(s)
Route::post('{object_type}/{id}/files',
@@ -725,7 +725,7 @@ Route::group(['middleware' => 'web'], function () {
'store'
]
)->name('ui.files.store')
->where(['object_type' => 'assets|audits|maintenances|hardware|models|users|locations|accessories|consumables|licenses|components']);
->where(['object_type' => 'assets|audits|maintenances|hardware|models|users|locations|accessories|consumables|licenses|suppliers|components']);
// Delete files(s)
Route::delete('{object_type}/{id}/files/{file_id}/delete',
@@ -734,7 +734,7 @@ Route::group(['middleware' => 'web'], function () {
'destroy'
]
)->name('ui.files.destroy')
->where(['object_type' => 'assets|maintenances|hardware|models|users|locations|accessories|consumables|licenses|components']);
->where(['object_type' => 'assets|maintenances|hardware|models|users|locations|accessories|consumables|licenses|suppliers|components']);
});
@@ -0,0 +1,2 @@
*
!.gitignore
@@ -2,8 +2,19 @@
namespace Tests\Feature\Notifications\Email;
use App\Mail\CheckoutAccessoryMail;
use App\Mail\CheckoutAssetMail;
use App\Mail\CheckoutComponentMail;
use App\Mail\CheckoutConsumableMail;
use App\Mail\CheckoutLicenseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -86,16 +97,52 @@ class AssetAcceptanceReminderTest extends TestCase
public function testReminderIsSentToUser()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
$checkedOutBy = User::factory()->canViewReports()->create();
$this->actingAs(User::factory()->canViewReports()->create())
->post($this->routeFor($checkoutAcceptance))
$checkoutTypes = [
Asset::class => CheckoutAssetMail::class,
Accessory::class => CheckoutAccessoryMail::class,
LicenseSeat::class => CheckoutLicenseMail::class,
Consumable::class => CheckoutConsumableMail::class,
//for the future its setup for components, but we dont send reminders for components at the moment.
// Component::class => CheckoutComponentMail::class,
];
$assignee = User::factory()->create(['email' => 'test@example.com']);
foreach ($checkoutTypes as $modelClass => $mailable) {
$item = $modelClass::factory()->create();
$acceptance = CheckoutAcceptance::factory()->withoutActionLog()->pending()->create([
'checkoutable_id' => $item->id,
'checkoutable_type' => $modelClass,
'assigned_to_id' => $assignee->id,
]);
if ($modelClass === LicenseSeat::class) {
$logType = License::class;
$logId = $item->license->id;
} else {
$logType = $modelClass;
$logId = $item->id;
}
Actionlog::factory()->create([
'action_type' => 'checkout',
'created_by' => $checkedOutBy->id,
'target_id' => $assignee->id,
'item_type' => $logType,
'item_id' => $logId,
'created_at' => $acceptance->created_at,
]);
$this->actingAs($checkedOutBy)
->post($this->routeFor($acceptance))
->assertRedirect(route('reports/unaccepted_assets'));
}
Mail::assertSent(CheckoutAssetMail::class, 1);
Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) use ($checkoutAcceptance) {
return $mail->hasTo($checkoutAcceptance->assignedTo->email)
&& $mail->hasSubject(trans('mail.unaccepted_asset_reminder'));
Mail::assertSent($mailable, 1);
Mail::assertSent($mailable, function ($mail) use ($assignee) {
return $mail->hasTo($assignee->email);
});
}