diff --git a/app/Events/CheckoutablesCheckedOutInBulk.php b/app/Events/CheckoutablesCheckedOutInBulk.php new file mode 100644 index 000000000000..4b75b32145b5 --- /dev/null +++ b/app/Events/CheckoutablesCheckedOutInBulk.php @@ -0,0 +1,24 @@ +authorize('checkout', Asset::class); try { @@ -717,6 +721,15 @@ public function storeCheckout(AssetCheckoutRequest $request) : RedirectResponse }); if (! $errors) { + CheckoutablesCheckedOutInBulk::dispatch( + $assets, + $target, + $admin, + $checkout_at, + $expected_checkin, + e($request->get('note')), + ); + // Redirect to the new asset page return redirect()->to('hardware')->with('success', trans_choice('admin/hardware/message.multi-checkout.success', $asset_ids)); } diff --git a/app/Listeners/CheckoutableListener.php b/app/Listeners/CheckoutableListener.php index 908dd58dfded..ee6902bcc5f0 100644 --- a/app/Listeners/CheckoutableListener.php +++ b/app/Listeners/CheckoutableListener.php @@ -33,6 +33,7 @@ use App\Notifications\CheckoutLicenseSeatNotification; use GuzzleHttp\Exception\ClientException; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Notification; use Exception; @@ -427,13 +428,20 @@ private function newMicrosoftTeamsWebhookEnabled(): bool private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool { + // @todo: update comment /** - * Send an email if any of the following conditions are met: + * Send an email if we didn't get here from a bulk checkout + * and any of the following conditions are met: * 1. The asset requires acceptance * 2. The item has a EULA * 3. The item should send an email at check-in/check-out */ + if (Context::get('action') === 'bulk_asset_checkout') { + // @todo: maybe we should see if there is only one asset being checked out and allow this to proceed if it is? + return false; + } + if ($checkoutable->requireAcceptance()) { return true; } @@ -451,6 +459,10 @@ private function shouldSendCheckoutEmailToUser(Model $checkoutable): bool private function shouldSendEmailToAlertAddress($acceptance = null): bool { + if (Context::get('action') === 'bulk_asset_checkout') { + return false; + } + $setting = Setting::getSettings(); if (!$setting) { diff --git a/app/Listeners/CheckoutablesCheckedOutInBulkListener.php b/app/Listeners/CheckoutablesCheckedOutInBulkListener.php new file mode 100644 index 000000000000..e081d767eeec --- /dev/null +++ b/app/Listeners/CheckoutablesCheckedOutInBulkListener.php @@ -0,0 +1,149 @@ +listen( + CheckoutablesCheckedOutInBulk::class, + CheckoutablesCheckedOutInBulkListener::class + ); + } + + public function handle(CheckoutablesCheckedOutInBulk $event): void + { + $notifiableUser = $this->getNotifiableUser($event); + + $shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($notifiableUser, $event->assets); + $shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($event->assets); + + if ($shouldSendEmailToUser && $notifiableUser) { + try { + Mail::to($notifiableUser)->send(new BulkAssetCheckoutMail( + $event->assets, + $event->target, + $event->admin, + $event->checkout_at, + $event->expected_checkin, + $event->note, + )); + + Log::info('BulkAssetCheckoutMail sent to checkout target'); + } catch (Exception $e) { + Log::debug("Exception caught during BulkAssetCheckoutMail to target: " . $e->getMessage()); + } + } + + if ($shouldSendEmailToAlertAddress && Setting::getSettings()->admin_cc_email) { + try { + Mail::to(Setting::getSettings()->admin_cc_email)->send(new BulkAssetCheckoutMail( + $event->assets, + $event->target, + $event->admin, + $event->checkout_at, + $event->expected_checkin, + $event->note, + )); + + Log::info('BulkAssetCheckoutMail sent to admin_cc_email'); + } catch (Exception $e) { + Log::debug("Exception caught during BulkAssetCheckoutMail to admin_cc_email: " . $e->getMessage()); + } + } + } + + private function shouldSendCheckoutEmailToUser(?User $user, Collection $assets): bool + { + if (!$user->email) { + return false; + } + + if ($this->hasAssetWithEula($assets)) { + return true; + } + + if ($this->hasAssetWithCategorySettingToSendEmail($assets)) { + return true; + } + + return $this->requiresAcceptance($assets); + } + + private function shouldSendEmailToAlertAddress(Collection $assets): bool + { + $setting = Setting::getSettings(); + + if (!$setting) { + return false; + } + + if ($setting->admin_cc_always) { + return true; + } + + if (!$this->requiresAcceptance($assets)) { + return false; + } + + return (bool) $setting->admin_cc_email; + } + + private function hasAssetWithEula(Collection $assets): bool + { + foreach ($assets as $asset) { + if ($asset->getEula()) { + return true; + } + } + + return false; + } + + private function hasAssetWithCategorySettingToSendEmail(Collection $assets): bool + { + foreach ($assets as $asset) { + if ($asset->checkin_email()) { + return true; + } + } + + return false; + } + + private function requiresAcceptance(Collection $assets): bool + { + return (bool) $assets->reduce( + fn($count, $asset) => $count + $asset->requireAcceptance() + ); + } + + private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event) + { + $target = $event->target; + + if ($target instanceof Asset) { + $target->load('assignedTo'); + return $target->assignedto; + } + + if ($target instanceof Location) { + return $target->manager; + } + + return $target; + } +} diff --git a/app/Mail/BulkAssetCheckoutMail.php b/app/Mail/BulkAssetCheckoutMail.php new file mode 100644 index 000000000000..b1a6ff528699 --- /dev/null +++ b/app/Mail/BulkAssetCheckoutMail.php @@ -0,0 +1,128 @@ +requires_acceptance = $this->requiresAcceptance(); + + $this->loadCustomFieldsOnAssets(); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: $this->getSubject(), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.markdown.bulk-asset-checkout-mail', + with: [ + 'introduction' => $this->getIntroduction(), + 'requires_acceptance' => $this->requires_acceptance, + 'requires_acceptance_wording' => $this->getRequiresAcceptanceWording(), + 'acceptance_url' => $this->acceptanceUrl(), + ], + ); + } + + public function attachments(): array + { + return []; + } + + private function getSubject(): string + { + if ($this->assets->count() > 1) { + return ucfirst(trans('general.assets_checked_out_count')); + } + + return trans('mail.Asset_Checkout_Notification', ['tag' => $this->assets->first()->asset_tag]); + } + + private function getIntroduction(): string + { + if ($this->target instanceof Location) { + return trans_choice('mail.new_item_checked_location', $this->assets->count(), ['location' => $this->target->name]); + } + + return trans_choice('mail.new_item_checked', $this->assets->count()); + } + + private function acceptanceUrl() + { + if ($this->assets->count() > 1) { + return route('account.accept'); + } + + return route('account.accept.item', $this->assets->first()); + } + + private function loadCustomFieldsOnAssets(): void + { + $this->assets = $this->assets->map(function (Asset $asset) { + $fields = $asset->model?->fieldset?->fields->filter(function (CustomField $field) { + return $field->show_in_email && !$field->field_encrypted; + }); + + $asset->setRelation('fields', $fields); + + return $asset; + }); + } + + private function requiresAcceptance(): bool + { + return (bool) $this->assets->reduce( + fn($count, $asset) => $count + $asset->requireAcceptance() + ); + } + + private function getRequiresAcceptanceWording(): array + { + if (!$this->requiresAcceptance()) { + return []; + } + + if ($this->assets->count() > 1) { + return [ + // todo: translate + trans_choice('mail.items_checked_out_require_acceptance', $this->assets->count()), + "**[✔ Click here to review the terms of use and accept the items]({$this->acceptanceUrl()})**", + ]; + } + + return [ + // todo: translate + trans_choice('mail.items_checked_out_require_acceptance', $this->assets->count()), + "**[✔ Click here to review the terms of use and accept the item]({$this->acceptanceUrl()})**", + ]; + } +} diff --git a/app/Mail/CheckoutAssetMail.php b/app/Mail/CheckoutAssetMail.php index 324c1c8f29b3..8f7c44c9f738 100644 --- a/app/Mail/CheckoutAssetMail.php +++ b/app/Mail/CheckoutAssetMail.php @@ -14,7 +14,6 @@ use Illuminate\Mail\Mailables\Attachment; use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; -use Illuminate\Notifications\Messages\MailMessage; use Illuminate\Queue\SerializesModels; class CheckoutAssetMail extends Mailable diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index 1f08b445c9ec..9143cdc8f5a0 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -3,6 +3,7 @@ namespace App\Providers; use App\Listeners\CheckoutableListener; +use App\Listeners\CheckoutablesCheckedOutInBulkListener; use App\Listeners\LogListener; use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider; @@ -31,5 +32,6 @@ class EventServiceProvider extends ServiceProvider protected $subscribe = [ LogListener::class, CheckoutableListener::class, + CheckoutablesCheckedOutInBulkListener::class, ]; } diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php index 733c52668ed4..6ebd8e40746d 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -214,4 +214,34 @@ public function doesNotRequireAcceptance() 'require_acceptance' => false, ]); } + + public function doesNotSendCheckinEmail() + { + return $this->state([ + 'checkin_email' => false, + ]); + } + + public function sendsCheckinEmail() + { + return $this->state([ + 'checkin_email' => true, + ]); + } + + public function hasLocalEula() + { + return $this->state([ + 'use_default_eula' => false, + 'eula_text' => 'Some EULA text here', + ]); + } + + public function withNoLocalOrGlobalEula() + { + return $this->state([ + 'use_default_eula' => false, + 'eula_text' => '', + ]); + } } diff --git a/resources/lang/en-US/mail.php b/resources/lang/en-US/mail.php index 707390e4f0ab..92cce47145ca 100644 --- a/resources/lang/en-US/mail.php +++ b/resources/lang/en-US/mail.php @@ -79,6 +79,7 @@ 'new_item_checked' => 'A new item has been checked out under your name, details are below.|:count new items have been checked out under your name, details are below.', 'new_item_checked_with_acceptance' => 'A new item has been checked out under your name that requires acceptance, details are below.|:count new items have been checked out under your name that requires acceptance, details are below.', 'new_item_checked_location' => 'A new item has been checked out to :location, details are below.|:count new items have been checked out to :location, details are below.', + 'items_checked_out_require_acceptance' => 'The checked out item requires acceptance.|One or more items require acceptance.', 'recent_item_checked' => 'An item was recently checked out under your name that requires acceptance, details are below.', 'notes' => 'Notes', 'password' => 'Password', diff --git a/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php b/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php new file mode 100644 index 000000000000..900a0488186a --- /dev/null +++ b/resources/views/mail/markdown/bulk-asset-checkout-mail.blade.php @@ -0,0 +1,71 @@ + + + + +{{ $introduction }} + +@if ($requires_acceptance) +@foreach($requires_acceptance_wording as $line) +{{ $line }}
+@endforeach +@endif + +
+ +@if ((isset($expected_checkin)) && ($expected_checkin!='')) +**{{ trans('mail.expecting_checkin_date') }}**: {{ Helper::getFormattedDateObject($expected_checkin, 'date', false) }} +@endif + +@if ($note) +**{{ trans('mail.additional_notes') }}**: {{ $note }} +@endif + + +| | | +| ------------- | ------------- | +@foreach($assets as $asset) +| **{{ trans('general.asset_tag') }}** | {{ $asset->display_name }}
{{trans('mail.serial').': '.$asset->serial}} | +@if (isset($asset->model?->category)) +| **{{ trans('general.category') }}** | {{ $asset->model->category->name }} | +@endif +@if (isset($asset->manufacturer)) +| **{{ trans('general.manufacturer') }}** | {{ $asset->manufacturer->name }} | +@endif +@if (isset($asset->model)) +| **{{ trans('general.asset_model') }}** | {{ $asset->model->name }} | +@endif +@if ((isset($asset->model?->model_number))) +| **{{ trans('general.model_no') }}** | {{ $asset->model->model_number }} | +@endif +@if (isset($asset->assetstatus)) +| **{{ trans('general.status') }}** | {{ $asset->assetstatus->name }} | +@endif +@if($asset->fields) +@foreach($asset->fields as $field) +@if ($asset->{ $field->db_column_name() } != '') +| **{{ $field->name }}** | {{ $asset->{ $field->db_column_name() } }} | +@endif +@endforeach +@endif +|
|
| +@endforeach +
+ +**{{ trans('general.administrator') }}**: {{ $admin->display_name }} + +{{ trans('mail.best_regards') }}
+ +{{ $snipeSettings->site_name }} +
diff --git a/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php b/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php index 6f6f250b9003..99ea17bb4aca 100644 --- a/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php +++ b/tests/Feature/Checkouts/Ui/BulkAssetCheckoutTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Checkouts\Ui; +use App\Mail\BulkAssetCheckoutMail; use App\Mail\CheckoutAssetMail; use App\Models\Asset; use App\Models\Company; @@ -59,10 +60,16 @@ public function testCanBulkCheckoutAssets() $asset->last_checkout = $checkoutAt; $asset->expected_checkin = $expectedCheckin; $this->assertHasTheseActionLogs($asset, ['create', 'checkout']); //Note: '$this' gets auto-bound in closures, so this does work. + $this->assertDatabaseHas('checkout_acceptances', [ + 'checkoutable_type' => Asset::class, + 'checkoutable_id' => $asset->id, + 'assigned_to_id' => $user->id, + 'qty' => 1, + ]); }); - Mail::assertSent(CheckoutAssetMail::class, 2); - Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) { + Mail::assertNotSent(CheckoutAssetMail::class); + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { return $mail->hasTo('someone@example.com'); }); } diff --git a/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php b/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php new file mode 100644 index 000000000000..9fb6f4554c34 --- /dev/null +++ b/tests/Feature/Notifications/Email/BulkCheckoutEmailTest.php @@ -0,0 +1,249 @@ +settings->disableAdminCC(); + $this->settings->disableAdminCCAlways(); + + $this->assets = Asset::factory()->requiresAcceptance()->count(2)->create(); + $this->assignee = User::factory()->create(['email' => 'someone@example.com']); + } + + public function test_sent_to_user() + { + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_to_location_manager() + { + $manager = User::factory()->create(); + + $this->assignee = Location::factory()->for($manager, 'manager')->create(); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) use ($manager) { + return $mail->hasTo($manager->email); + }); + } + + public function test_sent_to_user_asset_is_checked_out_to() + { + $user = User::factory()->create(); + + $this->assignee = Asset::factory()->assignedToUser($user)->create(); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) use ($user) { + return $mail->hasTo($user->email); + }); + } + + public function test_not_sent_to_user_when_user_does_not_have_email_address() + { + $this->assignee = User::factory()->create(['email' => null]); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + public function test_not_sent_to_user_if_assets_do_not_require_acceptance() + { + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + public function test_sent_when_assets_do_not_require_acceptance_but_have_a_eula() + { + $this->assets = Asset::factory()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->hasLocalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_when_assets_do_not_require_acceptance_or_have_a_eula_but_category_is_set_to_send_email() + { + $this->assets = Asset::factory()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->withNoLocalOrGlobalEula() + ->sendsCheckinEmail() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 1); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + } + + public function test_sent_to_cc_address_when_assets_require_acceptance() + { + $this->assets = Asset::factory()->requiresAcceptance()->count(2)->create(); + + $this->settings->enableAdminCC('cc@example.com')->disableAdminCCAlways(); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, 2); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo($this->assignee->email); + }); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo('cc@example.com'); + }); + } + + public function test_sent_to_cc_address_when_assets_do_not_require_acceptance_or_have_eula_but_admin_cc_always_enabled() + { + $this->settings->enableAdminCC('cc@example.com')->enableAdminCCAlways(); + + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + + Mail::assertSent(BulkAssetCheckoutMail::class, function (BulkAssetCheckoutMail $mail) { + return $mail->hasTo('cc@example.com'); + }); + } + + public function test_not_sent_to_cc_address_if_assets_do_not_require_acceptance() + { + $this->settings->enableAdminCC('cc@example.com')->disableAdminCCAlways(); + + $this->assets = Asset::factory()->doesNotRequireAcceptance()->count(2)->create(); + + $category = Category::factory() + ->doesNotRequireAcceptance() + ->doesNotSendCheckinEmail() + ->withNoLocalOrGlobalEula() + ->create(); + + $this->assets->each(fn($asset) => $asset->model->category()->associate($category)->save()); + + $this->sendRequest(); + + Mail::assertNotSent(CheckoutAssetMail::class); + Mail::assertNotSent(BulkAssetCheckoutMail::class); + } + + private function sendRequest() + { + $assigned = match (get_class($this->assignee)) { + User::class => [ + 'checkout_to_type' => 'user', + 'assigned_user' => $this->assignee->id, + ], + Location::class => [ + 'checkout_to_type' => 'location', + 'assigned_location' => $this->assignee->id, + ], + Asset::class => [ + 'checkout_to_type' => 'asset', + 'assigned_asset' => $this->assignee->id, + ], + default => [], + }; + + $this->actingAs(User::factory()->checkoutAssets()->viewAssets()->create()) + ->followingRedirects() + ->post(route('hardware.bulkcheckout.store'), [ + 'selected_assets' => $this->assets->pluck('id')->toArray(), + 'checkout_at' => now()->subWeek()->format('Y-m-d'), + 'expected_checkin' => now()->addWeek()->format('Y-m-d'), + 'note' => null, + ] + $assigned) + ->assertOk(); + } +} diff --git a/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php b/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php new file mode 100644 index 000000000000..993d33b93257 --- /dev/null +++ b/tests/Feature/Notifications/Webhooks/WebhookNotificationsUponBulkAssetCheckoutTest.php @@ -0,0 +1,39 @@ +settings->enableSlackWebhook(); + + $assets = Asset::factory()->requiresAcceptance()->count(2)->create(); + + $this->actingAs(User::factory()->checkoutAssets()->viewAssets()->create()) + ->followingRedirects() + ->post(route('hardware.bulkcheckout.store'), [ + 'selected_assets' => $assets->pluck('id')->toArray(), + 'checkout_to_type' => 'user', + 'assigned_user' => User::factory()->create(['email' => 'someone@example.com'])->id, + 'assigned_asset' => null, + 'checkout_at' => now()->subWeek()->format('Y-m-d'), + 'expected_checkin' => now()->addWeek()->format('Y-m-d'), + 'note' => null, + ]) + ->assertOk(); + + $this->assertSlackNotificationSent(CheckoutAssetNotification::class); + Notification::assertSentTimes(CheckoutAssetNotification::class, 2); + } +}