diff --git a/app/Http/Controllers/Accessories/AccessoryCheckoutController.php b/app/Http/Controllers/Accessories/AccessoryCheckoutController.php index 103cbd09b..19c8c6c7c 100644 --- a/app/Http/Controllers/Accessories/AccessoryCheckoutController.php +++ b/app/Http/Controllers/Accessories/AccessoryCheckoutController.php @@ -4,12 +4,12 @@ namespace App\Http\Controllers\Accessories; use App\Events\CheckoutableCheckedOut; use App\Http\Controllers\Controller; +use App\Http\Requests\AccessoryCheckoutRequest; use App\Models\Accessory; use App\Models\User; use Carbon\Carbon; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; -use Illuminate\Support\Facades\DB; use \Illuminate\Contracts\View\View; use \Illuminate\Http\RedirectResponse; @@ -57,44 +57,29 @@ class AccessoryCheckoutController extends Controller * * @author [A. Gianotto] [] * @param Request $request - * @param int $accessoryId + * @param int $accessory */ - public function store(Request $request, $accessoryId) : RedirectResponse + public function store(AccessoryCheckoutRequest $request, Accessory $accessory) : RedirectResponse { - // Check if the accessory exists - if (is_null($accessory = Accessory::withCount('users as users_count')->find($accessoryId))) { - // Redirect to the accessory management page with error - return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.user_not_found')); - } $this->authorize('checkout', $accessory); + $accessory->assigned_to = $request->input('assigned_to'); + $user = User::find($request->input('assigned_to')); + $accessory->checkout_qty = $request->input('checkout_qty', 1); - if (!$user = User::find($request->input('assigned_to'))) { - return redirect()->route('accessories.checkout.show', $accessory->id)->with('error', trans('admin/accessories/message.checkout.user_does_not_exist')); + for ($i = 0; $i < $accessory->checkout_qty; $i++) { + $accessory->users()->attach($accessory->id, [ + 'accessory_id' => $accessory->id, + 'created_at' => Carbon::now(), + 'user_id' => Auth::id(), + 'assigned_to' => $request->input('assigned_to'), + 'note' => $request->input('note'), + ]); } - - // Make sure there is at least one available to checkout - if ($accessory->numRemaining() <= 0){ - return redirect()->route('accessories.index')->with('error', trans('admin/accessories/message.checkout.unavailable')); - } - - - // Update the accessory data - $accessory->assigned_to = e($request->input('assigned_to')); - - $accessory->users()->attach($accessory->id, [ - 'accessory_id' => $accessory->id, - 'created_at' => Carbon::now(), - 'user_id' => Auth::id(), - 'assigned_to' => $request->get('assigned_to'), - 'note' => $request->input('note'), - ]); - - DB::table('accessories_users')->where('assigned_to', '=', $accessory->assigned_to)->where('accessory_id', '=', $accessory->id)->first(); - event(new CheckoutableCheckedOut($accessory, $user, auth()->user(), $request->input('note'))); // Redirect to the new accessory page - return redirect()->route('accessories.index')->with('success', trans('admin/accessories/message.checkout.success')); + return redirect()->route('accessories.index') + ->with('success', trans('admin/accessories/message.checkout.success')); } } diff --git a/app/Http/Controllers/Api/AccessoriesController.php b/app/Http/Controllers/Api/AccessoriesController.php index 383b95e6e..1ffdcaf19 100644 --- a/app/Http/Controllers/Api/AccessoriesController.php +++ b/app/Http/Controllers/Api/AccessoriesController.php @@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api; use App\Events\CheckoutableCheckedOut; use App\Helpers\Helper; use App\Http\Controllers\Controller; +use App\Http\Requests\AccessoryCheckoutRequest; +use App\Http\Requests\StoreAccessoryRequest; use App\Http\Transformers\AccessoriesTransformer; use App\Http\Transformers\SelectlistTransformer; use App\Models\Accessory; @@ -121,12 +123,12 @@ class AccessoriesController extends Controller /** * Store a newly created resource in storage. * + * @param \App\Http\Requests\ImageUploadRequest $request + * @return \Illuminate\Http\JsonResponse * @author [A. Gianotto] [] * @since [v4.0] - * @param \App\Http\Requests\ImageUploadRequest $request - * @return \Illuminate\Http\Response */ - public function store(ImageUploadRequest $request) + public function store(StoreAccessoryRequest $request) { $this->authorize('create', Accessory::class); $accessory = new Accessory; @@ -144,10 +146,10 @@ class AccessoriesController extends Controller /** * Display the specified resource. * + * @param int $id + * @return array * @author [A. Gianotto] [] * @since [v4.0] - * @param int $id - * @return \Illuminate\Http\Response */ public function show($id) { @@ -161,10 +163,10 @@ class AccessoriesController extends Controller /** * Display the specified resource. * + * @param int $id + * @return array * @author [A. Gianotto] [] * @since [v4.0] - * @param int $id - * @return \Illuminate\Http\Response */ public function accessory_detail($id) { @@ -273,43 +275,31 @@ class AccessoriesController extends Controller * If Slack is enabled and/or asset acceptance is enabled, it will also * trigger a Slack message and send an email. * - * @author [A. Gianotto] [] * @param int $accessoryId - * @return \Illuminate\Http\RedirectResponse + * @return \Illuminate\Http\JsonResponse + * @author [A. Gianotto] [] */ - public function checkout(Request $request, $accessoryId) + public function checkout(AccessoryCheckoutRequest $request, Accessory $accessory) { - // Check if the accessory exists - if (is_null($accessory = Accessory::withCount('users as users_count')->find($accessoryId))) { - return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.does_not_exist'))); - } - $this->authorize('checkout', $accessory); + $accessory->assigned_to = $request->input('assigned_to'); + $user = User::find($request->input('assigned_to')); + $accessory->checkout_qty = $request->input('checkout_qty', 1); - - if ($accessory->numRemaining() > 0) { - - if (! $user = User::find($request->input('assigned_to'))) { - return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/accessories/message.checkout.user_does_not_exist'))); - } - - // Update the accessory data - $accessory->assigned_to = $request->input('assigned_to'); - + for ($i = 0; $i < $accessory->checkout_qty; $i++) { $accessory->users()->attach($accessory->id, [ 'accessory_id' => $accessory->id, 'created_at' => Carbon::now(), 'user_id' => Auth::id(), - 'assigned_to' => $request->get('assigned_to'), - 'note' => $request->get('note'), + 'assigned_to' => $request->input('assigned_to'), + 'note' => $request->input('note'), ]); - - event(new CheckoutableCheckedOut($accessory, $user, auth()->user(), $request->input('note'))); - - return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/accessories/message.checkout.success'))); } - return response()->json(Helper::formatStandardApiResponse('error', null, 'No accessories remaining')); + // Set this value to be able to pass the qty through to the event + event(new CheckoutableCheckedOut($accessory, $user, auth()->user(), $request->input('note'))); + + return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/accessories/message.checkout.success'))); } diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index e3419f247..73358454d 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -43,10 +43,12 @@ class Kernel extends HttpKernel \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, \App\Http\Middleware\AssetCountForSidebar::class, \Illuminate\Session\Middleware\AuthenticateSession::class, + \Illuminate\Routing\Middleware\SubstituteBindings::class, ], 'api' => [ 'auth:api', + \Illuminate\Routing\Middleware\SubstituteBindings::class, ], ]; diff --git a/app/Http/Requests/AccessoryCheckoutRequest.php b/app/Http/Requests/AccessoryCheckoutRequest.php new file mode 100644 index 000000000..0e17b390c --- /dev/null +++ b/app/Http/Requests/AccessoryCheckoutRequest.php @@ -0,0 +1,79 @@ +accessory) { + + $this->diff = ($this->accessory->numRemaining() - $this->checkout_qty); + $this->merge([ + 'checkout_qty' => $this->checkout_qty ?? 1, + 'number_remaining_after_checkout' => (int) ($this->accessory->numRemaining() - $this->checkout_qty), + 'number_currently_remaining' => (int) $this->accessory->numRemaining(), + 'checkout_difference' => (int) $this->diff, + ]); + + \Log::debug('---------------------------------------------'); + } + + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + + return array_merge( + [ + 'assigned_to' => [ + 'required', + 'integer', + 'exists:users,id,deleted_at,NULL', + 'not_array' + ], + + 'number_remaining_after_checkout' => [ + 'min:0', + 'required', + 'integer', + ], + + 'checkout_qty' => [ + 'integer', + 'lte:number_currently_remaining', + 'min:1', + ], + ], + ); + } + + public function messages(): array + { + $messages = [ + 'checkout_qty.lte' => trans_choice('admin/accessories/message.checkout.checkout_qty.lte', $this->number_currently_remaining, [ + 'number_currently_remaining' => $this->number_currently_remaining, + 'checkout_qty' => $this->checkout_qty, + ]), + ]; + return $messages; + } +} diff --git a/app/Http/Requests/StoreAccessoryRequest.php b/app/Http/Requests/StoreAccessoryRequest.php new file mode 100644 index 000000000..c41bae7b4 --- /dev/null +++ b/app/Http/Requests/StoreAccessoryRequest.php @@ -0,0 +1,56 @@ +category_id) { + if ($category = Category::find($this->category_id)) { + $this->merge([ + 'category_type' => $category->category_type ?? null, + ]); + } + } + + } + + /** + * Get the validation rules that apply to the request. + * + * @return array|string> + */ + public function rules(): array + { + return array_merge( + ['category_type' => 'in:accessory'], + parent::rules(), + ); + } + + public function messages(): array + { + $messages = ['category_type.in' => trans('admin/accessories/message.invalid_category_type')]; + return $messages; + } + + public function response(array $errors) + { + return $this->redirector->back()->withInput()->withErrors($errors, $this->errorBag); + } +} diff --git a/app/Models/Accessory.php b/app/Models/Accessory.php index a234b1e57..20d2584c3 100755 --- a/app/Models/Accessory.php +++ b/app/Models/Accessory.php @@ -63,7 +63,7 @@ class Accessory extends SnipeModel 'company_id' => 'integer|nullable', 'min_amt' => 'integer|min:0|nullable', 'purchase_cost' => 'numeric|nullable|gte:0', - 'purchase_date' => 'date_format:Y-m-d|nullable', + 'purchase_date' => 'date_format:Y-m-d|nullable', ]; @@ -329,11 +329,24 @@ class Accessory extends SnipeModel } + /** + * Check how many items within an accessory are checked out + * + * @author [A. Gianotto] [] + * @since [v5.0] + * @return int + */ + public function numCheckedOut() + { + return $this->users_count ?? $this->users()->count(); + } + + /** * Check how many items of an accessory remain. * * In order to use this model method, you MUST call withCount('users as users_count') - * on the eloquent query in the controller, otherwise $this->>users_count will be null and + * on the eloquent query in the controller, otherwise $this->users_count will be null and * bad things happen. * * @author [A. Gianotto] [] @@ -342,11 +355,11 @@ class Accessory extends SnipeModel */ public function numRemaining() { - $checkedout = $this->users_count; + $checkedout = $this->numCheckedOut(); $total = $this->qty; $remaining = $total - $checkedout; - return (int) $remaining; + return $remaining; } /** diff --git a/app/Notifications/CheckoutAccessoryNotification.php b/app/Notifications/CheckoutAccessoryNotification.php index 803b697e8..721ba7f6a 100644 --- a/app/Notifications/CheckoutAccessoryNotification.php +++ b/app/Notifications/CheckoutAccessoryNotification.php @@ -30,6 +30,7 @@ class CheckoutAccessoryNotification extends Notification $this->item = $accessory; $this->admin = $checkedOutBy; $this->note = $note; + $this->checkout_qty = $accessory->checkout_qty; $this->target = $checkedOutTo; $this->acceptance = $acceptance; $this->settings = Setting::getSettings(); @@ -107,7 +108,7 @@ class CheckoutAccessoryNotification extends Notification ->from($botname) ->to($channel) ->attachment(function ($attachment) use ($item, $note, $admin, $fields) { - $attachment->title(htmlspecialchars_decode($item->present()->name), $item->present()->viewUrl()) + $attachment->title(htmlspecialchars_decode($this->checkout_qty.' x '.$item->present()->name), $item->present()->viewUrl()) ->fields($fields) ->content($note); }); @@ -127,6 +128,7 @@ class CheckoutAccessoryNotification extends Notification ->addStartGroupToSection('activityText') ->fact(htmlspecialchars_decode($item->present()->name), '', 'activityTitle') ->fact(trans('mail.assigned_to'), $target->present()->name) + ->fact(trans('general.qty'), $this->checkout_qty) ->fact(trans('mail.checkedout_from'), $item->location->name ? $item->location->name : '') ->fact(trans('mail.Accessory_Checkout_Notification') . " by ", $admin->present()->fullName()) ->fact(trans('admin/consumables/general.remaining'), $item->numRemaining()) @@ -184,6 +186,7 @@ class CheckoutAccessoryNotification extends Notification 'eula' => $eula, 'req_accept' => $req_accept, 'accept_url' => $accept_url, + 'checkout_qty' => $this->checkout_qty, ]) ->subject(trans('mail.Confirm_accessory_delivery')); } diff --git a/resources/lang/en-US/admin/accessories/message.php b/resources/lang/en-US/admin/accessories/message.php index c688d5e03..f60d41957 100644 --- a/resources/lang/en-US/admin/accessories/message.php +++ b/resources/lang/en-US/admin/accessories/message.php @@ -26,7 +26,11 @@ return array( 'error' => 'Accessory was not checked out, please try again', 'success' => 'Accessory checked out successfully.', 'unavailable' => 'Accessory is not available for checkout. Check quantity available', - 'user_does_not_exist' => 'That user is invalid. Please try again.' + 'user_does_not_exist' => 'That user is invalid. Please try again.', + 'checkout_qty' => array( + 'lte' => 'There is currently only one available accessory of this type, and you are trying to check out :checkout_qty. Please adjust the checkout quantity or the total stock of this accessory and try again.|There are :number_currently_remaining total available accessories, and you are trying to check out :checkout_qty. Please adjust the checkout quantity or the total stock of this accessory and try again.', + ), + ), 'checkin' => array( diff --git a/resources/lang/en-US/validation.php b/resources/lang/en-US/validation.php index 05374e23a..f1a5b9ed0 100644 --- a/resources/lang/en-US/validation.php +++ b/resources/lang/en-US/validation.php @@ -13,87 +13,148 @@ return [ | */ - 'accepted' => 'The :attribute must be accepted.', - 'active_url' => 'The :attribute is not a valid URL.', - 'after' => 'The :attribute must be a date after :date.', - 'after_or_equal' => 'The :attribute must be a date after or equal to :date.', - 'alpha' => 'The :attribute may only contain letters.', - 'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.', - 'alpha_num' => 'The :attribute may only contain letters and numbers.', - 'array' => 'The :attribute must be an array.', - 'before' => 'The :attribute must be a date before :date.', - 'before_or_equal' => 'The :attribute must be a date before or equal to :date.', - 'between' => [ - 'numeric' => 'The :attribute must be between :min - :max.', - 'file' => 'The :attribute must be between :min - :max kilobytes.', - 'string' => 'The :attribute must be between :min - :max characters.', - 'array' => 'The :attribute must have between :min and :max items.', + 'accepted' => 'The :attribute field must be accepted.', + 'accepted_if' => 'The :attribute field must be accepted when :other is :value.', + 'active_url' => 'The :attribute field must be a valid URL.', + 'after' => 'The :attribute field must be a date after :date.', + 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.', + 'alpha' => 'The :attribute field must only contain letters.', + 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.', + 'alpha_num' => 'The :attribute field must only contain letters and numbers.', + 'array' => 'The :attribute field must be an array.', + 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.', + 'before' => 'The :attribute field must be a date before :date.', + 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.', + 'between' => [ + 'array' => 'The :attribute field must have between :min and :max items.', + 'file' => 'The :attribute field must be between :min and :max kilobytes.', + 'numeric' => 'The :attribute field must be between :min and :max.', + 'string' => 'The :attribute field must be between :min and :max characters.', ], - 'boolean' => 'The :attribute must be true or false.', - 'confirmed' => 'The :attribute confirmation does not match.', - 'date' => 'The :attribute is not a valid date.', - 'date_format' => 'The :attribute does not match the format :format.', - 'different' => 'The :attribute and :other must be different.', - 'digits' => 'The :attribute must be :digits digits.', - 'digits_between' => 'The :attribute must be between :min and :max digits.', - 'dimensions' => 'The :attribute has invalid image dimensions.', - 'distinct' => 'The :attribute field has a duplicate value.', - 'email' => 'The :attribute format is invalid.', - 'exists' => 'The selected :attribute is invalid.', - 'file' => 'The :attribute must be a file.', - 'filled' => 'The :attribute field must have a value.', - 'image' => 'The :attribute must be an image.', + 'boolean' => 'The :attribute field must be true or false.', + 'can' => 'The :attribute field contains an unauthorized value.', + 'confirmed' => 'The :attribute field confirmation does not match.', + 'contains' => 'The :attribute field is missing a required value.', + 'current_password' => 'The password is incorrect.', + 'date' => 'The :attribute field must be a valid date.', + 'date_equals' => 'The :attribute field must be a date equal to :date.', + 'date_format' => 'The :attribute field must match the format :format.', + 'decimal' => 'The :attribute field must have :decimal decimal places.', + 'declined' => 'The :attribute field must be declined.', + 'declined_if' => 'The :attribute field must be declined when :other is :value.', + 'different' => 'The :attribute field and :other must be different.', + 'digits' => 'The :attribute field must be :digits digits.', + 'digits_between' => 'The :attribute field must be between :min and :max digits.', + 'dimensions' => 'The :attribute field has invalid image dimensions.', + 'distinct' => 'The :attribute field has a duplicate value.', + 'doesnt_end_with' => 'The :attribute field must not end with one of the following: :values.', + 'doesnt_start_with' => 'The :attribute field must not start with one of the following: :values.', + 'email' => 'The :attribute field must be a valid email address.', + 'ends_with' => 'The :attribute field must end with one of the following: :values.', + 'enum' => 'The selected :attribute is invalid.', + 'exists' => 'The selected :attribute is invalid.', + 'extensions' => 'The :attribute field must have one of the following extensions: :values.', + 'file' => 'The :attribute field must be a file.', + 'filled' => 'The :attribute field must have a value.', + 'gt' => [ + 'array' => 'The :attribute field must have more than :value items.', + 'file' => 'The :attribute field must be greater than :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than :value.', + 'string' => 'The :attribute field must be greater than :value characters.', + ], + 'gte' => [ + 'array' => 'The :attribute field must have :value items or more.', + 'file' => 'The :attribute field must be greater than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be greater than or equal to :value.', + 'string' => 'The :attribute field must be greater than or equal to :value characters.', + ], + 'hex_color' => 'The :attribute field must be a valid hexadecimal color.', + 'image' => 'The :attribute field must be an image.', 'import_field_empty' => 'The value for :fieldname cannot be null.', - 'in' => 'The selected :attribute is invalid.', - 'in_array' => 'The :attribute field does not exist in :other.', - 'integer' => 'The :attribute must be an integer.', - 'ip' => 'The :attribute must be a valid IP address.', - 'ipv4' => 'The :attribute must be a valid IPv4 address.', - 'ipv6' => 'The :attribute must be a valid IPv6 address.', - 'is_unique_department' => 'The :attribute must be unique to this Company Location', - 'json' => 'The :attribute must be a valid JSON string.', - 'max' => [ - 'numeric' => 'The :attribute may not be greater than :max.', - 'file' => 'The :attribute may not be greater than :max kilobytes.', - 'string' => 'The :attribute may not be greater than :max characters.', - 'array' => 'The :attribute may not have more than :max items.', + 'in' => 'The selected :attribute is invalid.', + 'in_array' => 'The :attribute field must exist in :other.', + 'integer' => 'The :attribute field must be an integer.', + 'ip' => 'The :attribute field must be a valid IP address.', + 'ipv4' => 'The :attribute field must be a valid IPv4 address.', + 'ipv6' => 'The :attribute field must be a valid IPv6 address.', + 'json' => 'The :attribute field must be a valid JSON string.', + 'list' => 'The :attribute field must be a list.', + 'lowercase' => 'The :attribute field must be lowercase.', + 'lt' => [ + 'array' => 'The :attribute field must have less than :value items.', + 'file' => 'The :attribute field must be less than :value kilobytes.', + 'numeric' => 'The :attribute field must be less than :value.', + 'string' => 'The :attribute field must be less than :value characters.', ], - 'mimes' => 'The :attribute must be a file of type: :values.', - 'mimetypes' => 'The :attribute must be a file of type: :values.', - 'min' => [ - 'numeric' => 'The :attribute must be at least :min.', - 'file' => 'The :attribute must be at least :min kilobytes.', - 'string' => 'The :attribute must be at least :min characters.', - 'array' => 'The :attribute must have at least :min items.', + 'lte' => [ + 'array' => 'The :attribute field must not have more than :value items.', + 'file' => 'The :attribute field must be less than or equal to :value kilobytes.', + 'numeric' => 'The :attribute field must be less than or equal to :value.', + 'string' => 'The :attribute field must be less than or equal to :value characters.', ], - 'starts_with' => 'The :attribute must start with one of the following: :values.', - 'ends_with' => 'The :attribute must end with one of the following: :values.', - - 'not_in' => 'The selected :attribute is invalid.', - 'numeric' => 'The :attribute must be a number.', - 'present' => 'The :attribute field must be present.', - 'valid_regex' => 'That is not a valid regex. ', - 'regex' => 'The :attribute format is invalid.', - 'required' => 'The :attribute field is required.', - 'required_if' => 'The :attribute field is required when :other is :value.', - 'required_unless' => 'The :attribute field is required unless :other is in :values.', - 'required_with' => 'The :attribute field is required when :values is present.', - 'required_with_all' => 'The :attribute field is required when :values is present.', - 'required_without' => 'The :attribute field is required when :values is not present.', + 'mac_address' => 'The :attribute field must be a valid MAC address.', + 'max' => [ + 'array' => 'The :attribute field must not have more than :max items.', + 'file' => 'The :attribute field must not be greater than :max kilobytes.', + 'numeric' => 'The :attribute field must not be greater than :max.', + 'string' => 'The :attribute field must not be greater than :max characters.', + ], + 'max_digits' => 'The :attribute field must not have more than :max digits.', + 'mimes' => 'The :attribute field must be a file of type: :values.', + 'mimetypes' => 'The :attribute field must be a file of type: :values.', + 'min' => [ + 'array' => 'The :attribute field must have at least :min items.', + 'file' => 'The :attribute field must be at least :min kilobytes.', + 'numeric' => 'The :attribute field must be at least :min.', + 'string' => 'The :attribute field must be at least :min characters.', + ], + 'min_digits' => 'The :attribute field must have at least :min digits.', + 'missing' => 'The :attribute field must be missing.', + 'missing_if' => 'The :attribute field must be missing when :other is :value.', + 'missing_unless' => 'The :attribute field must be missing unless :other is :value.', + 'missing_with' => 'The :attribute field must be missing when :values is present.', + 'missing_with_all' => 'The :attribute field must be missing when :values are present.', + 'multiple_of' => 'The :attribute field must be a multiple of :value.', + 'not_in' => 'The selected :attribute is invalid.', + 'not_regex' => 'The :attribute field format is invalid.', + 'numeric' => 'The :attribute field must be a number.', + 'password' => [ + 'letters' => 'The :attribute field must contain at least one letter.', + 'mixed' => 'The :attribute field must contain at least one uppercase and one lowercase letter.', + 'numbers' => 'The :attribute field must contain at least one number.', + 'symbols' => 'The :attribute field must contain at least one symbol.', + 'uncompromised' => 'The given :attribute has appeared in a data leak. Please choose a different :attribute.', + ], + 'present' => 'The :attribute field must be present.', + 'present_if' => 'The :attribute field must be present when :other is :value.', + 'present_unless' => 'The :attribute field must be present unless :other is :value.', + 'present_with' => 'The :attribute field must be present when :values is present.', + 'present_with_all' => 'The :attribute field must be present when :values are present.', + 'prohibited' => 'The :attribute field is prohibited.', + 'prohibited_if' => 'The :attribute field is prohibited when :other is :value.', + 'prohibited_unless' => 'The :attribute field is prohibited unless :other is in :values.', + 'prohibits' => 'The :attribute field prohibits :other from being present.', + 'regex' => 'The :attribute field format is invalid.', + 'required' => 'The :attribute field is required.', + 'required_array_keys' => 'The :attribute field must contain entries for: :values.', + 'required_if' => 'The :attribute field is required when :other is :value.', + 'required_if_accepted' => 'The :attribute field is required when :other is accepted.', + 'required_if_declined' => 'The :attribute field is required when :other is declined.', + 'required_unless' => 'The :attribute field is required unless :other is in :values.', + 'required_with' => 'The :attribute field is required when :values is present.', + 'required_with_all' => 'The :attribute field is required when :values are present.', + 'required_without' => 'The :attribute field is required when :values is not present.', 'required_without_all' => 'The :attribute field is required when none of :values are present.', - 'same' => 'The :attribute and :other must match.', - 'size' => [ - 'numeric' => 'The :attribute must be :size.', - 'file' => 'The :attribute must be :size kilobytes.', - 'string' => 'The :attribute must be :size characters.', - 'array' => 'The :attribute must contain :size items.', + 'same' => 'The :attribute field must match :other.', + 'size' => [ + 'array' => 'The :attribute field must contain :size items.', + 'file' => 'The :attribute field must be :size kilobytes.', + 'numeric' => 'The :attribute field must be :size.', + 'string' => 'The :attribute field must be :size characters.', ], + 'starts_with' => 'The :attribute field must start with one of the following: :values.', 'string' => 'The :attribute must be a string.', - 'timezone' => 'The :attribute must be a valid zone.', 'two_column_unique_undeleted' => 'The :attribute must be unique across :table1 and :table2. ', - 'unique' => 'The :attribute has already been taken.', - 'uploaded' => 'The :attribute failed to upload.', - 'url' => 'The :attribute format is invalid.', 'unique_undeleted' => 'The :attribute must be unique.', 'non_circular' => 'The :attribute must not create a circular reference.', 'not_array' => ':attribute cannot be an array.', @@ -102,12 +163,13 @@ return [ 'numbers' => 'Password must contain at least one number.', 'case_diff' => 'Password must use mixed case.', 'symbols' => 'Password must contain symbols.', - 'gte' => [ - 'numeric' => 'Value cannot be negative' - ], - 'checkboxes' => ':attribute contains invalid options.', - 'radio_buttons' => ':attribute is invalid.', - + 'timezone' => 'The :attribute field must be a valid timezone.', + 'unique' => 'The :attribute has already been taken.', + 'uploaded' => 'The :attribute failed to upload.', + 'uppercase' => 'The :attribute field must be uppercase.', + 'url' => 'The :attribute field must be a valid URL.', + 'ulid' => 'The :attribute field must be a valid ULID.', + 'uuid' => 'The :attribute field must be a valid UUID.', /* |-------------------------------------------------------------------------- @@ -129,7 +191,7 @@ return [ // date_format validation with slightly less stupid messages. It duplicates a lot, but it gets the job done :( // We use this because the default error message for date_format is reflects php Y-m-d, which non-PHP - // people won't know how to format. + // people won't know how to format. 'purchase_date.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD format', 'last_audit_date.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD hh:mm:ss format', 'expiration_date.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD format', @@ -137,9 +199,10 @@ return [ 'expected_checkin.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD format', 'start_date.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD format', 'end_date.date_format' => 'The :attribute must be a valid date in YYYY-MM-DD format', - - ], - + 'checkboxes' => ':attribute contains invalid options.', + 'radio_buttons' => ':attribute is invalid.', + 'invalid_value_in_field' => 'Invalid value included in this field', + ], /* |-------------------------------------------------------------------------- | Custom Validation Attributes @@ -158,5 +221,5 @@ return [ | Generic Validation Messages |-------------------------------------------------------------------------- */ - 'invalid_value_in_field' => 'Invalid value included in this field', + ]; diff --git a/resources/views/accessories/checkout.blade.php b/resources/views/accessories/checkout.blade.php index fc69d4655..71035a87c 100755 --- a/resources/views/accessories/checkout.blade.php +++ b/resources/views/accessories/checkout.blade.php @@ -49,9 +49,35 @@ @endif + +
+ +
+

{{ $accessory->qty }}

+
+
+ + +
+ +
+

{{ $accessory->numRemaining() }}

+
+
- @include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to']) + @include ('partials.forms.edit.user-select', ['translated_name' => trans('general.select_user'), 'fieldname' => 'assigned_to', 'required'=> 'true']) + + +
+ +
+
+ +
+
+ {!! $errors->first('checkout_qty', '
') !!} +
@if ($accessory->requireAcceptance() || $accessory->getEula() || ($snipeSettings->webhook_endpoint!='')) diff --git a/resources/views/notifications/markdown/checkout-accessory.blade.php b/resources/views/notifications/markdown/checkout-accessory.blade.php index a3126f7aa..836f57413 100644 --- a/resources/views/notifications/markdown/checkout-accessory.blade.php +++ b/resources/views/notifications/markdown/checkout-accessory.blade.php @@ -20,6 +20,9 @@ @if (isset($item->model_no)) | **{{ trans('general.model_no') }}** | {{ $item->model_no }} | @endif +@if (isset($checkout_qty)) +| **{{ trans('general.qty') }}** | {{ $checkout_qty }} | +@endif @if ($note) | **{{ trans('mail.additional_notes') }}** | {{ $note }} | @endif diff --git a/routes/web/accessories.php b/routes/web/accessories.php index 35ec73e7d..1f28892a0 100644 --- a/routes/web/accessories.php +++ b/routes/web/accessories.php @@ -13,7 +13,7 @@ Route::group(['prefix' => 'accessories', 'middleware' => ['auth']], function () )->name('accessories.checkout.show'); Route::post( - '{accessoryID}/checkout', + '{accessory}/checkout', [Accessories\AccessoryCheckoutController::class, 'store'] )->name('accessories.checkout.store'); diff --git a/tests/Feature/Checkouts/Api/AccessoryCheckoutTest.php b/tests/Feature/Checkouts/Api/AccessoryCheckoutTest.php index e6128aa78..3f99f67eb 100644 --- a/tests/Feature/Checkouts/Api/AccessoryCheckoutTest.php +++ b/tests/Feature/Checkouts/Api/AccessoryCheckoutTest.php @@ -33,20 +33,110 @@ class AccessoryCheckoutTest extends TestCase ->postJson(route('api.accessories.checkout', Accessory::factory()->withoutItemsRemaining()->create()), [ 'assigned_to' => User::factory()->create()->id, ]) - ->assertStatusMessageIs('error'); + ->assertOk() + ->assertStatusMessageIs('error') + ->assertJson( + [ + 'status' => 'error', + 'messages' => + [ + 'checkout_qty' => + [ + trans_choice('admin/accessories/message.checkout.checkout_qty.lte', 0, + [ + 'number_currently_remaining' => 0, + 'checkout_qty' => 1, + 'number_remaining_after_checkout' => 0 + ]) + ], + + ], + 'payload' => null, + ]) + ->assertStatus(200) + ->json(); } - public function testAccessoryCanBeCheckedOut() + public function testAccessoryCanBeCheckedOutWithoutQty() + { + $accessory = Accessory::factory()->create(); + $user = User::factory()->create(); + $admin = User::factory()->checkoutAccessories()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.accessories.checkout', $accessory), [ + 'assigned_to' => $user->id, + ]) + ->assertOk() + ->assertStatusMessageIs('success') + ->assertStatus(200) + ->assertJson(['messages' => trans('admin/accessories/message.checkout.success')]) + ->json(); + + $this->assertTrue($accessory->users->contains($user)); + + $this->assertEquals( + 1, + Actionlog::where([ + 'action_type' => 'checkout', + 'target_id' => $user->id, + 'target_type' => User::class, + 'item_id' => $accessory->id, + 'item_type' => Accessory::class, + 'user_id' => $admin->id, + ])->count(),'Log entry either does not exist or there are more than expected' + ); + } + + public function testAccessoryCanBeCheckedOutWithQty() + { + $accessory = Accessory::factory()->create(['qty' => 20]); + $user = User::factory()->create(); + $admin = User::factory()->checkoutAccessories()->create(); + + $this->actingAsForApi($admin) + ->postJson(route('api.accessories.checkout', $accessory), [ + 'assigned_to' => $user->id, + 'checkout_qty' => 2, + ]) + ->assertOk() + ->assertStatusMessageIs('success') + ->assertStatus(200) + ->assertJson(['messages' => trans('admin/accessories/message.checkout.success')]) + ->json(); + + $this->assertTrue($accessory->users->contains($user)); + + $this->assertEquals( + 1, + Actionlog::where([ + 'action_type' => 'checkout', + 'target_id' => $user->id, + 'target_type' => User::class, + 'item_id' => $accessory->id, + 'item_type' => Accessory::class, + 'user_id' => $admin->id, + ])->count(), + 'Log entry either does not exist or there are more than expected' + ); + } + + public function testAccessoryCannotBeCheckedOutToInvalidUser() { $accessory = Accessory::factory()->create(); $user = User::factory()->create(); $this->actingAsForApi(User::factory()->checkoutAccessories()->create()) ->postJson(route('api.accessories.checkout', $accessory), [ - 'assigned_to' => $user->id, - ]); + 'assigned_to' => 'invalid-user-id', + 'note' => 'oh hi there', + ]) + ->assertOk() + ->assertStatusMessageIs('error') + ->assertStatus(200) + ->json(); - $this->assertTrue($accessory->users->contains($user)); + $this->assertFalse($accessory->users->contains($user)); } public function testUserSentNotificationUponCheckout() diff --git a/tests/Feature/Checkouts/Ui/AccessoryCheckoutTest.php b/tests/Feature/Checkouts/Ui/AccessoryCheckoutTest.php index 8f97a4831..e0af379db 100644 --- a/tests/Feature/Checkouts/Ui/AccessoryCheckoutTest.php +++ b/tests/Feature/Checkouts/Ui/AccessoryCheckoutTest.php @@ -20,23 +20,36 @@ class AccessoryCheckoutTest extends TestCase public function testValidationWhenCheckingOutAccessory() { - $this->actingAs(User::factory()->checkoutAccessories()->create()) - ->post(route('accessories.checkout.store', Accessory::factory()->create()), [ + $accessory = Accessory::factory()->create(); + $response = $this->actingAs(User::factory()->superuser()->create()) + ->from(route('accessories.checkout.show', $accessory)) + ->post(route('accessories.checkout.store', $accessory), [ // missing assigned_to ]) - ->assertSessionHas('error'); + ->assertStatus(302) + ->assertSessionHas('errors') + ->assertRedirect(route('accessories.checkout.store', $accessory)); + + $this->followRedirects($response)->assertSee(trans('general.error')); } - public function testAccessoryMustBeAvailableWhenCheckingOut() + public function testAccessoryMustHaveAvailableItemsForCheckoutWhenCheckingOut() { - $this->actingAs(User::factory()->checkoutAccessories()->create()) - ->post(route('accessories.checkout.store', Accessory::factory()->withoutItemsRemaining()->create()), [ + + $accessory = Accessory::factory()->withoutItemsRemaining()->create(); + $response = $this->actingAs(User::factory()->viewAccessories()->checkoutAccessories()->create()) + ->from(route('accessories.checkout.show', $accessory)) + ->post(route('accessories.checkout.store', $accessory), [ 'assigned_to' => User::factory()->create()->id, ]) - ->assertSessionHas('error'); + ->assertStatus(302) + ->assertSessionHas('errors') + ->assertRedirect(route('accessories.checkout.store', $accessory)); + $response->assertInvalid(['checkout_qty']); + $this->followRedirects($response)->assertSee(trans('general.error')); } - public function testAccessoryCanBeCheckedOut() + public function testAccessoryCanBeCheckedOutWithoutQuantity() { $accessory = Accessory::factory()->create(); $user = User::factory()->create(); @@ -44,9 +57,44 @@ class AccessoryCheckoutTest extends TestCase $this->actingAs(User::factory()->checkoutAccessories()->create()) ->post(route('accessories.checkout.store', $accessory), [ 'assigned_to' => $user->id, + 'note' => 'oh hi there', ]); $this->assertTrue($accessory->users->contains($user)); + + $this->assertDatabaseHas('action_logs', [ + 'action_type' => 'checkout', + 'target_id' => $user->id, + 'target_type' => User::class, + 'item_id' => $accessory->id, + 'item_type' => Accessory::class, + 'note' => 'oh hi there', + ]); + } + + public function testAccessoryCanBeCheckedOutWithQuantity() + { + $accessory = Accessory::factory()->create(['qty'=>5]); + $user = User::factory()->create(); + + $this->actingAs(User::factory()->checkoutAccessories()->create()) + ->from(route('accessories.checkout.show', $accessory)) + ->post(route('accessories.checkout.store', $accessory), [ + 'assigned_to' => $user->id, + 'checkout_qty' => 3, + 'note' => 'oh hi there', + ]); + + $this->assertTrue($accessory->users->contains($user)); + + $this->assertDatabaseHas('action_logs', [ + 'action_type' => 'checkout', + 'target_id' => $user->id, + 'target_type' => User::class, + 'item_id' => $accessory->id, + 'item_type' => Accessory::class, + 'note' => 'oh hi there', + ]); } public function testUserSentNotificationUponCheckout() @@ -57,6 +105,7 @@ class AccessoryCheckoutTest extends TestCase $user = User::factory()->create(); $this->actingAs(User::factory()->checkoutAccessories()->create()) + ->from(route('accessories.checkout.show', $accessory)) ->post(route('accessories.checkout.store', $accessory), [ 'assigned_to' => $user->id, ]); @@ -71,6 +120,7 @@ class AccessoryCheckoutTest extends TestCase $user = User::factory()->create(); $this->actingAs($actor) + ->from(route('accessories.checkout.show', $accessory)) ->post(route('accessories.checkout.store', $accessory), [ 'assigned_to' => $user->id, 'note' => 'oh hi there',