diff --git a/.all-contributorsrc b/.all-contributorsrc index 5b730fb705..a277fb8563 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -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" + ] } ] } diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index ad29260375..a2fda56ce8 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -68,7 +68,8 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken | [
Juan Font](https://github.com/juanfont)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=juanfont "Code") | [
Juho Taipale](https://github.com/juhotaipale)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=juhotaipale "Code") | [
Korvin Szanto](https://github.com/KorvinSzanto)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=KorvinSzanto "Code") | [
Lewis Foster](https://lewisfoster.foo/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=sniff122 "Code") | [
Logan Swartzendruber](https://github.com/loganswartz)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=loganswartz "Code") | [
Lorenzo P.](https://github.com/lopezio)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=lopezio "Code") | [
Lukas Jung](https://github.com/m4us1ne)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=m4us1ne "Code") | | [
Ellie](https://leafedfox.xyz/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [
GA Stamper](https://github.com/gastamper)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [
Guillaume Lefranc](https://github.com/gl-pup)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [
Hajo MΓΆller](https://github.com/dasjoe)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [
Istvan Basa](https://github.com/pottom)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [
JJ Asghar](https://jjasghar.github.io/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [
James E. Msenga](https://github.com/JemCdo)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") | | [
Jan Felix Wiebe](https://github.com/jfwiebe)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [
Jo Drexl](https://www.nfon.com/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [
Austin Sasko](https://github.com/austinsasko)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [
Jasson](http://jassoncordones.github.io)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [
Okean](https://github.com/Tinyblargon)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [
Alejandro Medrano](https://www.lst.tfo.upm.es/alejandro-medrano/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [
Lukas Kraic](https://github.com/lukaskraic)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") | -| [
Π“Π΅Ρ€Ρ…Π°Ρ€Π΄ PICCORO Lenz McKAY ](https://github-readme-stats.vercel.app/api?username=mckaygerhard)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [
Johannes Pollitt](https://github.com/FlorestanII)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [
Michael Strobel](https://strobelm.de)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [
Nicky West](http://nickwest.me)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [
akaspeh1](https://github.com/akaspeh1)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | +| [
Π“Π΅Ρ€Ρ…Π°Ρ€Π΄ PICCORO Lenz McKAY ](https://github-readme-stats.vercel.app/api?username=mckaygerhard)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [
Johannes Pollitt](https://github.com/FlorestanII)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [
Michael Strobel](https://strobelm.de)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [
Nicky West](http://nickwest.me)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [
akaspeh1](https://github.com/akaspeh1)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [
Sebastian Marsching](http://sebastian.marsching.com/)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [
Mo](https://github.com/mohammad-ahmadi1)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") | +| [
Owen V. Hayes](https://github.com/MarvelousAnything)
[πŸ’»](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome! diff --git a/app/Console/Commands/SendAcceptanceReminder.php b/app/Console/Commands/SendAcceptanceReminder.php index 67efecbb34..4eb4a5114f 100644 --- a/app/Console/Commands/SendAcceptanceReminder.php +++ b/app/Console/Commands/SendAcceptanceReminder.php @@ -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]; }) diff --git a/app/Enums/ActionType.php b/app/Enums/ActionType.php new file mode 100644 index 0000000000..6798a6b78e --- /dev/null +++ b/app/Enums/ActionType.php @@ -0,0 +1,33 @@ + 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', ]; diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index c25c9d1aac..73fcbef334 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -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); } } diff --git a/app/Http/Controllers/ViewAssetsController.php b/app/Http/Controllers/ViewAssetsController.php index 2b767650ad..06a9d0ce41 100755 --- a/app/Http/Controllers/ViewAssetsController.php +++ b/app/Http/Controllers/ViewAssetsController.php @@ -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)); diff --git a/app/Models/Actionlog.php b/app/Models/Actionlog.php index 786246778c..fba7616496 100755 --- a/app/Models/Actionlog.php +++ b/app/Models/Actionlog.php @@ -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: diff --git a/app/Models/Checkoutable.php b/app/Models/Checkoutable.php new file mode 100644 index 0000000000..9bf091c15f --- /dev/null +++ b/app/Models/Checkoutable.php @@ -0,0 +1,79 @@ +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 ?? '', + ); + } +} diff --git a/app/Models/Component.php b/app/Models/Component.php index 2ad133d674..14c4521fc4 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -275,7 +275,19 @@ class Component extends SnipeModel { return $this->category?->checkin_email; } - + /** + * Get the list of checkouts for this License + * + * @author [A. Gianotto] [] + * @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 diff --git a/app/Models/Consumable.php b/app/Models/Consumable.php index 993c37c96a..78c908177d 100644 --- a/app/Models/Consumable.php +++ b/app/Models/Consumable.php @@ -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] [] + * @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 diff --git a/app/Models/License.php b/app/Models/License.php index 71fa08048b..e241d5fd84 100755 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -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 * diff --git a/app/Models/LicenseSeat.php b/app/Models/LicenseSeat.php index cc13481329..e4e93e7049 100755 --- a/app/Models/LicenseSeat.php +++ b/app/Models/LicenseSeat.php @@ -135,7 +135,31 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild return false; } + /** + * Get the list of checkouts for this License + * + * @author [A. Gianotto] [] + * @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] [] + * @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 * diff --git a/app/Models/Supplier.php b/app/Models/Supplier.php index 2c99330604..25e763c957 100755 --- a/app/Models/Supplier.php +++ b/app/Models/Supplier.php @@ -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. diff --git a/app/Presenters/ActionlogPresenter.php b/app/Presenters/ActionlogPresenter.php index 0f82d1f667..a2dc75113d 100644 --- a/app/Presenters/ActionlogPresenter.php +++ b/app/Presenters/ActionlogPresenter.php @@ -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'; } diff --git a/database/migrations/2025_10_22_144927_migrate_incorrect_action_types.php b/database/migrations/2025_10_22_144927_migrate_incorrect_action_types.php new file mode 100644 index 0000000000..b27ed56ef3 --- /dev/null +++ b/database/migrations/2025_10_22_144927_migrate_incorrect_action_types.php @@ -0,0 +1,31 @@ +where('action_type', 'request_canceled')->update(['action_type' => 'request canceled']); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // no down migration for this one + } +}; diff --git a/database/migrations/2025_11_10_205136_change_suppliers_notes_to_text.php b/database/migrations/2025_11_10_205136_change_suppliers_notes_to_text.php new file mode 100644 index 0000000000..e155623a67 --- /dev/null +++ b/database/migrations/2025_11_10_205136_change_suppliers_notes_to_text.php @@ -0,0 +1,28 @@ +text('notes')->change(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('text', function (Blueprint $table) { + // + }); + } +}; diff --git a/resources/views/reports/unaccepted_assets.blade.php b/resources/views/reports/unaccepted_assets.blade.php index d4e259f02f..86420f790a 100644 --- a/resources/views/reports/unaccepted_assets.blade.php +++ b/resources/views/reports/unaccepted_assets.blade.php @@ -49,54 +49,81 @@ }'> - {{ trans('general.date') }} + {{ 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') }} {{ trans('table.actions') }} - @if ($assetsForReport) - @foreach ($assetsForReport as $item) - @if ($item['assetItem']) - trashed()) style="text-decoration: line-through" @endif> - {{ Helper::getFormattedDateObject($item['acceptance']->created_at, 'datetime', false) }} - {{ ($item['assetItem']->company) ? $item['assetItem']->company->name : '' }} - {!! $item['assetItem']->model->category->present()->nameUrl() !!} - {!! $item['assetItem']->present()->modelUrl() !!} - {!! $item['assetItem']->present()->nameUrl() !!} - {{ $item['assetItem']->asset_tag }} - 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') !!} - - - @if(!$item['acceptance']->trashed()) -
- @if (($item['acceptance']->assignedTo) && ($item['acceptance']->assignedTo->email)) - @csrf - - - @else - - - - - - @endif - -
- @endif + @if ($itemsForReport) + @foreach ($itemsForReport as $item) + acceptance->trashed()) style="text-decoration: line-through" @endif> + {{-- Created date --}} + + {{ Helper::getFormattedDateObject($item->acceptance->created_at, 'datetime', false) }} + + {{-- Item Type --}} + {{ $item->type }} + {{-- Company name --}} + {!! $item->company !!} -
- - - @endif - @endforeach + {{-- Category --}} + {!! $item->category !!} + + {{-- Model --}} + {!! $item->model !!} + + {{-- Name --}} + {!! $item->name !!} + + {{-- Asset tag or blank --}} + {{ $item->asset_tag }} + + {{-- Assigned To (with soft-delete strike if needed) --}} + 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') !!} + + + {{-- Actions: send reminder / delete --}} + + + @unless($item->acceptance->trashed()) +
+ @csrf + + @if ($item->assignee && $item->assignee->email) + + @else + + + + + + @endif + + + +
+ @endunless + + + @endforeach @endif diff --git a/resources/views/suppliers/view.blade.php b/resources/views/suppliers/view.blade.php index bc216a964e..dfee233e1b 100755 --- a/resources/views/suppliers/view.blade.php +++ b/resources/views/suppliers/view.blade.php @@ -100,6 +100,26 @@ + +
  • + + + + + +
  • + +
  • + + + {{ trans('button.upload') }} + +
  • @@ -243,6 +263,14 @@ +
    +
    +
    + +
    +
    +
    + @@ -318,6 +346,9 @@ + @can('update', \App\Models\Supplier::class) + @include ('modals.upload-file', ['item_type' => 'supplier', 'item_id' => $supplier->id]) + @endcan @stop @section('moar_scripts') diff --git a/routes/api.php b/routes/api.php index b057705455..dbb9348f7e 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 diff --git a/routes/web.php b/routes/web.php index 53595cb8a3..47e1c46003 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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']); }); diff --git a/storage/private_uploads/suppliers/.gitignore b/storage/private_uploads/suppliers/.gitignore new file mode 100644 index 0000000000..c96a04f008 --- /dev/null +++ b/storage/private_uploads/suppliers/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore \ No newline at end of file diff --git a/tests/Feature/Notifications/Email/AssetAcceptanceReminderTest.php b/tests/Feature/Notifications/Email/AssetAcceptanceReminderTest.php index deb3e07d2c..04081241c9 100644 --- a/tests/Feature/Notifications/Email/AssetAcceptanceReminderTest.php +++ b/tests/Feature/Notifications/Email/AssetAcceptanceReminderTest.php @@ -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); }); }