Compare commits

...

33 Commits

Author SHA1 Message Date
snipe 240196c3a1 Fixed logging 2026-05-04 11:46:34 +01:00
snipe 409ce69e32 Added some logging 2026-05-04 11:34:04 +01:00
snipe 0bdd3034d1 Pint 2026-05-04 11:00:34 +01:00
snipe f04a41408d Include custom fields in checkout emails (if they’re supposed to be included) 2026-05-04 11:00:27 +01:00
snipe 15effb1974 Formatting changes 2026-04-30 10:34:17 +01:00
snipe ccd60eb6d0 Added signed in place admin info 2026-04-30 10:34:06 +01:00
snipe 2f54f5b051 Fixed docblock 2026-04-30 10:13:48 +01:00
snipe 509584d1ff Make sure we’re logging action_source, IP, etc 2026-04-30 10:13:38 +01:00
snipe 95ac268046 Small cleanups 2026-04-30 09:54:08 +01:00
snipe f4b9736862 Fixed tests 2026-04-30 09:53:52 +01:00
snipe a0bf7a018c Only allow admins/super admins to delete or resend from unaccepted items 2026-04-29 17:18:05 +01:00
snipe ac4975e1d1 Added tests 2026-04-29 17:17:41 +01:00
snipe c7c3b04c5f Deleted old blade 2026-04-29 16:59:34 +01:00
snipe 88f87db4fd Renamed blade 2026-04-29 16:59:16 +01:00
snipe ad0199f662 Updated test strings, added form permission check 2026-04-29 16:47:15 +01:00
snipe 75a276d9fa Moved checkbox 2026-04-29 16:06:44 +01:00
snipe ead0047629 Migration to add qty to consumables_users table 2026-04-29 15:22:03 +01:00
snipe 46f6766bb4 Added qty to signature 2026-04-29 15:21:48 +01:00
snipe 8a470d3ef9 Added method for acceptance on consumable 2026-04-29 15:21:28 +01:00
snipe 5506245959 Fixed breadcrumbs 2026-04-29 15:11:51 +01:00
snipe 0fc175581a Check for app.always_send_email 2026-04-29 15:11:20 +01:00
snipe cd9005f82b RMB for consumables 2026-04-29 15:11:05 +01:00
snipe 24237d4259 Added always_send_email env var 2026-04-29 14:58:05 +01:00
snipe b2d707aaab Pint 2026-04-28 16:25:55 +01:00
snipe a432f23692 Add icons to show visibility 2026-04-28 16:25:48 +01:00
snipe a63b9ec627 Pint 2026-04-28 13:13:39 +01:00
snipe a35820d612 Added assigning user and indicator that it was signed in place 2026-04-28 13:13:31 +01:00
snipe ecf8ce3ec1 Pint 2026-04-28 12:54:34 +01:00
snipe 1908beb671 Check for custom fields with show_in_email = 1, field_encrypted = 0 2026-04-28 12:54:23 +01:00
snipe 1872c6eed9 Merge pull request #18950 from grokability/show-hide-password
🎥 Added password toggle JS/HTML
2026-04-28 10:42:21 +01:00
snipe 53199b9737 Added password toggle JS/HTML 2026-04-28 10:35:36 +01:00
snipe 73861c6a04 Merge pull request #18948 from marcusmoore/fixes/index-history-test
Fixed test name
2026-04-27 22:58:34 +01:00
Marcus Moore e2969dd3e2 Fix filename 2026-04-27 13:59:40 -07:00
39 changed files with 1254 additions and 376 deletions
@@ -6,6 +6,7 @@ use App\Events\CheckoutAccepted;
use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AcceptSignatureRequest;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
@@ -36,7 +37,8 @@ class AcceptanceController extends Controller
*/
public function index(): View
{
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get();
$user = auth()->user();
$acceptances = CheckoutAcceptance::forUser($user)->pending()->get();
return view('account/accept.index', compact('acceptances'));
}
@@ -51,13 +53,13 @@ class AcceptanceController extends Controller
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
return redirect()->route('account.accept')->with('error', trans('general.generic_model_not_found', ['model' => trans('general.accept_eula')]));
}
if (! $acceptance->isPending()) {
@@ -76,7 +78,7 @@ class AcceptanceController extends Controller
}
if (! Company::isCurrentUserHasAccess($acceptance->checkoutable)) {
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
@@ -90,18 +92,12 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function store(Request $request, $id): RedirectResponse
public function store(AcceptSignatureRequest $request, CheckoutAcceptance $acceptance): RedirectResponse
{
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
abort(403, trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$assignedUser = User::find($acceptance->assigned_to_id);
@@ -185,6 +181,33 @@ class AcceptanceController extends Controller
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
}
if ($isSignInPlaceAdminFlow && (! $acceptance->signed_in_place || (int) $acceptance->signed_in_place_admin !== (int) $currentUser->id)) {
$acceptance->forceFill([
'signed_in_place' => true,
'signed_in_place_admin' => $currentUser->id,
])->save();
}
// Determine signed_in_place and admin for PDF/email output
$signedInPlace = $isSignInPlaceAdminFlow ? true : (bool) $acceptance->signed_in_place;
$signedInPlaceAdmin = null;
if ($isSignInPlaceAdminFlow) {
$signedInPlaceAdmin = [
'name' => $currentUser->display_name,
'username' => $currentUser->username,
'email' => $currentUser->email,
];
} elseif ($acceptance->signed_in_place && $acceptance->signed_in_place_admin) {
$admin = User::find($acceptance->signed_in_place_admin);
if ($admin) {
$signedInPlaceAdmin = [
'name' => $admin->display_name,
'username' => $admin->username,
'email' => $admin->email,
];
}
}
// Get the data array ready for the notifications and PDF generation
$data = [
'item_tag' => $item->asset_tag,
@@ -206,21 +229,46 @@ class AcceptanceController extends Controller
'logo' => ($encoded_logo) ?? null,
'date_settings' => $settings->date_display_format,
'qty' => $acceptance->qty ?? 1,
'signed_in_place' => $signedInPlace,
];
if ($signedInPlaceAdmin) {
$data['signed_in_place_admin'] = $signedInPlaceAdmin;
}
// Add custom fields for asset (show_in_email = 1, field_encrypted = 0)
$customFields = [];
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$fields = $item->model->fieldset->fields->where('show_in_email', true)->where('field_encrypted', false);
foreach ($fields as $field) {
$label = $field->name;
$dbColumn = $field->db_column;
$value = $item->$dbColumn;
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $label,
'value' => $value,
];
}
}
}
if (! empty($customFields)) {
$data['custom_fields'] = $customFields;
}
if ($request->input('asset_acceptance') === 'accepted') {
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
Storage::put('private_uploads/eula-pdfs/'.$pdf_filename, $pdf_content);
// Log the acceptance
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
$accept_qty = $request->input('accept_qty', $acceptance->qty ?? 1);
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'), $accept_qty);
$alwaysSendAcceptanceCopy = (bool) (config('app.always_send_email') || config('app.always_send_eula'));
// Send the PDF to the signing user
if (($request->input('send_copy') === '1') && ($assignedUser->email !== '')) {
if (($alwaysSendAcceptanceCopy || ($request->input('send_copy') === '1')) && ($assignedUser->email !== '')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
@@ -25,33 +25,27 @@ class ConsumableCheckoutController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Consumable $consumable): View|RedirectResponse
{
if ($consumable = Consumable::find($id)) {
$this->authorize('checkout', $consumable);
$this->authorize('checkout', $consumable);
// Make sure the category is valid
if ($consumable->category) {
// Make sure the category is valid
if ($consumable->category) {
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
}
// Not found
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.does_not_exist'));
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
}
@@ -68,12 +62,8 @@ class ConsumableCheckoutController extends Controller
*
* @throws AuthorizationException
*/
public function store(Request $request, $consumableId)
public function store(Request $request, Consumable $consumable)
{
if (is_null($consumable = Consumable::with('users')->find($consumableId))) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.not_found'));
}
$this->authorize('checkout', $consumable);
// If the quantity is not present in the request or is not a positive integer, set it to 1
@@ -99,14 +89,14 @@ class ConsumableCheckoutController extends Controller
// Update the consumable data
$consumable->assigned_to = e($request->input('assigned_to'));
for ($i = 0; $i < $quantity; $i++) {
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => e($request->input('assigned_to')),
'note' => $request->input('note'),
]);
}
// Attach the consumable to the user ONCE with the correct qty and note
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => $assigned_to,
'note' => $request->input('note'),
'qty' => $quantity,
]);
$consumable->checkout_qty = $quantity;
+17 -9
View File
@@ -1218,7 +1218,7 @@ class ReportsController extends Controller
->filter(fn ($unaccepted) => $unaccepted->checkoutable)
->map(fn ($unaccepted) => Checkoutable::fromAcceptance($unaccepted));
return view('reports/unaccepted_assets', compact('itemsForReport', 'showDeleted'));
return view('reports/unaccepted_items', compact('itemsForReport', 'showDeleted'));
}
/**
@@ -1230,6 +1230,10 @@ class ReportsController extends Controller
*/
public function sentAssetAcceptanceReminder(Request $request): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
$id = $request->input('acceptance_id');
$query = CheckoutAcceptance::query()
@@ -1251,7 +1255,7 @@ class ReportsController extends Controller
Log::debug('No pending acceptances');
// Redirect to the unaccepted items report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$item = $acceptance->checkoutable;
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
@@ -1263,7 +1267,7 @@ class ReportsController extends Controller
if (is_null($acceptance->created_at)) {
Log::debug('No acceptance created_at');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
} else {
if ($item instanceof LicenseSeat) {
$logItem_res = $item->license->checkouts()->with('adminuser')->where('created_at', '=', $acceptance->created_at)->get();
@@ -1273,18 +1277,18 @@ class ReportsController extends Controller
if ($logItem_res->isEmpty()) {
Log::debug('Acceptance date mismatch');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$logItem = $logItem_res[0];
}
if (is_null($email) || $email === '') {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.no_email'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.no_email'));
}
$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'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.reminder_sent'));
}
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
@@ -1317,17 +1321,21 @@ class ReportsController extends Controller
*/
public function deleteAssetAcceptance($acceptanceId = null): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
// Redirect to the unaccepted assets report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
if ($acceptance->delete()) {
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.acceptance_deleted'));
} else {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.deletion_failed'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.deletion_failed'));
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AcceptSignatureRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$acceptance = $this->route('acceptance');
$user = Auth::user();
if (! $acceptance || ! $user) {
return false;
}
if (is_string($acceptance)) {
$acceptance = CheckoutAcceptance::find($acceptance);
if (! $acceptance) {
return false;
}
}
if (! $user instanceof User) {
return false;
}
// Only allow if the user is the assigned user or sign-in-place admin
$assignedToId = $acceptance->assigned_to_id ?? null;
$isSignInPlaceAdmin = session('sign_in_place_acceptance_id') === $acceptance->id && $user->can('checkout', $acceptance->checkoutable);
return $user->id === $assignedToId || $isSignInPlaceAdmin;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// ...existing validation rules...
];
}
protected function failedAuthorization()
{
$user = Auth::user();
$acceptance = $this->route('acceptance');
// If user is logged in and acceptance exists, treat as business logic error
if ($user && $acceptance) {
$redirectResponse = redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
throw new ValidationException($this->getValidatorInstance(), $redirectResponse);
}
// Otherwise, use default 403
parent::failedAuthorization();
}
}
+74 -39
View File
@@ -78,7 +78,7 @@ class CheckoutableListener
$acceptance = $this->getCheckoutAcceptance($event);
$shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
@@ -175,7 +175,7 @@ class CheckoutableListener
}
$shouldSendEmailToUser = $this->checkoutableCategoryShouldSendEmail($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress();
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress(null, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
@@ -198,33 +198,39 @@ class CheckoutableListener
}
$mailable = $this->getCheckinMailType($event);
$notifiable = $this->getNotifiableUser($event);
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
if (! $mailable) {
Log::debug('No checkin mail type available for checkoutable class: '.get_class($event->checkoutable));
} else {
$notifiable = $this->getNotifiableUser($event);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
}
@@ -392,7 +398,14 @@ class CheckoutableListener
LicenseSeat::class => CheckinLicenseMail::class,
Component::class => CheckinComponentMail::class,
];
$mailable = $lookup[get_class($event->checkoutable)];
$checkoutableClass = get_class($event->checkoutable);
if (! array_key_exists($checkoutableClass, $lookup)) {
return null;
}
$mailable = $lookup[$checkoutableClass];
return new $mailable($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
@@ -466,11 +479,16 @@ class CheckoutableListener
* 1. The asset requires acceptance
* 2. The item has a EULA
* 3. The item should send an email at check-in/check-out
* 4. The config('app.always_send_email') is true
*/
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
}
if (config('app.always_send_email')) {
return true;
}
if ($checkoutable->requireAcceptance()) {
return true;
}
@@ -495,7 +513,7 @@ class CheckoutableListener
return ($acceptance instanceof CheckoutAcceptance) || ! empty($event->checkoutable->getEula());
}
private function shouldSendEmailToAlertAddress($acceptance = null): bool
private function shouldSendEmailToAlertAddress($acceptance = null, ?Model $checkoutable = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
@@ -507,22 +525,39 @@ class CheckoutableListener
return false;
}
if (is_null($acceptance) && ! $setting->admin_cc_always) {
$alertRecipients = $this->getFormattedAlertAddresses((bool) $setting->admin_cc_always);
if (empty($alertRecipients)) {
return false;
}
return (bool) $setting->admin_cc_email;
}
private function getFormattedAlertAddresses(): array
{
$alertAddresses = Setting::getSettings()->admin_cc_email;
if ($alertAddresses !== '') {
return array_filter(array_map('trim', explode(',', $alertAddresses)));
if (is_null($acceptance) && ! $setting->admin_cc_always) {
if (! $checkoutable || ! $this->checkoutableCategoryShouldSendEmail($checkoutable)) {
return false;
}
}
return [];
return true;
}
private function getFormattedAlertAddresses(bool $allowAlertEmailFallback = false): array
{
$setting = Setting::getSettings();
if (! $setting) {
return [];
}
$adminCcAddresses = $setting->admin_cc_email;
$fallbackAlertAddresses = $allowAlertEmailFallback ? $setting->alert_email : null;
$rawAddresses = $adminCcAddresses ?: $fallbackAlertAddresses;
if ($rawAddresses === null || $rawAddresses === '') {
return [];
}
return array_values(array_unique(array_filter(array_map('trim', explode(',', $rawAddresses)))));
}
private function generateEmailRecipients(
@@ -536,7 +571,7 @@ class CheckoutableListener
// if user && cc: to user, cc admin
if ($shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $notifiable;
$cc[] = $this->getFormattedAlertAddresses();
$cc[] = $this->getFormattedAlertAddresses(true);
}
// if user && no cc: to user
@@ -546,7 +581,7 @@ class CheckoutableListener
// if no user && cc: to admin
if (! $shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $this->getFormattedAlertAddresses();
$to[] = $this->getFormattedAlertAddresses(true);
}
return [$to, $cc];
+10 -3
View File
@@ -10,6 +10,7 @@ use App\Events\UserMerged;
use App\Models\Actionlog;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
class LogListener
@@ -59,7 +60,10 @@ class LogListener
$logaction->action_type = 'accepted';
$logaction->action_date = $event->acceptance->accepted_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -79,7 +83,10 @@ class LogListener
$logaction->action_type = 'declined';
$logaction->action_date = $event->acceptance->declined_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -127,7 +134,7 @@ class LogListener
/**
* Register the listeners for the subscriber.
*
* @param Illuminate\Events\Dispatcher $events
* @param Dispatcher $events
*/
public function subscribe($events)
{
+66 -4
View File
@@ -23,6 +23,21 @@ class CheckoutAcceptance extends Model
'alert_on_response_id' => 'integer',
];
protected $fillable = [
'assigned_to_id',
'checkoutable_type',
'checkoutable_id',
'accepted_at',
'declined_at',
'note',
'signature_filename',
'stored_eula',
'stored_eula_file',
'qty',
'signed_in_place',
'signed_in_place_admin',
];
/**
* Get the mail recipient from the config
*
@@ -112,7 +127,7 @@ class CheckoutAcceptance extends Model
*/
public function isCheckedOutTo(User $user)
{
return $this->assignedTo?->is($user);
return $this->assigned_to_id === $user->id;
}
/**
@@ -121,20 +136,27 @@ class CheckoutAcceptance extends Model
* checkout_acceptances table or you'll get an error.
*
* @param string $signature_filename
* @param string|null $eula
* @param string|null $filename
* @param string|null $note
* @param int|null $qty
*/
public function accept($signature_filename, $eula = null, $filename = null, $note = null)
public function accept($signature_filename, $eula = null, $filename = null, $note = null, $qty = null)
{
$this->accepted_at = now();
$this->signature_filename = $signature_filename;
$this->stored_eula = $eula;
$this->stored_eula_file = $filename;
$this->note = $note;
if ($qty !== null) {
$this->qty = $qty;
}
$this->save();
/**
* Update state for the checked out item
*/
$this->checkoutable->acceptedCheckout($this->assignedTo, $signature_filename, $filename);
$this->checkoutable->acceptedCheckout($this->assignedTo, $qty, $note, $signature_filename, $filename);
}
/**
@@ -208,7 +230,7 @@ class CheckoutAcceptance extends Model
$pdf->SetAuthor($data['assigned_to']);
$pdf->SetTitle('Asset Acceptance: '.$data['item_tag']);
$pdf->SetSubject('Asset Acceptance: '.$data['item_tag']);
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos');
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos,'.$data['item_tag'] ?? null.', '.$data['item_name'] ?? null.', '.$data['assigned_to'] ?? null);
$pdf->SetFont('dejavusans', '', 8, '', true);
$pdf->SetPrintHeader(false);
$pdf->SetPrintFooter(false);
@@ -243,6 +265,17 @@ class CheckoutAcceptance extends Model
if ($data['item_serial'] != null) {
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
}
// Render custom fields if present
if (! empty($data['custom_fields']) && is_array($data['custom_fields'])) {
foreach ($data['custom_fields'] as $customField) {
$label = $customField['label'] ?? '';
$value = $customField['value'] ?? '';
if ($label !== '' && $value !== '') {
$pdf->writeHTML(e($label).': '.e($value), true, 0, true, 0, '');
}
}
}
if (($data['qty'] != null) && ($data['qty'] > 1)) {
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
}
@@ -250,6 +283,35 @@ class CheckoutAcceptance extends Model
if ($data['email'] != null) {
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
}
// Add assigning user if present
if (! empty($data['assigning_user'])) {
$assigningUser = $data['assigning_user'];
$assigningUserLine = trans('general.assigned_by').': '.e($assigningUser['name'] ?? $assigningUser['email'] ?? '');
if (! empty($assigningUser['employee_num'])) {
$assigningUserLine .= ' ('.e($assigningUser['employee_num']).')';
}
$pdf->writeHTML($assigningUserLine, true, 0, true, 0, '');
}
// Add signed in place row (always show)
$signedInPlace = ! empty($data['signed_in_place']) && filter_var($data['signed_in_place'], FILTER_VALIDATE_BOOLEAN);
$pdf->writeHTML(trans('general.signed_in_place').': '.($signedInPlace ? trans('general.yes') : trans('general.no')), true, 0, true, 0, '');
// If signed in place, show admin info
if ($signedInPlace && ! empty($data['signed_in_place_admin'])) {
$admin = $data['signed_in_place_admin'];
$adminName = $admin['name'] ?? '';
$adminUsername = $admin['username'] ?? '';
$adminEmail = $admin['email'] ?? '';
$adminDetails = $adminName;
if (! empty($adminUsername)) {
$adminDetails .= ' ('.$adminUsername.')';
}
if (! empty($adminEmail)) {
$adminDetails .= ' <'.$adminEmail.'>';
}
$adminLine = trans('general.signed_in_place_admin', ['admin' => $adminDetails]);
$pdf->writeHTML($adminLine, true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
+79
View File
@@ -495,4 +495,83 @@ class Consumable extends SnipeModel
{
return $query->leftJoin('users as users_sort', 'consumables.created_by', '=', 'users_sort.id')->select('consumables.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order);
}
/**
* Handle logic after a consumable checkout is accepted by the user.
*
* @param string|null $signature
* @param string|null $filename
*/
public function acceptedCheckout(User $acceptedBy, ?int $qty = null, ?string $note = null, $signature = null, $filename = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $acceptedBy->getAssignedItemsWithPendingAcceptance()
->where('item_id', $this->id)
->where('qty', $qty)
->where('item_type', self::class)
->whereNull('declined_at')
->sortByDesc('created_at')
->first();
if ($acceptance) {
if ($qty !== null) {
$acceptance->qty = $qty;
}
if ($note !== null) {
$acceptance->note = $note;
}
$acceptance->save();
}
// Attach the consumable to the user if not already attached
$pivot = $acceptedBy->consumables()->where('consumable_id', $this->id)->first();
if (! $pivot) {
$acceptedBy->consumables()->attach($this->id, [
'created_by' => $acceptance?->created_by ?? null,
]);
}
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Handle logic after a consumable checkout is declined by the user.
*
* @param string|null $signature
*/
public function declinedCheckout(User $declinedBy, $signature = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $declinedBy->acceptances()
->where('item_id', $this->id)
->where('item_type', self::class)
->whereNull('accepted_at')
->latest('created_at')
->first();
$qty = $acceptance?->qty ?? 1;
$note = $acceptance?->note;
// Detach the consumable from the user (if present)
$declinedBy->consumables()->detach($this->id);
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Log an acceptance or decline action for this consumable.
*/
protected function logActionAcceptance(string $actionType, User $user, int $qty, ?string $note = null): void
{
$this->assetlog()->create([
'action_type' => $actionType,
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $this->id,
'item_type' => self::class,
'quantity' => $qty,
'note' => $note,
'created_by' => auth()->id() ?? $user->id,
]);
}
}
@@ -33,6 +33,9 @@ class AcceptanceItemAcceptedNotification extends Notification
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->note = $params['note'] ?? null;
$this->signed_in_place = $params['signed_in_place'] ?? false;
$this->signed_in_place_admin = $params['signed_in_place_admin'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
@@ -76,6 +79,9 @@ class AcceptanceItemAcceptedNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'signed_in_place' => $this->signed_in_place,
'signed_in_place_admin' => $this->signed_in_place_admin,
'custom_fields' => $this->custom_fields,
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
])
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
$this->settings = Setting::getSettings();
$this->file = $params['file'] ?? null;
$this->qty = $params['qty'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
/**
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
'assigned_to' => $this->assigned_to,
'company_name' => $this->company_name,
'qty' => $this->qty,
'custom_fields' => $this->custom_fields,
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
])
->attach($pdf_path)
@@ -32,6 +32,7 @@ class AcceptanceItemDeclinedNotification extends Notification
$this->settings = Setting::getSettings();
$this->qty = $params['qty'] ?? null;
$this->admin = $params['admin'] ?? null;
$this->custom_fields = $params['custom_fields'] ?? [];
}
/**
@@ -74,6 +75,7 @@ class AcceptanceItemDeclinedNotification extends Notification
'company_name' => $this->company_name,
'qty' => $this->qty,
'admin' => $this->admin,
'custom_fields' => $this->custom_fields,
'user' => $this->assigned_to,
'intro_text' => trans('mail.acceptance_declined_greeting', ['user' => $this->assigned_to]),
])
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace App\Presenters;
use App\Models\CustomField;
/**
* Class CustomFieldPresenter
* Handles presentation logic for CustomField, including visibility icons.
*/
final class CustomFieldPresenter extends Presenter
{
private CustomField $field;
public function __construct(CustomField $field)
{
$this->field = $field;
}
/**
* Returns an array of icon HTML for where the field is visible.
*
* @return string[] Array of HTML icon strings
*/
public function visibilityIconsArray(): array
{
$icons = [];
if ($this->field->display_checkout) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkout')).'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted"></i></span>';
}
if ($this->field->display_checkin) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkin')).'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted"></i></span>';
}
if ($this->field->display_audit) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_audit')).'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted"></i></span>';
}
if ($this->field->display_in_user_view) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_in_user_view_table')).'" data-tooltip="true"><i class="fas fa-user text-muted"></i></span>';
}
if ($this->field->show_in_listview) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_listview_short')).'" data-tooltip="true"><i class="fas fa-list text-muted"></i></span>';
}
if ($this->field->show_in_email) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_email_short')).'" data-tooltip="true"><i class="fas fa-envelope text-muted"></i></span>';
}
if ($this->field->show_in_requestable_list) {
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_requestable_list_short')).'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted"></i></span>';
}
return $icons;
}
/**
* Returns the icons as a single HTML string (for backward compatibility)
*/
public function visibilityIcons(): string
{
return implode(' ', $this->visibilityIconsArray());
}
}
+17
View File
@@ -537,4 +537,21 @@ return [
'max_unpaginated_records' => env('MAX_UNPAGINATED', '5000'),
/*
|--------------------------------------------------------------------------
| Always send emails on acceptance/EULA
|--------------------------------------------------------------------------
| This setting allows you to bypass the "email me a copy" checkbox on EULA/item acceptance,
| and forces Snipe-IT to always send email to the accepting user if they have an email address.
*/
'always_send_email' => env('ALWAYS_SEND_EMAIL', false),
/*
|--------------------------------------------------------------------------
| Always Send EULA
|--------------------------------------------------------------------------
| If true, the EULA will always be sent and the checkbox will be hidden.
*/
'always_send_eula' => env('ALWAYS_SEND_EULA', false),
];
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('consumables_users', function (Blueprint $table) {
$table->unsignedInteger('qty')->nullable()->default(1)->after('created_by');
});
}
public function down(): void
{
Schema::table('consumables_users', function (Blueprint $table) {
$table->dropColumn('qty');
});
}
};
@@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('checkout_acceptances', function (Blueprint $table) {
$table->boolean('signed_in_place')->default(false)->after('declined_at');
$table->unsignedBigInteger('signed_in_place_admin')->nullable()->after('signed_in_place');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('checkout_acceptances', function (Blueprint $table) {
$table->dropColumn(['signed_in_place', 'signed_in_place_admin']);
});
}
};
+1 -1
View File
@@ -49,7 +49,7 @@ return [
'asset_already_accepted' => 'This item has already been accepted.',
'accept_or_decline' => 'You must either accept or decline this asset.',
'cannot_delete_yourself' => 'We would feel really bad if you deleted yourself, please reconsider.',
'incorrect_user_accepted' => 'The asset you have attempted to accept was not checked out to you.',
'incorrect_user_accepted' => 'The item you are attempting to accept was not checked out to you.',
'ldap_could_not_connect' => 'Could not connect to the LDAP server. Please check your LDAP server configuration in the LDAP config file. <br>Error from LDAP Server:',
'ldap_could_not_bind' => 'Could not bind to the LDAP server. Please check your LDAP server configuration in the LDAP config file. <br>Error from LDAP Server: ',
'ldap_could_not_search' => 'Could not search the LDAP server. Please check your LDAP server configuration in the LDAP config file. <br>Error from LDAP Server:',
+5 -2
View File
@@ -54,6 +54,7 @@ return [
'avatar_upload' => 'Upload Avatar',
'back' => 'Back',
'bad_data' => 'Nothing found. Maybe bad data?',
'bad_data_or_already_accepted' => 'No corresponding acceptance was found. You might not have permission to perform this action, or it may have already been accepted',
'bulkaudit' => 'Scanner Bulk Audit',
'bulkaudit_status' => 'Audit Status',
'bulk_checkout' => 'Bulk Checkout',
@@ -590,6 +591,7 @@ return [
'address2' => 'Address Line 2',
'import_note' => 'Imported using csv importer',
],
'acceptance_email_always_sent' => 'An email will be sent to the accepting user automatically.',
'remove_customfield_association' => 'Remove this field from the fieldset. This will not delete the custom field, only this field\'s association with this fieldset.',
'checked_out_to_fields' => 'Checked Out To Fields',
'percent_complete' => '% complete',
@@ -748,8 +750,9 @@ return [
],
'months_plural' => '1 month|:count months',
'token_unrevoked' => 'API token reinstated',
'token_revoked' => 'API token revoked',
'assigned_by' => 'Assigned by',
'signed_in_place' => 'Signed in place',
'signed_in_place_admin' => 'Signed in place by :admin',
];
@@ -125,12 +125,18 @@
<div class="row">
<div class="col-md-7">
@if ($acceptance->assignedTo?->email)
<div class="col-md-12" style="display: none;" id="showEmailBox">
<label class="form-control">
<input type="checkbox" value="1" name="send_copy" id="send_copy" checked="checked" aria-label="send_copy">
{{ trans('mail.send_pdf_copy') }} ({{ $acceptance->assignedTo->email }})
</label>
</div>
@if (config('app.always_send_email') || config('app.always_send_eula'))
<div class="col-md-12" id="emailInfoBox">
{{ trans('general.acceptance_email_always_sent') }} ({{ $acceptance->assignedTo->email }})
</div>
@else
<div class="col-md-12" id="showEmailBox">
<label class="form-control">
<input type="checkbox" value="1" name="send_copy" id="send_copy" checked="checked" aria-label="send_copy">
{{ trans('mail.send_pdf_copy') }} ({{ $acceptance->assignedTo->email }})
</label>
</div>
@endif
@endif
</div>
<div class="col-md-5 text-right">
@@ -195,16 +201,16 @@
@endif
$('[name="asset_acceptance"]').on('change', function() {
if ($(this).is(':checked') && $(this).attr('id') === 'declined') {
$("#showEmailBox").hide();
$("#emailInfoBox").hide();
$("#showSubmit").show();
$("#submit-button").removeClass("btn-success").addClass("btn-danger").show();
$("#submitIcon").removeClass("fa-check").addClass("fa-times");
$("#buttonText").text('{{ trans_choice('general.i_decline_item', $acceptance->qty ?? 1) }}');
$("#note").prop('required', true);
} else if ($(this).is(':checked') && $(this).attr('id') === 'accepted') {
$("#emailInfoBox").show();
$("#showEmailBox").show();
$("#showSubmit").show();
$("#submit-button").removeClass("btn-danger").addClass("btn-success").show();
+15 -4
View File
@@ -57,21 +57,32 @@
<fieldset name="login" aria-label="login">
<div class="form-group{{ $errors->has('username') ? ' has-error' : '' }}">
<label for="username">
<label for="username" class="control-label">
<x-icon type="user" />
{{ trans('admin/users/table.username') }}
</label>
<input class="form-control" placeholder="{{ trans('admin/users/table.username') }}" name="username" type="text" id="username" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autofocus>
<input class="form-control" placeholder="{{ trans('admin/users/table.username') }}" name="username" type="text" id="username" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autocapitalize="off" spellcheck="false" autofocus>
{!! $errors->first('username', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
<div class="form-group{{ $errors->has('password') ? ' has-error' : '' }}">
<label for="password">
<label for="password" class="control-label">
<x-icon type="password" />
{{ trans('admin/users/table.password') }}
</label>
<input class="form-control" placeholder="{{ trans('admin/users/table.password') }}" name="password" type="password" id="password" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}">
<div class="input-group">
<input class="form-control" placeholder="{{ trans('admin/users/table.password') }}" name="password" type="password" id="password-field" autocomplete="{{ (config('auth.login_autocomplete') === true) ? 'on' : 'off' }}" autocorrect="off" autocapitalize="off" spellcheck="false">
<span class="input-group-addon">
<i data-toggle="#password-field" class="fa fa-fw fa-eye toggle-password" aria-hidden="true"></i>
<span class="sr-only">Toggle password visibility</span>
</span>
</div>
{!! $errors->first('password', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
<div class="form-group">
<label class="form-control">
<input name="remember" type="checkbox" value="1" id="remember"> {{ trans('auth/general.remember_me') }}
+73 -61
View File
@@ -10,7 +10,7 @@
@section('content')
<div class="row">
<div class="col-md-9">
<div class="col-md-6 col-md-offset-3">
<form class="form-horizontal" id="checkout_form" method="post" action="" autocomplete="off">
<!-- CSRF Token -->
@@ -81,71 +81,83 @@
<!-- User -->
@include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true'])
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@if ($consumable->category->require_acceptance=='1')
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.required_acceptance') }}
<br>
@endif
@if ($consumable->getEula())
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.required_eula') }}
<br>
@endif
@if (($consumable->category) && ($consumable->category->checkin_email))
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.checkin_email_notification') }}
<br>
@endif
@if ($snipeSettings->webhook_endpoint!='')
<i class="fab fa-slack"></i>
{{ trans('general.webhook_msg_note') }}
@endif
<!-- Checkout QTY -->
<div class="form-group {{ $errors->has('qty') ? 'error' : '' }} ">
<label for="qty" class="col-md-3 control-label">{{ trans('general.qty') }}</label>
<div class="col-md-7 col-sm-12 required">
<div class="col-md-2" style="padding-left:0px">
<input class="form-control" type="number" name="checkout_qty" id="checkout_qty" value="1" min="1" max="{{$consumable->numRemaining()}}" maxlength="999999"/>
</div>
</div>
</div>
<!-- Sign in place checkbox -->
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div id="sign_in_place_div" class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
@endif
{!! $errors->first('qty', '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
@endif
<!-- Checkout QTY -->
<div class="form-group {{ $errors->has('qty') ? 'error' : '' }} ">
<label for="qty" class="col-md-3 control-label">{{ trans('general.qty') }}</label>
<div class="col-md-7 col-sm-12 required">
<div class="col-md-2" style="padding-left:0px">
<input class="form-control" type="number" name="checkout_qty" id="checkout_qty" value="1" min="1" max="{{$consumable->numRemaining()}}" maxlength="999999" />
<!-- Note -->
<div class="form-group {{ $errors->has('note') ? 'error' : '' }}">
<label for="note" class="col-md-3 control-label">{{ trans('admin/hardware/form.notes') }}</label>
<div class="col-md-7">
<textarea class="col-md-6 form-control" name="note">{{ old('note') }}</textarea>
{!! $errors->first('note', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
{!! $errors->first('qty', '<div class="col-md-8 col-md-offset-3"><span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span></div>') !!}
</div>
<!-- Note -->
<div class="form-group {{ $errors->has('note') ? 'error' : '' }}">
<label for="note" class="col-md-3 control-label">{{ trans('admin/hardware/form.notes') }}</label>
<div class="col-md-7">
<textarea class="col-md-6 form-control" name="note">{{ old('note') }}</textarea>
{!! $errors->first('note', '<span class="alert-msg" aria-hidden="true"><i class="fas fa-times" aria-hidden="true"></i> :message</span>') !!}
</div>
</div>
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1' || $consumable->getEula() || ($snipeSettings->webhook_endpoint!=''))
<div class="form-group notification-callout">
<div class="col-md-8 col-md-offset-3">
<div class="callout callout-info">
@if ($consumable->category->require_acceptance=='1')
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.required_acceptance') }}
<br>
@endif
@if ($consumable->getEula())
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.required_eula') }}
<br>
@endif
@if (($consumable->category) && ($consumable->category->checkin_email))
<i class="far fa-envelope"></i>
{{ trans('admin/categories/general.checkin_email_notification') }}
<br>
@endif
@if ($snipeSettings->webhook_endpoint!='')
<i class="fab fa-slack"></i>
{{ trans('general.webhook_msg_note') }}
@endif
</div>
</div>
<!-- EULA/email checkbox or info message -->
@if ($consumable->getEula())
<div class="col-md-8 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="send_eula_copy" id="send_eula_copy" checked="checked" aria-label="send_eula_copy">
{{ trans('mail.send_pdf_copy') }}
</label>
</div>
@endif
<!-- Sign in place checkbox -->
@if ($consumable->requireAcceptance() || (string) $snipeSettings->require_accept_signature === '1')
<div id="sign_in_place_div" class="col-md-7 col-md-offset-3">
<label class="form-control">
<input type="checkbox" value="1" name="sign_in_place" @checked(old('sign_in_place', session('sign_in_place', false))) aria-label="sign_in_place">
{{ trans('general.sign_in_place') }}
</label>
<p class="help-block">
{{ trans('general.sign_in_place_help') }}
</p>
</div>
@endif
</div>
@endif
</div> <!-- .box-body -->
<x-redirect_submit_options
index_route="consumables.index"
+12 -1
View File
@@ -64,7 +64,18 @@
{{-- Javascript files --}}
<script src="{{ url(mix('js/dist/all.js')) }}" nonce="{{ csrf_token() }}"></script>
<script>
$(".toggle-password").click(function () {
$(this).toggleClass("fa-eye fa-eye-slash");
var input = $($(this).attr("data-toggle"));
if (input.attr("type") === "password") {
input.attr("type", "text");
}
else {
input.attr("type", "password");
}
});
</script>
@stack('js')
</body>
+2 -2
View File
@@ -1857,8 +1857,8 @@
{{ trans('general.asset_maintenance_report') }}
</a>
</li>
<li {{!! (request()->is('reports/unaccepted_assets') ? ' class="active"' : '') !!}}>
<a href="{{ url('reports/unaccepted_assets') }}">
<li {{!! (request()->is('reports/unaccepted_items') ? ' class="active"' : '') !!}}>
<a href="{{ url('reports/unaccepted_items') }}">
{{ trans('general.unaccepted_asset_report') }}
</a>
</li>
@@ -1,6 +1,7 @@
@if (($model) && ($model->fieldset) && $model->fieldset->displayAnyFieldsInForm($show_custom_fields_type ?? ''))
<div class="col-md-12 col-sm-12">
@if(isset($show_fieldset) && ($show_fieldset=='true'))
<fieldset name="custom-fields">
<x-form.legend
help_text="{!! trans('admin/custom_fields/general.general_help_text') !!}">
@@ -8,6 +9,9 @@
{{ trans('admin/custom_fields/general.custom_fields') }}
</x-form.legend>
@endif
@foreach($model->fieldset->fields as $field)
@if (!isset($show_custom_fields_type) || ($field->displayFieldInCurrentForm($show_custom_fields_type)))
@@ -15,12 +19,12 @@
<div class="form-group{{ $errors->has($field->db_column_name()) ? ' has-error' : '' }}">
<label for="{{ $field->db_column_name() }}" class="col-md-3 control-label">
{{ $field->name }}
<label for="{{ $field->db_column_name() }}" class="col-md-3 control-label">
{{ $field->name }}
<br>
</label>
</label>
<div class="col-md-7 col-sm-12">
<div class="col-md-8 col-sm-12">
@if ($field->element!='text')
@@ -36,7 +40,7 @@
@elseif ($field->element=='textarea')
<!-- Textarea -->
<textarea class="col-md-6 form-control" id="{{ $field->db_column_name() }}" name="{{ $field->db_column_name() }}"{{ ($field->pivot->required=='1') ? ' required' : '' }}>{{ old($field->db_column_name(),(isset($item) ? Helper::gracefulDecrypt($field, $item->{$field->db_column_name()}) : $field->defaultValue($model->id))) }}</textarea>
<textarea rows="6" class="form-control" id="{{ $field->db_column_name() }}" name="{{ $field->db_column_name() }}"{{ ($field->pivot->required=='1') ? ' required' : '' }}>{{ old($field->db_column_name(),(isset($item) ? Helper::gracefulDecrypt($field, $item->{$field->db_column_name()}) : $field->defaultValue($model->id))) }}</textarea>
@elseif ($field->element=='checkbox')
<!-- Checkbox -->
@@ -66,9 +70,10 @@
<!-- Date field -->
@if ($field->format=='DATE')
<div class="input-group col-md-5" style="padding-left: 0px;">
<div class="input-group date" data-provide="datepicker" data-date-format="yyyy-mm-dd" data-autoclose="true" data-date-clear-btn="true">
<input type="text" class="form-control" placeholder="{{ trans('general.select_date') }}" name="{{ $field->db_column_name() }}" id="{{ $field->db_column_name() }}" readonly value="{{ old($field->db_column_name(),(isset($item) ? Helper::gracefulDecrypt($field, $item->{$field->db_column_name()}) : $field->defaultValue($model->id))) }}" style="background-color:inherit"{{ ($field->pivot->required=='1') ? ' required' : '' }}>
<div class="input-group col-md-7">
<div class="input-group date" data-provide="datepicker" data-date-format="yyyy-mm-dd" data-autoclose="true" data-date-clear-btn="true">
<input type="text" class="form-control" placeholder="{{ trans('general.select_date') }}" name="{{ $field->db_column_name() }}" id="{{ $field->db_column_name() }}" value="{{ old($field->db_column_name(),(isset($item) ? Helper::gracefulDecrypt($field, $item->{$field->db_column_name()}) : $field->defaultValue($model->id))) }}" style="background-color:inherit"{{ ($field->pivot->required=='1') ? ' required' : '' }}>
<span class="input-group-addon"><x-icon type="calendar" /></span>
</div>
</div>
@@ -84,7 +89,16 @@
@endif
@if ($field->help_text!='')
{{-- Visibility icons below the field, before help text --}}
@php $presenter = new \App\Presenters\CustomFieldPresenter($field); @endphp
@if (count($presenter->visibilityIconsArray()))
@if ($field->help_text != '')
<p class="help-block">{{ $field->help_text }} <span class="custom-field-visibility-icons"><br>{!! $presenter->visibilityIcons() !!}</span>
</p>
@else
<div class="custom-field-visibility-icons" style="margin-bottom:7px;">{!! $presenter->visibilityIcons() !!}</div>
@endif
@elseif ($field->help_text != '')
<p class="help-block">{{ $field->help_text }}</p>
@endif
@@ -107,8 +121,6 @@
@endif
@endforeach
</fieldset>
</div>
@endif
@endif
@@ -43,6 +43,29 @@
@if (isset($qty))
| **{{ trans('general.qty') }}** | {{ $qty }} |
@endif
@if (!empty($custom_fields) && is_iterable($custom_fields))
@foreach ($custom_fields as $customField)
@if (!empty($customField['label']) && array_key_exists('value', $customField) && $customField['value'] !== '')
| **{{ $customField['label'] }}** | {{ $customField['value'] }} |
@endif
@endforeach
@endif
| **{{ trans('general.signed_in_place') }}** | {{ isset($signed_in_place) && $signed_in_place ? trans('general.yes') : trans('general.no') }} |
@if(isset($signed_in_place) && $signed_in_place && isset($signed_in_place_admin) && is_array($signed_in_place_admin))
@php
$adminName = $signed_in_place_admin['name'] ?? '';
$adminUsername = $signed_in_place_admin['username'] ?? '';
$adminEmail = $signed_in_place_admin['email'] ?? '';
$adminDetails = $adminName;
if (!empty($adminUsername)) {
$adminDetails .= ' (' . $adminUsername . ')';
}
if (!empty($adminEmail)) {
$adminDetails .= ' <' . $adminEmail . '>';
}
@endphp
| **{{ trans('general.signed_in_place_admin', ['admin' => $adminDetails]) }}** | {{ trans('general.signed_in_place_admin', ['admin' => $adminDetails]) }} |
@endif
@endcomponent
{{ trans('mail.best_regards') }}
@@ -1,145 +0,0 @@
<?php
?>
@extends('layouts/default')
{{-- Page title --}}
@section('title')
{{ trans('general.unaccepted_asset_report') }}
@parent
@stop
@section('header_right')
<div class="btn-toolbar" role="toolbar">
<div class="btn-group mr-2" role="group">
@if($showDeleted)
<a href="{{ route('reports/unaccepted_assets') }}" class="btn btn-default" ><i class="fa fa-trash icon-white" aria-hidden="true"></i> {{ trans('general.hide_deleted') }}</a>
@else
<a href="{{ route('reports/unaccepted_assets', ['deleted' => 'deleted']) }}" class="btn btn-default" ><i class="fa fa-trash icon-white" aria-hidden="true"></i> {{ trans('general.show_deleted') }}</a>
@endif
</div>
<div class="btn-group mr-2" role="group">
<form method="POST" action="{{ route('reports/export/unaccepted_assets') }}" accept-charset="UTF-8" class="form-horizontal">
{{csrf_field()}}
<button type="submit" class="btn btn-default"><i class="fa fa-download icon-white" aria-hidden="true"></i> {{ trans('general.download_all') }}</button>
</form>
</div>
</div>
@stop
{{-- Page content --}}
@section('content')
<div class="row">
<div class="col-md-12">
<div class="box box-default">
<div class="box-body">
<table
data-cookie-id-table="unacceptedAssetsReport"
data-id-table="unacceptedAssetsReport"
data-side-pagination="client"
data-sort-order="asc"
data-sort-name="created_at"
data-advanced-search="false"
id="unacceptedAssetsReport"
data-fixed-number="false"
data-fixed-right-number="false"
class="table table-striped snipe-table"
data-export-options='{
"fileName": "maintenance-report-{{ date('Y-m-d') }}",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
<thead>
<tr role="row">
<th class="col-sm-1" data-field="created_at" data-searchable="false" data-sortable="true">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.type') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/companies/table.title') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.category') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/form.model') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('general.name') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/table.asset_tag') }}</th>
<th class="col-sm-1" data-sortable="true" >{{ trans('admin/hardware/table.checkoutto') }}</th>
<th class="col-md-1"><span class="line"></span>{{ trans('table.actions') }}</th>
</tr>
</thead>
<tbody>
@if ($itemsForReport)
@foreach ($itemsForReport as $item)
<tr @if($item->acceptance->trashed()) style="text-decoration: line-through" @endif>
{{-- Created date --}}
<td>
{{ Helper::getFormattedDateObject($item->acceptance->created_at, 'datetime', false) }}
</td>
{{-- Item Type --}}
<td>{{ $item->type }}</td>
{{-- Company name --}}
<td>{{ $item->plain_text_company }}</td>
{{-- Category --}}
<td>{{ $item->plain_text_category }}</td>
{{-- Model --}}
<td>{{ $item->plain_text_model }}</td>
{{-- Name --}}
<td>{{ $item->plain_text_name }}</td>
{{-- Asset tag or blank --}}
<td>{{ $item->asset_tag }}</td>
{{-- Assigned To (with soft-delete strike if needed) --}}
<td @if(!$item->assignee || (method_exists($item->assignee, 'trashed') && $item->assignee->trashed())) style="text-decoration: line-through" @endif>
{!! $item->assignee
? optional($item->assignee->present())->nameUrl() ?? e($item->assignee->name)
: trans('admin/reports/general.deleted_user') !!}
</td>
{{-- Actions: send reminder / delete --}}
<td class="text-nowrap">
@unless($item->acceptance->trashed())
<form method="post" class="white-space: nowrap;" action="{{ route('reports/unaccepted_assets_sent_reminder') }}">
@csrf
<input type="hidden" name="acceptance_id" value="{{ $item->acceptance_id }}">
@if ($item->assignee && $item->assignee->email)
<button class="btn btn-sm btn-warning" data-tooltip="true" data-title="{{ trans('admin/reports/general.send_reminder') }}">
<i class="fa fa-repeat" aria-hidden="true"></i>
</button>
@else
<span data-tooltip="true" data-title="{{ trans('admin/reports/general.cannot_send_reminder') }}">
<a class="btn btn-sm btn-warning disabled" href="#">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
</span>
@endif
<a href="{{ route('reports/unaccepted_assets_delete', ['acceptanceId' => $item->acceptance_id]) }}"
class="btn btn-sm btn-danger delete-asset"
data-tooltip="true"
data-toggle="modal"
data-content="{{ trans('general.delete_confirm', ['item' => trans('admin/reports/general.acceptance_request')]) }}"
data-title="{{ trans('general.delete') }}"
onClick="return false;">
<i class="fa fa-trash"></i>
</a>
</form>
@endunless
</td>
</tr>
@endforeach
@endif
</tbody>
<tfoot>
<tr>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
@stop
@section('moar_scripts')
@include ('partials.bootstrap-table')
@stop
@@ -0,0 +1,154 @@
<?php
?>
@extends('layouts/default')
{{-- Page title --}}
@section('title')
{{ trans('general.unaccepted_asset_report') }}
@parent
@stop
@section('header_right')
<div class="btn-toolbar" role="toolbar">
<div class="btn-group mr-2" role="group">
@if($showDeleted)
<a href="{{ route('reports/unaccepted_items') }}" class="btn btn-default"><i class="fa fa-trash icon-white" aria-hidden="true"></i> {{ trans('general.hide_deleted') }}
</a>
@else
<a href="{{ route('reports/unaccepted_items', ['deleted' => 'deleted']) }}" class="btn btn-default"><i class="fa fa-trash icon-white" aria-hidden="true"></i> {{ trans('general.show_deleted') }}
</a>
@endif
</div>
<div class="btn-group mr-2" role="group">
<form method="POST" action="{{ route('reports/export/unaccepted_items') }}" accept-charset="UTF-8" class="form-horizontal">
{{csrf_field()}}
<button type="submit" class="btn btn-default">
<i class="fa fa-download icon-white" aria-hidden="true"></i> {{ trans('general.download_all') }}
</button>
</form>
</div>
</div>
@stop
{{-- Page content --}}
@section('content')
<div class="row">
<div class="col-md-12">
<div class="box box-default">
<div class="box-body">
<table
data-cookie-id-table="unacceptedItemsReport"
data-id-table="unacceptedItemsReport"
data-side-pagination="client"
data-sort-order="asc"
data-sort-name="created_at"
data-advanced-search="false"
id="unacceptedItemsReport"
data-fixed-number="false"
data-fixed-right-number="false"
class="table table-striped snipe-table"
data-export-options='{
"fileName": "unaccepted-items-report-{{ date('Y-m-d') }}",
"ignoreColumn": ["actions","image","change","checkbox","checkincheckout","icon"]
}'>
<thead>
<tr role="row">
<th class="col-sm-1" data-field="created_at" data-searchable="false" data-sortable="true">{{ trans('general.date') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('general.type') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('admin/companies/table.title') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('general.category') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('admin/hardware/form.model') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('general.name') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('admin/hardware/table.asset_tag') }}</th>
<th class="col-sm-1" data-sortable="true">{{ trans('admin/hardware/table.checkoutto') }}</th>
@if(auth()->user()?->isAdmin() || auth()->user()?->isSuperUser())
<th class="col-md-1"><span class="line"></span>{{ trans('table.actions') }}</th>
@endif
</tr>
</thead>
<tbody>
@if ($itemsForReport)
@foreach ($itemsForReport as $item)
<tr @if($item->acceptance->trashed()) style="text-decoration: line-through" @endif>
{{-- Created date --}}
<td>
{{ Helper::getFormattedDateObject($item->acceptance->created_at, 'datetime', false) }}
</td>
{{-- Item Type --}}
<td>{{ $item->type }}</td>
{{-- Company name --}}
<td>{{ $item->plain_text_company }}</td>
{{-- Category --}}
<td>{{ $item->plain_text_category }}</td>
{{-- Model --}}
<td>{{ $item->plain_text_model }}</td>
{{-- Name --}}
<td>{{ $item->plain_text_name }}</td>
{{-- Asset tag or blank --}}
<td>{{ $item->asset_tag }}</td>
{{-- Assigned To (with soft-delete strike if needed) --}}
<td @if(!$item->assignee || (method_exists($item->assignee, 'trashed') && $item->assignee->trashed())) style="text-decoration: line-through" @endif>
{!! $item->assignee
? optional($item->assignee->present())->nameUrl() ?? e($item->assignee->name)
: trans('general.deleted') !!}
</td>
{{-- Actions: send reminder / delete --}}
@if(auth()->user()?->isAdmin() || auth()->user()?->isSuperUser())
<td class="text-nowrap">
@unless($item->acceptance->trashed())
<form method="post" class="white-space: nowrap;" action="{{ route('reports/unaccepted_items_sent_reminder') }}">
@csrf
<input type="hidden" name="acceptance_id" value="{{ $item->acceptance_id }}">
@if ($item->assignee && $item->assignee->email)
<button class="btn btn-sm btn-warning" data-tooltip="true" data-title="{{ trans('admin/reports/general.send_reminder') }}">
<i class="fa fa-repeat" aria-hidden="true"></i>
</button>
@else
<span data-tooltip="true" data-title="{{ trans('admin/reports/general.cannot_send_reminder') }}">
<a class="btn btn-sm btn-warning disabled" href="#">
<i class="fa fa-repeat" aria-hidden="true"></i>
</a>
</span>
@endif
<a href="{{ route('reports/unaccepted_items_delete', ['acceptanceId' => $item->acceptance_id]) }}"
class="btn btn-sm btn-danger delete-asset"
data-tooltip="true"
data-toggle="modal"
data-content="{{ trans('general.delete_confirm', ['item' => trans('admin/reports/general.acceptance_request')]) }}"
data-title="{{ trans('general.delete') }}"
onClick="return false;">
<i class="fa fa-trash"></i>
</a>
</form>
@endunless
</td>
@endif
</tr>
@endforeach
@endif
</tbody>
<tfoot>
<tr>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
@stop
@section('moar_scripts')
@include ('partials.bootstrap-table')
@stop
+5 -5
View File
@@ -545,19 +545,19 @@ Route::group(['prefix' => 'reports', 'middleware' => ['auth']], function () {
->name('reports.activity.post');
Route::get('unaccepted_assets/{deleted?}', [ReportsController::class, 'getAssetAcceptanceReport'])
->name('reports/unaccepted_assets')
->name('reports/unaccepted_items')
->breadcrumbs(fn (Trail $trail) => $trail->parent('home')
->push(trans('general.unaccepted_asset_report'), route('reports/unaccepted_assets')));
->push(trans('general.unaccepted_asset_report'), route('reports/unaccepted_items')));
Route::post('unaccepted_assets/sent_reminder', [ReportsController::class, 'sentAssetAcceptanceReminder'])
->name('reports/unaccepted_assets_sent_reminder');
->name('reports/unaccepted_items_sent_reminder');
Route::delete('unaccepted_assets/{acceptanceId}/delete', [ReportsController::class, 'deleteAssetAcceptance'])
->name('reports/unaccepted_assets_delete');
->name('reports/unaccepted_items_delete');
Route::post(
'unaccepted_assets/{deleted?}', [ReportsController::class, 'postAssetAcceptanceReport'])
->name('reports/export/unaccepted_assets');
->name('reports/export/unaccepted_items');
});
+9 -10
View File
@@ -1,29 +1,28 @@
<?php
use App\Http\Controllers\Consumables;
use App\Models\Consumable;
use Illuminate\Support\Facades\Route;
use Tabuna\Breadcrumbs\Trail;
Route::group(['prefix' => 'consumables', 'middleware' => ['auth']], function () {
Route::get(
'{consumablesID}/checkout',
[Consumables\ConsumableCheckoutController::class, 'create']
)->name('consumables.checkout.show');
Route::get('{consumable}/checkout', [Consumables\ConsumableCheckoutController::class, 'create'])
->breadcrumbs(fn (Trail $trail, Consumable $consumable) => $trail->parent('consumables.show', $consumable)
->push(trans('general.checkout'), route('consumables.index'))
)->name('consumables.checkout.show');
Route::post(
'{consumablesID}/checkout',
'{consumable}/checkout',
[Consumables\ConsumableCheckoutController::class, 'store']
)->name('consumables.checkout.store');
Route::get('{consumable}/clone',
[Consumables\ConsumablesController::class, 'clone']
)->name('consumables.clone.create');
});
Route::resource('consumables', Consumables\ConsumablesController::class, [
'middleware' => ['auth'],
'parameters' => ['consumable' => 'consumable_id'],
@@ -0,0 +1,43 @@
<?php
namespace Tests\Feature\CheckoutAcceptances\Ui;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Tests\TestCase;
class AcceptanceAuthorizationTest extends TestCase
{
public function test_assigned_user_can_accept()
{
$assignee = User::factory()->create();
$asset = Asset::factory()->create();
$acceptance = CheckoutAcceptance::factory()->pending()->for($assignee, 'assignedTo')->for($asset, 'checkoutable')->create();
$this->actingAs($assignee)
->post(route('account.store-acceptance', $acceptance), [
'asset_acceptance' => 'accepted',
'note' => 'ok',
])
->assertSessionHasNoErrors();
$this->assertNotNull($acceptance->fresh()->accepted_at);
}
public function test_other_user_cannot_accept()
{
$other = User::factory()->create();
$assignee = User::factory()->create();
$asset = Asset::factory()->create();
$acceptance = CheckoutAcceptance::factory()->pending()->for($assignee, 'assignedTo')->for($asset, 'checkoutable')->create();
$response = $this->actingAs($other)
->post(route('account.store-acceptance', $acceptance), [
'asset_acceptance' => 'accepted',
'note' => 'no',
]);
$response->assertRedirectToRoute('account.accept');
$response->assertSessionHas('error');
$this->assertNull($acceptance->fresh()->accepted_at);
}
}
@@ -4,7 +4,6 @@ namespace Tests\Feature\CheckoutAcceptances\Ui;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
@@ -150,11 +149,15 @@ class AccessoryAcceptanceTest extends TestCase
{
Notification::fake();
$assignee = User::factory()->create();
$otherUser = User::factory()->create();
$accessory = Accessory::factory()->create();
$acceptance = CheckoutAcceptance::factory()
->pending()
->for(Asset::factory()->laptopMbp(), 'checkoutable')
->for($assignee, 'assignedTo')
->for($accessory, 'checkoutable')
->create();
$this->actingAs($otherUser)
@@ -235,4 +238,30 @@ class AccessoryAcceptanceTest extends TestCase
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
}
public function test_acceptance_create_page_shows_email_info_when_always_send_email_enabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => true]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('general.acceptance_email_always_sent'), false);
$response->assertDontSee(trans('mail.send_pdf_copy'), false);
}
public function test_acceptance_create_page_shows_checkbox_when_always_send_email_disabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => false]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('mail.send_pdf_copy'), false);
$response->assertDontSee(trans('general.acceptance_email_always_sent'), false);
}
}
@@ -6,9 +6,13 @@ use App\Events\CheckoutAccepted;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\CustomField;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
use App\Notifications\AcceptanceItemAcceptedToUserNotification;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Notification;
use Tests\TestCase;
class AssetAcceptanceTest extends TestCase
@@ -180,6 +184,84 @@ class AssetAcceptanceTest extends TestCase
);
}
public function test_admin_acceptance_email_contains_custom_fields_marked_show_in_email(): void
{
Notification::fake();
$this->settings->enableAlertEmail();
$customField = CustomField::factory()->create([
'name' => 'Cost Center',
'show_in_email' => '1',
'field_encrypted' => '0',
])->fresh();
$asset = Asset::factory()->hasMultipleCustomFields([$customField])->create();
$asset->{$customField->db_column} = 'ENG-42';
$asset->save();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($asset, 'checkoutable')
->create();
$this->actingAs($checkoutAcceptance->assignedTo)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertSessionHasNoErrors();
Notification::assertSentTo(
$checkoutAcceptance,
function (AcceptanceItemAcceptedNotification $notification) {
$rendered = $notification->toMail()->render();
$this->assertStringContainsString('Cost Center', $rendered);
$this->assertStringContainsString('ENG-42', $rendered);
return true;
}
);
}
public function test_admin_acceptance_email_does_not_contain_encrypted_custom_fields(): void
{
Notification::fake();
$this->settings->enableAlertEmail();
$customField = CustomField::factory()->create([
'name' => 'SSN',
'show_in_email' => '1',
'field_encrypted' => '1',
])->fresh();
$asset = Asset::factory()->hasMultipleCustomFields([$customField])->create();
$asset->{$customField->db_column} = '123-45-6789';
$asset->save();
$checkoutAcceptance = CheckoutAcceptance::factory()
->pending()
->for($asset, 'checkoutable')
->create();
$this->actingAs($checkoutAcceptance->assignedTo)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertSessionHasNoErrors();
Notification::assertSentTo(
$checkoutAcceptance,
function (AcceptanceItemAcceptedNotification $notification) {
$rendered = $notification->toMail()->render();
$this->assertStringNotContainsString('SSN', $rendered);
$this->assertStringNotContainsString('123-45-6789', $rendered);
return true;
}
);
}
public function test_action_logged_when_declining_asset()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
@@ -232,7 +314,11 @@ class AssetAcceptanceTest extends TestCase
])
->assertRedirect(route('users.show', $assignee));
$this->assertNotNull($checkoutAcceptance->refresh()->accepted_at);
$checkoutAcceptance->refresh();
$this->assertNotNull($checkoutAcceptance->accepted_at);
$this->assertTrue((bool) $checkoutAcceptance->signed_in_place);
$this->assertSame($admin->id, $checkoutAcceptance->signed_in_place_admin);
Event::assertDispatched(CheckoutAccepted::class);
}
@@ -257,7 +343,8 @@ class AssetAcceptanceTest extends TestCase
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertRedirect(route('users.show', $assignee));
->assertRedirectToRoute('account.accept')
->assertSessionHas('error');
}
public function test_stale_sign_in_place_post_with_missing_assignee_does_not_throw_route_error()
@@ -320,4 +407,68 @@ class AssetAcceptanceTest extends TestCase
->assertDontSee(route('users.show', $assignee), false)
->assertDontSee(route('hardware.checkout.create', $asset), false);
}
public function test_acceptance_create_page_shows_email_info_when_always_send_email_enabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => true]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('general.acceptance_email_always_sent'), false);
$response->assertDontSee(trans('mail.send_pdf_copy'), false);
}
public function test_acceptance_create_page_shows_checkbox_when_always_send_email_disabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => false]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('mail.send_pdf_copy'), false);
$response->assertDontSee(trans('general.acceptance_email_always_sent'), false);
}
public function test_acceptance_create_page_hides_checkbox_when_always_send_eula_enabled(): void
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config([
'app.always_send_email' => false,
'app.always_send_eula' => true,
]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('general.acceptance_email_always_sent'), false);
$response->assertDontSee(trans('mail.send_pdf_copy'), false);
}
public function test_acceptance_always_sends_copy_when_always_send_eula_enabled(): void
{
Notification::fake();
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config([
'app.always_send_email' => false,
'app.always_send_eula' => true,
]);
$this->actingAs($checkoutAcceptance->assignedTo)
->post(route('account.store-acceptance', $checkoutAcceptance), [
'asset_acceptance' => 'accepted',
])
->assertSessionHasNoErrors();
Notification::assertSentTo(
$checkoutAcceptance->assignedTo,
AcceptanceItemAcceptedToUserNotification::class
);
}
}
@@ -74,4 +74,30 @@ class ConsumableAcceptanceTest extends TestCase
'quantity' => 2,
]);
}
public function test_acceptance_create_page_shows_email_info_when_always_send_email_enabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => true]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('general.acceptance_email_always_sent'), false);
$response->assertDontSee(trans('mail.send_pdf_copy'), false);
}
public function test_acceptance_create_page_shows_checkbox_when_always_send_email_disabled()
{
$checkoutAcceptance = CheckoutAcceptance::factory()->pending()->create();
config(['app.always_send_email' => false]);
$response = $this->actingAs($checkoutAcceptance->assignedTo)
->get(route('account.accept.item', $checkoutAcceptance));
$response->assertOk();
$response->assertSee(trans('mail.send_pdf_copy'), false);
$response->assertDontSee(trans('general.acceptance_email_always_sent'), false);
}
}
@@ -33,7 +33,7 @@ class AcceptanceReminderTest extends TestCase
Mail::fake();
$this->admin = User::factory()->canViewReports()->create();
$this->admin = User::factory()->admin()->canViewReports()->create();
$this->assignee = User::factory()->create();
}
@@ -43,7 +43,7 @@ class AcceptanceReminderTest extends TestCase
$userWithoutPermission = User::factory()->create();
$this->actingAs($userWithoutPermission)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $checkoutAcceptance->id,
]))
->assertForbidden();
@@ -54,7 +54,7 @@ class AcceptanceReminderTest extends TestCase
public function test_reminder_not_sent_if_acceptance_does_not_exist()
{
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => 999999,
]));
@@ -66,7 +66,7 @@ class AcceptanceReminderTest extends TestCase
$checkoutAcceptanceAlreadyAccepted = CheckoutAcceptance::factory()->accepted()->create();
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $checkoutAcceptanceAlreadyAccepted->id,
]));
@@ -100,7 +100,7 @@ class AcceptanceReminderTest extends TestCase
$checkoutAcceptance = $callback();
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $checkoutAcceptance->id,
]))
// check we didn't crash...
@@ -124,10 +124,10 @@ class AcceptanceReminderTest extends TestCase
->create();
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $acceptance->id,
]))
->assertRedirect(route('reports/unaccepted_assets'));
->assertRedirect(route('reports/unaccepted_items'));
Mail::assertSent(CheckoutAccessoryMail::class, 1);
@@ -153,10 +153,10 @@ class AcceptanceReminderTest extends TestCase
$this->createActionLogEntry($asset, $this->admin, $this->assignee, $acceptance);
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $acceptance->id,
]))
->assertRedirect(route('reports/unaccepted_assets'));
->assertRedirect(route('reports/unaccepted_items'));
Mail::assertSent(CheckoutAssetMail::class, 1);
@@ -182,10 +182,10 @@ class AcceptanceReminderTest extends TestCase
$this->createActionLogEntry($consumable, $this->admin, $this->assignee, $acceptance);
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $acceptance->id,
]))
->assertRedirect(route('reports/unaccepted_assets'));
->assertRedirect(route('reports/unaccepted_items'));
Mail::assertSent(CheckoutConsumableMail::class, 1);
@@ -211,10 +211,10 @@ class AcceptanceReminderTest extends TestCase
$this->createActionLogEntry($licenseSeat, $this->admin, $this->assignee, $acceptance);
$this->actingAs($this->admin)
->post(route('reports/unaccepted_assets_sent_reminder', [
->post(route('reports/unaccepted_items_sent_reminder', [
'acceptance_id' => $acceptance->id,
]))
->assertRedirect(route('reports/unaccepted_assets'));
->assertRedirect(route('reports/unaccepted_items'));
Mail::assertSent(CheckoutLicenseMail::class, 1);
@@ -76,6 +76,21 @@ class EmailNotificationsToAdminAlertEmailUponCheckinTest extends TestCase
});
}
public function test_admin_alert_email_sent_when_category_sends_email_and_admin_cc_always_disabled()
{
$this->settings
->enableAdminCC('cc@example.com')
->disableAdminCCAlways();
$this->category->update(['checkin_email' => true]);
$this->fireCheckInEvent($this->asset, $this->user);
Mail::assertSent(CheckinAssetMail::class, function (CheckinAssetMail $mail) {
return $mail->hasCc('cc@example.com') || $mail->hasTo('cc@example.com');
});
}
public function test_admin_alert_email_still_sent_when_user_has_no_email_address()
{
$this->settings->enableAdminCC('cc@example.com');
@@ -121,6 +136,22 @@ class EmailNotificationsToAdminAlertEmailUponCheckinTest extends TestCase
});
}
public function test_alert_email_receives_checkin_notification_when_admin_cc_always_enabled_and_admin_cc_email_empty()
{
$this->settings
->disableAdminCC()
->enableAlertEmail('alerts@example.com')
->enableAdminCCAlways();
$this->category->update(['checkin_email' => false]);
$this->fireCheckInEvent($this->asset, $this->user);
Mail::assertSent(CheckinAssetMail::class, function (CheckinAssetMail $mail) {
return $mail->hasTo('alerts@example.com') || $mail->hasCc('alerts@example.com');
});
}
private function fireCheckInEvent($asset, $user): void
{
event(new CheckoutableCheckedIn(
@@ -66,6 +66,21 @@ class EmailNotificationsToAdminAlertEmailUponCheckoutTest extends TestCase
});
}
public function test_admin_alert_email_sent_when_category_sends_email_and_admin_cc_always_disabled()
{
$this->settings
->enableAdminCC('cc@example.com')
->disableAdminCCAlways();
$this->category->update(['checkin_email' => true]);
$this->fireCheckoutEvent();
Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) {
return $mail->hasCc('cc@example.com') || $mail->hasTo('cc@example.com');
});
}
public function test_admin_alert_email_still_sent_when_user_has_no_email_address()
{
$this->settings->enableAdminCC('cc@example.com');
@@ -110,6 +125,22 @@ class EmailNotificationsToAdminAlertEmailUponCheckoutTest extends TestCase
});
}
public function test_alert_email_receives_checkout_notification_when_admin_cc_always_enabled_and_admin_cc_email_empty()
{
$this->settings
->disableAdminCC()
->enableAlertEmail('alerts@example.com')
->enableAdminCCAlways();
$this->category->update(['checkin_email' => false]);
$this->fireCheckoutEvent();
Mail::assertSent(CheckoutAssetMail::class, function (CheckoutAssetMail $mail) {
return $mail->hasTo('alerts@example.com') || $mail->hasCc('alerts@example.com');
});
}
private function fireCheckoutEvent(): void
{
event(new CheckoutableCheckedOut(
@@ -89,6 +89,11 @@ class EmailNotificationsToUserUponCheckinTest extends TestCase
public function test_handles_user_not_having_email_address_set()
{
$this->settings
->disableAdminCC()
->disableAdminCCAlways()
->disableAlertEmail();
$user = User::factory()->create(['email' => null]);
$asset = Asset::factory()->assignedToUser($user)->create();
@@ -108,6 +108,11 @@ class EmailNotificationsToUserUponCheckoutTest extends TestCase
public function test_handles_user_not_having_email_address_set()
{
$this->settings
->disableAdminCC()
->disableAdminCCAlways()
->disableAlertEmail();
$this->category->update(['checkin_email' => true]);
$this->user->update(['email' => null]);
@@ -2,6 +2,7 @@
namespace Tests\Feature\Reporting;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Illuminate\Testing\TestResponse;
use League\Csv\Reader;
@@ -44,14 +45,51 @@ class UnacceptedAssetReportTest extends TestCase
public function test_permission_required_to_view_unaccepted_asset_report()
{
$this->actingAs(User::factory()->create())
->get(route('reports/unaccepted_assets'))
->get(route('reports/unaccepted_items'))
->assertForbidden();
}
public function test_user_can_list_unaccepted_assets()
{
$this->actingAs(User::factory()->superuser()->create())
->get(route('reports/unaccepted_assets'))
$this->actingAs(User::factory()->canViewReports()->create())
->get(route('reports/unaccepted_items'))
->assertOk();
}
public function test_regular_user_cannot_perform_reminder_or_delete()
{
$user = User::factory()->canViewReports()->create();
$acceptance = CheckoutAcceptance::factory()->pending()->create();
$this->actingAs($user)
->post(route('reports/unaccepted_items_sent_reminder'), ['acceptance_id' => $acceptance->id])
->assertForbidden();
$this->actingAs($user)
->delete(route('reports/unaccepted_items_delete', $acceptance->id))
->assertForbidden();
}
public function test_admin_can_perform_reminder_and_delete()
{
$admin = User::factory()->admin()->canViewReports()->create();
$acceptance = CheckoutAcceptance::factory()->pending()->create();
$this->actingAs($admin)
->post(route('reports/unaccepted_items_sent_reminder'), ['acceptance_id' => $acceptance->id])
->assertStatus(302); // Or whatever is appropriate (redirect, etc)
$this->actingAs($admin)
->delete(route('reports/unaccepted_items_delete', $acceptance->id))
->assertStatus(302);
}
public function test_superuser_can_perform_reminder_and_delete()
{
$superuser = User::factory()->superuser()->canViewReports()->create();
$acceptance = CheckoutAcceptance::factory()->pending()->create();
$this->actingAs($superuser)
->post(route('reports/unaccepted_items_sent_reminder'), ['acceptance_id' => $acceptance->id])
->assertStatus(302);
$this->actingAs($superuser)
->delete(route('reports/unaccepted_items_delete', $acceptance->id))
->assertStatus(302);
}
}