From 500cbf5d922549a6f28bafd68fe6d560d2acb39a Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 9 Jul 2025 11:12:18 -0700 Subject: [PATCH 01/11] add component checkout notification, update checkout blade, update listener --- app/Listeners/CheckoutableListener.php | 8 + app/Models/Component.php | 31 ++++ .../CheckoutComponentNotification.php | 164 ++++++++++++++++++ resources/lang/en-US/mail.php | 1 + resources/views/components/checkout.blade.php | 24 +++ 5 files changed, 228 insertions(+) create mode 100644 app/Notifications/CheckoutComponentNotification.php diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 9766c88da1..0fb4abd188 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -25,6 +25,8 @@ use App\Notifications\CheckinAssetNotification; use App\Notifications\CheckinLicenseSeatNotification; use App\Notifications\CheckoutAccessoryNotification; use App\Notifications\CheckoutAssetNotification; +use App\Notifications\CheckoutComponentNotification; +use App\Notifications\CheckoutComponentsNotification; use App\Notifications\CheckoutConsumableNotification; use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; @@ -269,6 +271,9 @@ class CheckoutableListener case LicenseSeat::class: $notificationClass = CheckinLicenseSeatNotification::class; break; +// case Component::class: +// $notificationClass = CheckinComponentNotification::class; +// break; } Log::debug('Notification class: '.$notificationClass); @@ -299,6 +304,9 @@ class CheckoutableListener case LicenseSeat::class: $notificationClass = CheckoutLicenseSeatNotification::class; break; + case Component::class: + $notificationClass = CheckoutComponentNotification::class; + break; } diff --git a/app/Models/Component.php b/app/Models/Component.php index df3289e52f..997579a021 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Helpers\Helper; use App\Models\Traits\HasUploads; use App\Models\Traits\Searchable; use App\Presenters\Presentable; @@ -203,6 +204,36 @@ class Component extends SnipeModel { return $this->belongsTo(\App\Models\Manufacturer::class, 'manufacturer_id'); } + /** + * Determine whether this asset requires acceptance by the assigned user + * + * @author [A. Gianotto] [] + * @since [v4.0] + * @return bool + */ + public function requireAcceptance() + { + return $this->category->require_acceptance; + } + + /** + * Checks for a category-specific EULA, and if that doesn't exist, + * checks for a settings level EULA + * + * @author [A. Gianotto] [] + * @since [v4.0] + * @return string | false + */ + public function getEula() + { + if ($this->category->eula_text) { + return Helper::parseEscapedMarkedown($this->category->eula_text); + } elseif ((Setting::getSettings()->default_eula_text) && ($this->category->use_default_eula == '1')) { + return Helper::parseEscapedMarkedown(Setting::getSettings()->default_eula_text); + } else { + return null; + } + } /** * Establishes the component -> action logs relationship diff --git a/app/Notifications/CheckoutComponentNotification.php b/app/Notifications/CheckoutComponentNotification.php new file mode 100644 index 0000000000..a5e9b79463 --- /dev/null +++ b/app/Notifications/CheckoutComponentNotification.php @@ -0,0 +1,164 @@ +item = $component; + $this->admin = $checkedOutBy; + $this->note = $note; + $this->target = $checkedOutTo; + $this->acceptance = $acceptance; + $this->qty = $component->checkout_qty; + + $this->settings = Setting::getSettings(); + } + + /**` + * Get the notification's delivery channels. + * + * @return array + */ + public function via() + { + $notifyBy = []; + if (Setting::getSettings()->webhook_selected == 'google' && Setting::getSettings()->webhook_endpoint) { + + $notifyBy[] = GoogleChatChannel::class; + } + + if (Setting::getSettings()->webhook_selected == 'microsoft' && Setting::getSettings()->webhook_endpoint) { + + $notifyBy[] = MicrosoftTeamsChannel::class; + } + + if (Setting::getSettings()->webhook_selected == 'slack' || Setting::getSettings()->webhook_selected == 'general' ) { + $notifyBy[] = SlackWebhookChannel::class; + } + + return $notifyBy; + } + + public function toSlack() + { + $target = $this->target; + $admin = $this->admin; + $item = $this->item; + $note = $this->note; + $botname = ($this->settings->webhook_botname) ? $this->settings->webhook_botname : 'Snipe-Bot'; + $channel = ($this->settings->webhook_channel) ? $this->settings->webhook_channel : ''; + + $fields = [ + trans('general.to') => '<'.$target->present()->viewUrl().'|'.$target->present()->fullName().'>', + trans('general.by') => '<'.$admin->present()->viewUrl().'|'.$admin->present()->fullName().'>', + ]; + + if ($item->location) { + $fields[trans('general.location')] = $item->location->name; + } + + if ($item->company) { + $fields[trans('general.company')] = $item->company->name; + } + + return (new SlackMessage) + ->content(':arrow_up: :paperclip: Component Checked Out') + ->from($botname) + ->to($channel) + ->attachment(function ($attachment) use ($item, $note, $admin, $fields) { + $attachment->title(htmlspecialchars_decode($item->present()->name), $item->present()->viewUrl()) + ->fields($fields) + ->content($note); + }); + } + public function toMicrosoftTeams() + { + $target = $this->target; + $admin = $this->admin; + $item = $this->item; + $note = $this->note; + + if(!Str::contains(Setting::getSettings()->webhook_endpoint, 'workflows')) { + return MicrosoftTeamsMessage::create() + ->to($this->settings->webhook_endpoint) + ->type('success') + ->addStartGroupToSection('activityTitle') + ->title(trans('mail.Consumable_checkout_notification')) + ->addStartGroupToSection('activityText') + ->fact(htmlspecialchars_decode($item->present()->name), '', 'activityTitle') + ->fact(trans('mail.Component_checkout_notification')." by ", $admin->present()->fullName()) + ->fact(trans('mail.assigned_to'), $target->present()->fullName()) + ->fact(trans('admin/consumables/general.remaining'), $item->numRemaining()) + ->fact(trans('mail.notes'), $note ?: ''); + } + + $message = trans('mail.Component_checkout_notification'); + $details = [ + trans('mail.assigned_to') => $target->present()->fullName(), + trans('mail.item') => htmlspecialchars_decode($item->present()->name), + trans('mail.Component_checkout_notification').' by' => $admin->present()->fullName(), + trans('admin/consumables/general.remaining') => $item->numRemaining(), + trans('mail.notes') => $note ?: '', + ]; + + return array($message, $details); + } + public function toGoogleChat() + { + $target = $this->target; + $item = $this->item; + $note = $this->note; + + return GoogleChatMessage::create() + ->to($this->settings->webhook_endpoint) + ->card( + Card::create() + ->header( + ''.trans('mail.Component_checkout_notification').'' ?: '', + htmlspecialchars_decode($item->present()->name) ?: '', + ) + ->section( + Section::create( + KeyValue::create( + trans('mail.assigned_to') ?: '', + $target->present()->fullName() ?: '', + trans('admin/consumables/general.remaining').': '.$item->numRemaining(), + ) + ->onClick(route('users.show', $target->id)) + ) + ) + ); + + } +} diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index 6878de1561..d0717ea4ff 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -13,6 +13,7 @@ return [ 'Confirm_consumable_delivery' => 'Consumable delivery confirmation', 'Confirm_license_delivery' => 'License delivery confirmation', 'Consumable_checkout_notification' => 'Consumable checked out', + 'Component_checkout_notification' => 'Component checked out', 'Days' => 'Days', 'Expected_Checkin_Date' => 'An asset checked out to you is due to be checked back in on :date', 'Expected_Checkin_Notification' => 'Reminder: :name checkin deadline approaching', diff --git a/resources/views/components/checkout.blade.php b/resources/views/components/checkout.blade.php index a55ca4b21e..8e554575f5 100644 --- a/resources/views/components/checkout.blade.php +++ b/resources/views/components/checkout.blade.php @@ -41,7 +41,31 @@ @endif + @if ($component->requireAcceptance() || $component->getEula() || ($snipeSettings->webhook_endpoint!='')) +
+
+
+ @if ($component->category->require_acceptance=='1') + + {{ trans('admin/categories/general.required_acceptance') }} +
+ @endif + + @if ($component->getEula()) + + {{ trans('admin/categories/general.required_eula') }} +
+ @endif + + @if ($snipeSettings->webhook_endpoint!='') + + {{ trans('general.webhook_msg_note') }} + @endif +
+
+
+ @endif
From bffb2fe82fbf5667aa6a6a9ffc11b5cf49b503ff Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 9 Jul 2025 11:23:27 -0700 Subject: [PATCH 02/11] checkout notification fires --- app/Listeners/CheckoutableListener.php | 3 +-- app/Notifications/CheckoutComponentNotification.php | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 0fb4abd188..1767d70bbe 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -26,7 +26,6 @@ use App\Notifications\CheckinLicenseSeatNotification; use App\Notifications\CheckoutAccessoryNotification; use App\Notifications\CheckoutAssetNotification; use App\Notifications\CheckoutComponentNotification; -use App\Notifications\CheckoutComponentsNotification; use App\Notifications\CheckoutConsumableNotification; use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; @@ -41,7 +40,7 @@ use Osama\LaravelTeamsNotification\TeamsNotification; class CheckoutableListener { private array $skipNotificationsFor = [ - Component::class, +// Component::class, ]; /** diff --git a/app/Notifications/CheckoutComponentNotification.php b/app/Notifications/CheckoutComponentNotification.php index a5e9b79463..8b4a950f98 100644 --- a/app/Notifications/CheckoutComponentNotification.php +++ b/app/Notifications/CheckoutComponentNotification.php @@ -114,7 +114,7 @@ class CheckoutComponentNotification extends Notification ->to($this->settings->webhook_endpoint) ->type('success') ->addStartGroupToSection('activityTitle') - ->title(trans('mail.Consumable_checkout_notification')) + ->title(trans('mail.Component_checkout_notification')) ->addStartGroupToSection('activityText') ->fact(htmlspecialchars_decode($item->present()->name), '', 'activityTitle') ->fact(trans('mail.Component_checkout_notification')." by ", $admin->present()->fullName()) From 36090bf83e7e4c8d8f8da9b76e56a171863ee6bc Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 9 Jul 2025 11:35:24 -0700 Subject: [PATCH 03/11] checked in notification fires, updated icon translation usage --- app/Listeners/CheckoutableListener.php | 7 +- .../CheckinComponentNotification.php | 166 ++++++++++++++++++ .../CheckoutComponentNotification.php | 4 +- resources/lang/en-US/mail.php | 1 + 4 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 app/Notifications/CheckinComponentNotification.php diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 1767d70bbe..a00cfd00d7 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -22,6 +22,7 @@ use App\Models\Setting; use App\Models\User; use App\Notifications\CheckinAccessoryNotification; use App\Notifications\CheckinAssetNotification; +use App\Notifications\CheckinComponentNotification; use App\Notifications\CheckinLicenseSeatNotification; use App\Notifications\CheckoutAccessoryNotification; use App\Notifications\CheckoutAssetNotification; @@ -270,9 +271,9 @@ class CheckoutableListener case LicenseSeat::class: $notificationClass = CheckinLicenseSeatNotification::class; break; -// case Component::class: -// $notificationClass = CheckinComponentNotification::class; -// break; + case Component::class: + $notificationClass = CheckinComponentNotification::class; + break; } Log::debug('Notification class: '.$notificationClass); diff --git a/app/Notifications/CheckinComponentNotification.php b/app/Notifications/CheckinComponentNotification.php new file mode 100644 index 0000000000..0786b52178 --- /dev/null +++ b/app/Notifications/CheckinComponentNotification.php @@ -0,0 +1,166 @@ +target = $checkedOutTo; + $this->item = $component; + $this->admin = $checkedInBy; + $this->note = $note; + $this->settings = Setting::getSettings(); + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via() + { + $notifyBy = []; + + if (Setting::getSettings()->webhook_selected == 'google' && Setting::getSettings()->webhook_endpoint) { + + $notifyBy[] = GoogleChatChannel::class; + } + if (Setting::getSettings()->webhook_selected == 'microsoft' && Setting::getSettings()->webhook_endpoint) { + + $notifyBy[] = MicrosoftTeamsChannel::class; + } + + if (Setting::getSettings()->webhook_selected == 'slack' || Setting::getSettings()->webhook_selected == 'general' ) { + $notifyBy[] = SlackWebhookChannel::class; + } + + return $notifyBy; + } + + public function toSlack() + { + $target = $this->target; + $admin = $this->admin; + $item = $this->item; + $note = $this->note; + $botname = ($this->settings->webhook_botname) ? $this->settings->webhook_botname : 'Snipe-Bot'; + $channel = ($this->settings->webhook_channel) ? $this->settings->webhook_channel : ''; + + if ($admin) { + $fields = [ + trans('general.from') => '<'.$target->present()->viewUrl().'|'.$target->present()->fullName().'>', + trans('general.by') => '<'.$admin->present()->viewUrl().'|'.$admin->present()->fullName().'>', + ]; + + if ($item->location) { + $fields[trans('general.location')] = $item->location->name; + } + + if ($item->company) { + $fields[trans('general.company')] = $item->company->name; + } + + } else { + $fields = [ + 'To' => '<'.$target->present()->viewUrl().'|'.$target->present()->fullName().'>', + 'By' => 'CLI tool', + ]; + } + + return (new SlackMessage) + ->content(':arrow_down: :package: '.trans('mail.Component_checkin_notification')) + ->from($botname) + ->to($channel) + ->attachment(function ($attachment) use ($item, $note, $admin, $fields) { + $attachment->title(htmlspecialchars_decode($item->present()->name), $item->present()->viewUrl()) + ->fields($fields) + ->content($note); + }); + } + public function toMicrosoftTeams() + { + $target = $this->target; + $admin = $this->admin; + $item = $this->item; + $note = $this->note; + if(!Str::contains(Setting::getSettings()->webhook_endpoint, 'workflows')) { + return MicrosoftTeamsMessage::create() + ->to($this->settings->webhook_endpoint) + ->type('success') + ->addStartGroupToSection('activityTitle') + ->title(trans('mail.Component_checkin_notification')) + ->addStartGroupToSection('activityText') + ->fact(htmlspecialchars_decode($item->present()->name), '', 'header') + ->fact(trans('mail.Component_checkin_notification')." by ", $admin->present()->fullName() ?: 'CLI tool') + ->fact(trans('mail.checkedin_from'), $target->present()->fullName()) + ->fact(trans('admin/consumables/general.remaining'), $item->availCount()->count()) + ->fact(trans('mail.notes'), $note ?: ''); + } + + $message = trans('mail.Component_checkin_notification'); + $details = [ + trans('mail.checkedin_from')=> $target->present()->fullName(), + trans('mail.license_for') => htmlspecialchars_decode($item->present()->name), + trans('mail.Component_checkin_notification')." by " => $admin->present()->fullName() ?: 'CLI tool', + trans('admin/consumables/general.remaining') => $item->availCount()->count(), + trans('mail.notes') => $note ?: '', + ]; + + return array($message, $details); + } + public function toGoogleChat() + { + $target = $this->target; + $item = $this->item; + $note = $this->note; + + return GoogleChatMessage::create() + ->to($this->settings->webhook_endpoint) + ->card( + Card::create() + ->header( + ''.trans('mail.Component_checkin_notification').'' ?: '', + htmlspecialchars_decode($item->present()->name) ?: '', + ) + ->section( + Section::create( + KeyValue::create( + trans('mail.checkedin_from') ?: '', + $target->present()->fullName() ?: '', + trans('admin/consumables/general.remaining').': '.$item->availCount()->count(), + ) + ->onClick(route('components.show', $item->id)) + ) + ) + ); + + } +} diff --git a/app/Notifications/CheckoutComponentNotification.php b/app/Notifications/CheckoutComponentNotification.php index 8b4a950f98..fab303fb52 100644 --- a/app/Notifications/CheckoutComponentNotification.php +++ b/app/Notifications/CheckoutComponentNotification.php @@ -93,7 +93,7 @@ class CheckoutComponentNotification extends Notification } return (new SlackMessage) - ->content(':arrow_up: :paperclip: Component Checked Out') + ->content(':arrow_up: :package: '.trans('mail.Component_checkout_notification')) ->from($botname) ->to($channel) ->attachment(function ($attachment) use ($item, $note, $admin, $fields) { @@ -155,7 +155,7 @@ class CheckoutComponentNotification extends Notification $target->present()->fullName() ?: '', trans('admin/consumables/general.remaining').': '.$item->numRemaining(), ) - ->onClick(route('users.show', $target->id)) + ->onClick(route('api.assets.show', $target->id)) ) ) ); diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index d0717ea4ff..7c82eec29d 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -14,6 +14,7 @@ return [ 'Confirm_license_delivery' => 'License delivery confirmation', 'Consumable_checkout_notification' => 'Consumable checked out', 'Component_checkout_notification' => 'Component checked out', + 'Component_checkin_notification' => 'Component checked in', 'Days' => 'Days', 'Expected_Checkin_Date' => 'An asset checked out to you is due to be checked back in on :date', 'Expected_Checkin_Notification' => 'Reminder: :name checkin deadline approaching', From 8214b11da5e558132493e39a9d20131793fccbe1 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 9 Jul 2025 11:44:53 -0700 Subject: [PATCH 04/11] MS teams fires properly --- app/Notifications/CheckinComponentNotification.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/Notifications/CheckinComponentNotification.php b/app/Notifications/CheckinComponentNotification.php index 0786b52178..457f1b5896 100644 --- a/app/Notifications/CheckinComponentNotification.php +++ b/app/Notifications/CheckinComponentNotification.php @@ -121,16 +121,15 @@ class CheckinComponentNotification extends Notification ->fact(htmlspecialchars_decode($item->present()->name), '', 'header') ->fact(trans('mail.Component_checkin_notification')." by ", $admin->present()->fullName() ?: 'CLI tool') ->fact(trans('mail.checkedin_from'), $target->present()->fullName()) - ->fact(trans('admin/consumables/general.remaining'), $item->availCount()->count()) + ->fact(trans('admin/consumables/general.remaining'), $item->numRemaining()) ->fact(trans('mail.notes'), $note ?: ''); } $message = trans('mail.Component_checkin_notification'); $details = [ trans('mail.checkedin_from')=> $target->present()->fullName(), - trans('mail.license_for') => htmlspecialchars_decode($item->present()->name), trans('mail.Component_checkin_notification')." by " => $admin->present()->fullName() ?: 'CLI tool', - trans('admin/consumables/general.remaining') => $item->availCount()->count(), + trans('admin/consumables/general.remaining') => $item->numRemaining(), trans('mail.notes') => $note ?: '', ]; From 3bbd0fdbcddcf0968c6ea82bf5820a0ada14ee25 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 9 Jul 2025 17:02:51 -0700 Subject: [PATCH 05/11] google notifications fires properly --- app/Listeners/CheckoutableListener.php | 2 +- app/Notifications/CheckinComponentNotification.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index a00cfd00d7..a149bc05a3 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -27,6 +27,7 @@ use App\Notifications\CheckinLicenseSeatNotification; use App\Notifications\CheckoutAccessoryNotification; use App\Notifications\CheckoutAssetNotification; use App\Notifications\CheckoutComponentNotification; +use App\Notifications\CheckoutComponentsNotification; use App\Notifications\CheckoutConsumableNotification; use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; @@ -147,7 +148,6 @@ class CheckoutableListener $shouldSendEmailToUser = $this->checkoutableCategoryShouldSendEmail($event->checkoutable); $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress(); $shouldSendWebhookNotification = $this->shouldSendWebhookNotification(); - if (!$shouldSendEmailToUser && !$shouldSendEmailToAlertAddress && !$shouldSendWebhookNotification) { return; } diff --git a/app/Notifications/CheckinComponentNotification.php b/app/Notifications/CheckinComponentNotification.php index 457f1b5896..4057c26812 100644 --- a/app/Notifications/CheckinComponentNotification.php +++ b/app/Notifications/CheckinComponentNotification.php @@ -154,7 +154,7 @@ class CheckinComponentNotification extends Notification KeyValue::create( trans('mail.checkedin_from') ?: '', $target->present()->fullName() ?: '', - trans('admin/consumables/general.remaining').': '.$item->availCount()->count(), + trans('admin/consumables/general.remaining').': '.$item->numRemaining(), ) ->onClick(route('components.show', $item->id)) ) From 64d397c3f3a06d42d358979660ce8c07a0bb1f41 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Thu, 10 Jul 2025 11:26:10 -0700 Subject: [PATCH 06/11] add component notification tests --- .../SlackNotificationsUponCheckinTest.php | 38 ++++++++----- .../SlackNotificationsUponCheckoutTest.php | 53 ++++++++++++++----- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckinTest.php b/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckinTest.php index e2a97c1603..d366103d74 100644 --- a/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckinTest.php +++ b/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckinTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature\Notifications\Webhooks; use App\Models\AssetModel; use App\Models\Category; +use App\Notifications\CheckinComponentNotification; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use App\Events\CheckoutableCheckedIn; @@ -96,6 +97,31 @@ class SlackNotificationsUponCheckinTest extends TestCase $this->assertNoSlackNotificationSent(CheckinAssetNotification::class); } + #[DataProvider('assetCheckInTargets')] + public function testComponentCheckinSendsSlackNotificationWhenSettingEnabled($checkoutTarget) + { + $this->settings->enableSlackWebhook(); + + $this->fireCheckInEvent( + Component::factory()->create(), + $checkoutTarget(), + ); + + $this->assertSlackNotificationSent(CheckinComponentNotification::class); + } + + #[DataProvider('assetCheckInTargets')] + public function testComponentCheckinDoesNotSendSlackNotificationWhenSettingDisabled($checkoutTarget) + { + $this->settings->disableSlackWebhook(); + + $this->fireCheckInEvent( + Component::factory()->create(), + $checkoutTarget(), + ); + + $this->assertNoSlackNotificationSent(CheckinComponentNotification::class); + } public function testSlackNotificationIsStillSentWhenCategoryEmailIsNotSetToSendEmails() { @@ -118,18 +144,6 @@ class SlackNotificationsUponCheckinTest extends TestCase $this->assertSlackNotificationSent(CheckinAssetNotification::class); } - public function testComponentCheckinDoesNotSendSlackNotification() - { - $this->settings->enableSlackWebhook(); - - $this->fireCheckInEvent( - Component::factory()->create(), - Asset::factory()->laptopMbp()->create(), - ); - - Notification::assertNothingSent(); - } - #[DataProvider('licenseCheckInTargets')] public function testLicenseCheckinSendsSlackNotificationWhenSettingEnabled($checkoutTarget) { diff --git a/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckoutTest.php b/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckoutTest.php index c9f426dd30..908acddf3b 100644 --- a/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckoutTest.php +++ b/tests/Feature/Notifications/Webhooks/SlackNotificationsUponCheckoutTest.php @@ -4,6 +4,8 @@ namespace Tests\Feature\Notifications\Webhooks; use App\Models\AssetModel; use App\Models\Category; +use App\Notifications\CheckoutComponentNotification; +use Illuminate\Support\Facades\Mail; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use App\Events\CheckoutableCheckedOut; @@ -30,12 +32,13 @@ class SlackNotificationsUponCheckoutTest extends TestCase parent::setUp(); Notification::fake(); + Mail::fake(); } public static function assetCheckoutTargets(): array { return [ - 'Asset checked out to user' => [fn() => User::factory()->create()], + 'Asset checked out to user' => [fn() => User::factory()->create(['email' => null])], 'Asset checked out to asset' => [fn() => Asset::factory()->laptopMbp()->create()], 'Asset checked out to location' => [fn() => Location::factory()->create()], ]; @@ -44,7 +47,7 @@ class SlackNotificationsUponCheckoutTest extends TestCase public static function licenseCheckoutTargets(): array { return [ - 'License checked out to user' => [fn() => User::factory()->create()], + 'License checked out to user' => [fn() => User::factory()->create(['email' => null])], 'License checked out to asset' => [fn() => Asset::factory()->laptopMbp()->create()], ]; } @@ -98,7 +101,41 @@ class SlackNotificationsUponCheckoutTest extends TestCase $this->assertNoSlackNotificationSent(CheckoutAssetNotification::class); } + #[DataProvider('assetCheckoutTargets')] + public function testComponentCheckoutSendsSlackNotificationWhenSettingEnabled($checkoutTarget) + { + $this->settings->enableSlackWebhook(); + $component = Component::factory()->create([ + 'category_id' => Category::factory()->create([ + 'require_acceptance' => false, + 'eula_text' => null, + ]), + ]); + $this->fireCheckOutEvent( + $component, + $checkoutTarget(), + ); + $this->assertSlackNotificationSent(CheckoutComponentNotification::class); + } + + #[DataProvider('assetCheckoutTargets')] + public function testComponentCheckoutDoesNotSendSlackNotificationWhenSettingDisabled($checkoutTarget) + { + $this->settings->disableSlackWebhook(); + $component = Component::factory()->create([ + 'category_id' => Category::factory()->create([ + 'require_acceptance' => false, + 'eula_text' => null, + ]), + ]); + $this->fireCheckOutEvent( + $component, + $checkoutTarget(), + ); + + $this->assertNoSlackNotificationSent(CheckoutComponentNotification::class); + } public function testSlackNotificationIsStillSentWhenCategoryEmailIsNotSetToSendEmails() { $this->settings->enableSlackWebhook(); @@ -120,18 +157,6 @@ class SlackNotificationsUponCheckoutTest extends TestCase $this->assertSlackNotificationSent(CheckoutAssetNotification::class); } - public function testComponentCheckoutDoesNotSendSlackNotification() - { - $this->settings->enableSlackWebhook(); - - $this->fireCheckOutEvent( - Component::factory()->create(), - Asset::factory()->laptopMbp()->create(), - ); - - Notification::assertNothingSent(); - } - public function testConsumableCheckoutSendsSlackNotificationWhenSettingEnabled() { $this->settings->enableSlackWebhook(); From 2244eebc3b59137bef0c192748e7030aa1075b27 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Tue, 15 Jul 2025 11:00:39 -0700 Subject: [PATCH 07/11] add Component Checkout Mail --- app/Listeners/CheckoutableListener.php | 5 +- app/Mail/CheckoutComponentMail.php | 88 +++++++++++++++++++ resources/lang/en-US/mail.php | 1 + .../markdown/checkout-component.blade.php | 51 +++++++++++ routes/web.php | 5 ++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 app/Mail/CheckoutComponentMail.php create mode 100644 resources/views/mail/markdown/checkout-component.blade.php diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index a149bc05a3..8816e39cc8 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -8,6 +8,7 @@ use App\Mail\CheckinLicenseMail; use App\Mail\CheckoutAccessoryMail; use App\Mail\CheckoutAssetMail; use App\Mail\CheckinAssetMail; +use App\Mail\CheckoutComponentMail; use App\Mail\CheckoutConsumableMail; use App\Mail\CheckoutLicenseMail; use App\Models\Accessory; @@ -152,7 +153,8 @@ class CheckoutableListener return; } - if ($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) { + if (($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) && + !($event->checkoutable instanceof Component)) { /** * Send the appropriate notification */ @@ -318,6 +320,7 @@ class CheckoutableListener Asset::class => CheckoutAssetMail::class, LicenseSeat::class => CheckoutLicenseMail::class, Consumable::class => CheckoutConsumableMail::class, + Component::class => CheckoutComponentMail::class, ]; $mailable= $lookup[get_class($event->checkoutable)]; diff --git a/app/Mail/CheckoutComponentMail.php b/app/Mail/CheckoutComponentMail.php new file mode 100644 index 0000000000..44f0d33a0d --- /dev/null +++ b/app/Mail/CheckoutComponentMail.php @@ -0,0 +1,88 @@ +item = $component; + $this->admin = $checkedOutBy; + $this->note = $note; + $this->target = $checkedOutTo; + $this->acceptance = $acceptance; + $this->qty = optional($component->assets->first())->pivot->assigned_qty; + + + $this->settings = Setting::getSettings(); + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + $from = new Address(config('mail.from.address'), config('mail.from.name')); + + return new Envelope( + from: $from, + subject: trans('mail.Confirm_component_delivery'), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + + $eula = $this->item->getEula(); + $req_accept = $this->item->requireAcceptance(); + + $accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance); + + return new Content( + markdown: 'mail.markdown.checkout-component', + with: [ + 'item' => $this->item, + 'admin' => $this->admin, + 'note' => $this->note, + 'target' => $this->target, + 'eula' => $eula, + 'req_accept' => $req_accept, + 'accept_url' => $accept_url, + 'qty' => $this->qty, + ] + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } + public function build() + { + return $this + ->markdown('mail.markdown.checkout-component', $this->viewData); + } +} diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index 7c82eec29d..b45f38fdd9 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -11,6 +11,7 @@ return [ 'Confirm_accessory_delivery' => 'Accessory delivery confirmation', 'Confirm_asset_delivery' => 'Asset delivery confirmation', 'Confirm_consumable_delivery' => 'Consumable delivery confirmation', + 'Confirm_component_delivery' => 'Component delivery confirmation', 'Confirm_license_delivery' => 'License delivery confirmation', 'Consumable_checkout_notification' => 'Consumable checked out', 'Component_checkout_notification' => 'Component checked out', diff --git a/resources/views/mail/markdown/checkout-component.blade.php b/resources/views/mail/markdown/checkout-component.blade.php new file mode 100644 index 0000000000..c3359638aa --- /dev/null +++ b/resources/views/mail/markdown/checkout-component.blade.php @@ -0,0 +1,51 @@ +@component('mail::message') +# {{ trans('mail.hello') }} {{ $target->assignedto->present()->fullName() }}, + +{{ trans('mail.new_item_checked') }} + + +@component('mail::table') +| | | +| ------------- | ------------- | +@if (isset($checkout_date)) +| **{{ trans('mail.checkout_date') }}** | {{ $checkout_date }} | +@endif +| **{{ trans('general.component') }}** | {{ $item->name }} | +@if (isset($qty)) +| **{{ trans('general.qty') }}** | {{ $qty }} | +@endif +@if (isset($item->manufacturer)) +| **{{ trans('general.manufacturer') }}** | {{ $item->manufacturer->name }} | +@endif +@if ($note) +| **{{ trans('mail.additional_notes') }}** | {{ $note }} | +@endif +@if ($admin) +| **{{ trans('general.administrator') }}** | {{ $admin->present()->fullName() }} | +@endif +@endcomponent + +@if (($req_accept == 1) && ($eula!='')) +{{ trans('mail.read_the_terms_and_click') }} +@elseif (($req_accept == 1) && ($eula=='')) +{{ trans('mail.click_on_the_link_asset') }} +@elseif (($req_accept == 0) && ($eula!='')) +{{ trans('mail.read_the_terms') }} +@endif + +@if ($eula) +@component('mail::panel') +{!! $eula !!} +@endcomponent +@endif + +@if ($req_accept == 1) +**[✔ {{ trans('mail.i_have_read') }}]({{ $accept_url }})** +@endif + + +{{ trans('mail.best_regards') }} + +{{ $snipeSettings->site_name }} + +@endcomponent diff --git a/routes/web.php b/routes/web.php index 9ce977309d..40a1d163ec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -52,7 +52,12 @@ Route::group(['middleware' => 'auth'], function () { [LabelsController::class, 'show'] )->where('labelName', '.*')->name('labels.show'); + Route::get('/test-email', function () { + $mailable = new \App\Mail\CheckoutComponentMail( + ); + return $mailable->render(); // dumps HTML + }); /* * Manufacturers */ From f130186b3785cfb3c517f7104d8d69f6a26ba45b Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Tue, 15 Jul 2025 11:56:34 -0700 Subject: [PATCH 08/11] add Component Checkin Mail --- app/Listeners/CheckoutableListener.php | 10 +-- app/Mail/CheckinComponentMail.php | 71 +++++++++++++++++++ app/Models/Component.php | 14 ++++ resources/lang/en-US/mail.php | 1 + .../mail/markdown/checkin-component.blade.php | 25 +++++++ .../markdown/checkout-component.blade.php | 1 - 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 app/Mail/CheckinComponentMail.php create mode 100644 resources/views/mail/markdown/checkin-component.blade.php diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 8816e39cc8..7e289b19e6 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -4,6 +4,7 @@ namespace App\Listeners; use App\Events\CheckoutableCheckedOut; use App\Mail\CheckinAccessoryMail; +use App\Mail\CheckinComponentMail; use App\Mail\CheckinLicenseMail; use App\Mail\CheckoutAccessoryMail; use App\Mail\CheckoutAssetMail; @@ -28,7 +29,6 @@ use App\Notifications\CheckinLicenseSeatNotification; use App\Notifications\CheckoutAccessoryNotification; use App\Notifications\CheckoutAssetNotification; use App\Notifications\CheckoutComponentNotification; -use App\Notifications\CheckoutComponentsNotification; use App\Notifications\CheckoutConsumableNotification; use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; @@ -153,8 +153,7 @@ class CheckoutableListener return; } - if (($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) && - !($event->checkoutable instanceof Component)) { + if ($shouldSendEmailToUser || $shouldSendEmailToAlertAddress) { /** * Send the appropriate notification */ @@ -333,8 +332,8 @@ class CheckoutableListener Accessory::class => CheckinAccessoryMail::class, Asset::class => CheckinAssetMail::class, LicenseSeat::class => CheckinLicenseMail::class, + Component::class => CheckinComponentMail::class, ]; - $mailable= $lookup[get_class($event->checkoutable)]; return new $mailable($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note); @@ -480,7 +479,8 @@ class CheckoutableListener return match (true) { $checkoutable instanceof Asset => $checkoutable->model->category, $checkoutable instanceof Accessory, - $checkoutable instanceof Consumable => $checkoutable->category, + $checkoutable instanceof Consumable, + $checkoutable instanceof Component => $checkoutable->category, $checkoutable instanceof LicenseSeat => $checkoutable->license->category, }; } diff --git a/app/Mail/CheckinComponentMail.php b/app/Mail/CheckinComponentMail.php new file mode 100644 index 0000000000..5b62a2c4b8 --- /dev/null +++ b/app/Mail/CheckinComponentMail.php @@ -0,0 +1,71 @@ +item = $component; + $this->target = $checkedOutTo; + $this->admin = $checkedInby; + $this->note = $note; + $this->settings = Setting::getSettings(); + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + $from = new Address(config('mail.from.address'), config('mail.from.name')); + + return new Envelope( + from: $from, + subject: trans('mail.Confirm_component_checkin'), + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + markdown: 'mail.markdown.checkin-component', + with: [ + 'item' => $this->item, + 'admin' => $this->admin, + 'note' => $this->note, + 'target' => $this->target, + ] + ); + } + + /** + * Get the attachments for the message. + * + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/Component.php b/app/Models/Component.php index 997579a021..63ed623012 100644 --- a/app/Models/Component.php +++ b/app/Models/Component.php @@ -9,6 +9,7 @@ use App\Presenters\Presentable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Storage; use Watson\Validating\ValidatingTrait; /** @@ -278,6 +279,19 @@ class Component extends SnipeModel } + /** + * Determine whether to send a checkin/checkout email based on + * asset model category + * + * @author [A. Gianotto] [] + * @since [v4.0] + * @return bool + */ + public function checkin_email() + { + return $this->category?->checkin_email; + } + /** * Check how many items within a component are remaining diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index b45f38fdd9..c38932d6fc 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -8,6 +8,7 @@ return [ 'Asset_Checkout_Notification' => 'Asset checked out', 'Confirm_Accessory_Checkin' => 'Accessory checkin confirmation', 'Confirm_Asset_Checkin' => 'Asset checkin confirmation', + 'Confirm_component_checkin' => 'Component checkin confirmation', 'Confirm_accessory_delivery' => 'Accessory delivery confirmation', 'Confirm_asset_delivery' => 'Asset delivery confirmation', 'Confirm_consumable_delivery' => 'Consumable delivery confirmation', diff --git a/resources/views/mail/markdown/checkin-component.blade.php b/resources/views/mail/markdown/checkin-component.blade.php new file mode 100644 index 0000000000..70442f5e3e --- /dev/null +++ b/resources/views/mail/markdown/checkin-component.blade.php @@ -0,0 +1,25 @@ +@component('mail::message') +# {{ trans('mail.hello') }} {{ $target->assignedto->present()->fullName() }}, + +{{ trans('mail.the_following_item') }} + +@component('mail::table') +| | | +| ------------- | ------------- | +| **{{ trans('general.component') }}** | {{ $item->name }} | +@if (isset($item->manufacturer)) +| **{{ trans('general.manufacturer') }}** | {{ $item->manufacturer->name }} | +@endif +@if ($admin) +| **{{ trans('general.administrator') }}** | {{ $admin->present()->fullName() }} | +@endif +@if ($note) +| **{{ trans('mail.additional_notes') }}** | {{ $note }} | +@endif +@endcomponent + +{{ trans('mail.best_regards') }} + +{{ $snipeSettings->site_name }} + +@endcomponent diff --git a/resources/views/mail/markdown/checkout-component.blade.php b/resources/views/mail/markdown/checkout-component.blade.php index c3359638aa..0a26b57512 100644 --- a/resources/views/mail/markdown/checkout-component.blade.php +++ b/resources/views/mail/markdown/checkout-component.blade.php @@ -3,7 +3,6 @@ {{ trans('mail.new_item_checked') }} - @component('mail::table') | | | | ------------- | ------------- | From 214757ab0b08cfe607e132fafc86446a873f08fc Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Tue, 15 Jul 2025 12:04:36 -0700 Subject: [PATCH 09/11] fix mailable --- app/Mail/CheckoutComponentMail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Mail/CheckoutComponentMail.php b/app/Mail/CheckoutComponentMail.php index 44f0d33a0d..461c81ed5d 100644 --- a/app/Mail/CheckoutComponentMail.php +++ b/app/Mail/CheckoutComponentMail.php @@ -26,7 +26,7 @@ class CheckoutComponentMail extends Mailable $this->note = $note; $this->target = $checkedOutTo; $this->acceptance = $acceptance; - $this->qty = optional($component->assets->first())->pivot->assigned_qty; + $this->qty = optional(optional($component->assets->first())->pivot)->assigned_qty; $this->settings = Setting::getSettings(); From f62b5df5669a80e6ed05760e02590b036a9e0f3d Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Tue, 15 Jul 2025 15:40:21 -0700 Subject: [PATCH 10/11] use ternaries instead of optionals --- app/Mail/CheckoutComponentMail.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/Mail/CheckoutComponentMail.php b/app/Mail/CheckoutComponentMail.php index 461c81ed5d..46f2a317db 100644 --- a/app/Mail/CheckoutComponentMail.php +++ b/app/Mail/CheckoutComponentMail.php @@ -26,8 +26,7 @@ class CheckoutComponentMail extends Mailable $this->note = $note; $this->target = $checkedOutTo; $this->acceptance = $acceptance; - $this->qty = optional(optional($component->assets->first())->pivot)->assigned_qty; - + $this->qty = $component->assets->first()?->pivot?->assigned_qty; $this->settings = Setting::getSettings(); } From fc469707a325a2716915c908d9a3b104450efe04 Mon Sep 17 00:00:00 2001 From: Godfrey M Date: Wed, 16 Jul 2025 10:51:33 -0700 Subject: [PATCH 11/11] clean up --- app/Mail/CheckoutComponentMail.php | 5 ----- .../views/mail/markdown/checkout-component.blade.php | 10 +++++----- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/Mail/CheckoutComponentMail.php b/app/Mail/CheckoutComponentMail.php index 46f2a317db..e914d14196 100644 --- a/app/Mail/CheckoutComponentMail.php +++ b/app/Mail/CheckoutComponentMail.php @@ -79,9 +79,4 @@ class CheckoutComponentMail extends Mailable { return []; } - public function build() - { - return $this - ->markdown('mail.markdown.checkout-component', $this->viewData); - } } diff --git a/resources/views/mail/markdown/checkout-component.blade.php b/resources/views/mail/markdown/checkout-component.blade.php index 0a26b57512..b28d291961 100644 --- a/resources/views/mail/markdown/checkout-component.blade.php +++ b/resources/views/mail/markdown/checkout-component.blade.php @@ -25,16 +25,16 @@ @endcomponent @if (($req_accept == 1) && ($eula!='')) -{{ trans('mail.read_the_terms_and_click') }} + {{ trans('mail.read_the_terms_and_click') }} @elseif (($req_accept == 1) && ($eula=='')) -{{ trans('mail.click_on_the_link_asset') }} + {{ trans('mail.click_on_the_link_asset') }} @elseif (($req_accept == 0) && ($eula!='')) -{{ trans('mail.read_the_terms') }} + {{ trans('mail.read_the_terms') }} @endif @if ($eula) @component('mail::panel') -{!! $eula !!} + {!! $eula !!} @endcomponent @endif @@ -47,4 +47,4 @@ {{ $snipeSettings->site_name }} -@endcomponent +@endcomponent \ No newline at end of file