Merge pull request #15114 from snipe/checkout_multiple_accessories

Checkout multiple of an accessory in one checkout
This commit is contained in:
snipe 2024-07-18 17:38:19 +01:00 committed by GitHub
commit 4465aef991
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 532 additions and 168 deletions

View file

@ -4,12 +4,12 @@ namespace App\Http\Controllers\Accessories;
use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutableCheckedOut;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Models\Accessory; use App\Models\Accessory;
use App\Models\User; use App\Models\User;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use \Illuminate\Contracts\View\View; use \Illuminate\Contracts\View\View;
use \Illuminate\Http\RedirectResponse; use \Illuminate\Http\RedirectResponse;
@ -57,44 +57,29 @@ class AccessoryCheckoutController extends Controller
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @param Request $request * @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); $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'))) { for ($i = 0; $i < $accessory->checkout_qty; $i++) {
return redirect()->route('accessories.checkout.show', $accessory->id)->with('error', trans('admin/accessories/message.checkout.user_does_not_exist')); $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'))); event(new CheckoutableCheckedOut($accessory, $user, auth()->user(), $request->input('note')));
// Redirect to the new accessory page // 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'));
} }
} }

View file

@ -5,6 +5,8 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut; use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper; use App\Helpers\Helper;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Transformers\AccessoriesTransformer; use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\SelectlistTransformer; use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory; use App\Models\Accessory;
@ -121,12 +123,12 @@ class AccessoriesController extends Controller
/** /**
* Store a newly created resource in storage. * Store a newly created resource in storage.
* *
* @param \App\Http\Requests\ImageUploadRequest $request
* @return \Illuminate\Http\JsonResponse
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @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); $this->authorize('create', Accessory::class);
$accessory = new Accessory; $accessory = new Accessory;
@ -144,10 +146,10 @@ class AccessoriesController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
* *
* @param int $id
* @return array
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id
* @return \Illuminate\Http\Response
*/ */
public function show($id) public function show($id)
{ {
@ -161,10 +163,10 @@ class AccessoriesController extends Controller
/** /**
* Display the specified resource. * Display the specified resource.
* *
* @param int $id
* @return array
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
* @since [v4.0] * @since [v4.0]
* @param int $id
* @return \Illuminate\Http\Response
*/ */
public function accessory_detail($id) 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 * If Slack is enabled and/or asset acceptance is enabled, it will also
* trigger a Slack message and send an email. * trigger a Slack message and send an email.
* *
* @author [A. Gianotto] [<snipe@snipe.net>]
* @param int $accessoryId * @param int $accessoryId
* @return \Illuminate\Http\RedirectResponse * @return \Illuminate\Http\JsonResponse
* @author [A. Gianotto] [<snipe@snipe.net>]
*/ */
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); $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);
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
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');
$accessory->users()->attach($accessory->id, [ $accessory->users()->attach($accessory->id, [
'accessory_id' => $accessory->id, 'accessory_id' => $accessory->id,
'created_at' => Carbon::now(), 'created_at' => Carbon::now(),
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'assigned_to' => $request->get('assigned_to'), 'assigned_to' => $request->input('assigned_to'),
'note' => $request->get('note'), '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')));
} }

View file

@ -43,10 +43,12 @@ class Kernel extends HttpKernel
\Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class,
\App\Http\Middleware\AssetCountForSidebar::class, \App\Http\Middleware\AssetCountForSidebar::class,
\Illuminate\Session\Middleware\AuthenticateSession::class, \Illuminate\Session\Middleware\AuthenticateSession::class,
\Illuminate\Routing\Middleware\SubstituteBindings::class,
], ],
'api' => [ 'api' => [
'auth:api', 'auth:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
], ],
]; ];

View file

@ -0,0 +1,79 @@
<?php
namespace App\Http\Requests;
use App\Models\Accessory;
use Illuminate\Support\Facades\Gate;
class AccessoryCheckoutRequest extends ImageUploadRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Gate::allows('checkout', new Accessory);
}
public function prepareForValidation(): void
{
if ($this->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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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;
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use App\Models\Accessory;
use App\Models\Category;
use Illuminate\Support\Facades\Gate;
class StoreAccessoryRequest extends ImageUploadRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return Gate::allows('create', new Accessory);
}
public function prepareForValidation(): void
{
if ($this->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, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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);
}
}

View file

@ -63,7 +63,7 @@ class Accessory extends SnipeModel
'company_id' => 'integer|nullable', 'company_id' => 'integer|nullable',
'min_amt' => 'integer|min:0|nullable', 'min_amt' => 'integer|min:0|nullable',
'purchase_cost' => 'numeric|nullable|gte:0', '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] [<snipe@snipe.net>]
* @since [v5.0]
* @return int
*/
public function numCheckedOut()
{
return $this->users_count ?? $this->users()->count();
}
/** /**
* Check how many items of an accessory remain. * Check how many items of an accessory remain.
* *
* In order to use this model method, you MUST call withCount('users as users_count') * 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. * bad things happen.
* *
* @author [A. Gianotto] [<snipe@snipe.net>] * @author [A. Gianotto] [<snipe@snipe.net>]
@ -342,11 +355,11 @@ class Accessory extends SnipeModel
*/ */
public function numRemaining() public function numRemaining()
{ {
$checkedout = $this->users_count; $checkedout = $this->numCheckedOut();
$total = $this->qty; $total = $this->qty;
$remaining = $total - $checkedout; $remaining = $total - $checkedout;
return (int) $remaining; return $remaining;
} }
/** /**

View file

@ -30,6 +30,7 @@ class CheckoutAccessoryNotification extends Notification
$this->item = $accessory; $this->item = $accessory;
$this->admin = $checkedOutBy; $this->admin = $checkedOutBy;
$this->note = $note; $this->note = $note;
$this->checkout_qty = $accessory->checkout_qty;
$this->target = $checkedOutTo; $this->target = $checkedOutTo;
$this->acceptance = $acceptance; $this->acceptance = $acceptance;
$this->settings = Setting::getSettings(); $this->settings = Setting::getSettings();
@ -107,7 +108,7 @@ class CheckoutAccessoryNotification extends Notification
->from($botname) ->from($botname)
->to($channel) ->to($channel)
->attachment(function ($attachment) use ($item, $note, $admin, $fields) { ->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) ->fields($fields)
->content($note); ->content($note);
}); });
@ -127,6 +128,7 @@ class CheckoutAccessoryNotification extends Notification
->addStartGroupToSection('activityText') ->addStartGroupToSection('activityText')
->fact(htmlspecialchars_decode($item->present()->name), '', 'activityTitle') ->fact(htmlspecialchars_decode($item->present()->name), '', 'activityTitle')
->fact(trans('mail.assigned_to'), $target->present()->name) ->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.checkedout_from'), $item->location->name ? $item->location->name : '')
->fact(trans('mail.Accessory_Checkout_Notification') . " by ", $admin->present()->fullName()) ->fact(trans('mail.Accessory_Checkout_Notification') . " by ", $admin->present()->fullName())
->fact(trans('admin/consumables/general.remaining'), $item->numRemaining()) ->fact(trans('admin/consumables/general.remaining'), $item->numRemaining())
@ -184,6 +186,7 @@ class CheckoutAccessoryNotification extends Notification
'eula' => $eula, 'eula' => $eula,
'req_accept' => $req_accept, 'req_accept' => $req_accept,
'accept_url' => $accept_url, 'accept_url' => $accept_url,
'checkout_qty' => $this->checkout_qty,
]) ])
->subject(trans('mail.Confirm_accessory_delivery')); ->subject(trans('mail.Confirm_accessory_delivery'));
} }

View file

@ -26,7 +26,11 @@ return array(
'error' => 'Accessory was not checked out, please try again', 'error' => 'Accessory was not checked out, please try again',
'success' => 'Accessory checked out successfully.', 'success' => 'Accessory checked out successfully.',
'unavailable' => 'Accessory is not available for checkout. Check quantity available', '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( 'checkin' => array(

View file

@ -13,87 +13,148 @@ return [
| |
*/ */
'accepted' => 'The :attribute must be accepted.', 'accepted' => 'The :attribute field must be accepted.',
'active_url' => 'The :attribute is not a valid URL.', 'accepted_if' => 'The :attribute field must be accepted when :other is :value.',
'after' => 'The :attribute must be a date after :date.', 'active_url' => 'The :attribute field must be a valid URL.',
'after_or_equal' => 'The :attribute must be a date after or equal to :date.', 'after' => 'The :attribute field must be a date after :date.',
'alpha' => 'The :attribute may only contain letters.', 'after_or_equal' => 'The :attribute field must be a date after or equal to :date.',
'alpha_dash' => 'The :attribute may only contain letters, numbers, and dashes.', 'alpha' => 'The :attribute field must only contain letters.',
'alpha_num' => 'The :attribute may only contain letters and numbers.', 'alpha_dash' => 'The :attribute field must only contain letters, numbers, dashes, and underscores.',
'array' => 'The :attribute must be an array.', 'alpha_num' => 'The :attribute field must only contain letters and numbers.',
'before' => 'The :attribute must be a date before :date.', 'array' => 'The :attribute field must be an array.',
'before_or_equal' => 'The :attribute must be a date before or equal to :date.', 'ascii' => 'The :attribute field must only contain single-byte alphanumeric characters and symbols.',
'between' => [ 'before' => 'The :attribute field must be a date before :date.',
'numeric' => 'The :attribute must be between :min - :max.', 'before_or_equal' => 'The :attribute field must be a date before or equal to :date.',
'file' => 'The :attribute must be between :min - :max kilobytes.', 'between' => [
'string' => 'The :attribute must be between :min - :max characters.', 'array' => 'The :attribute field must have between :min and :max items.',
'array' => 'The :attribute 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.', 'boolean' => 'The :attribute field must be true or false.',
'confirmed' => 'The :attribute confirmation does not match.', 'can' => 'The :attribute field contains an unauthorized value.',
'date' => 'The :attribute is not a valid date.', 'confirmed' => 'The :attribute field confirmation does not match.',
'date_format' => 'The :attribute does not match the format :format.', 'contains' => 'The :attribute field is missing a required value.',
'different' => 'The :attribute and :other must be different.', 'current_password' => 'The password is incorrect.',
'digits' => 'The :attribute must be :digits digits.', 'date' => 'The :attribute field must be a valid date.',
'digits_between' => 'The :attribute must be between :min and :max digits.', 'date_equals' => 'The :attribute field must be a date equal to :date.',
'dimensions' => 'The :attribute has invalid image dimensions.', 'date_format' => 'The :attribute field must match the format :format.',
'distinct' => 'The :attribute field has a duplicate value.', 'decimal' => 'The :attribute field must have :decimal decimal places.',
'email' => 'The :attribute format is invalid.', 'declined' => 'The :attribute field must be declined.',
'exists' => 'The selected :attribute is invalid.', 'declined_if' => 'The :attribute field must be declined when :other is :value.',
'file' => 'The :attribute must be a file.', 'different' => 'The :attribute field and :other must be different.',
'filled' => 'The :attribute field must have a value.', 'digits' => 'The :attribute field must be :digits digits.',
'image' => 'The :attribute must be an image.', '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.', 'import_field_empty' => 'The value for :fieldname cannot be null.',
'in' => 'The selected :attribute is invalid.', 'in' => 'The selected :attribute is invalid.',
'in_array' => 'The :attribute field does not exist in :other.', 'in_array' => 'The :attribute field must exist in :other.',
'integer' => 'The :attribute must be an integer.', 'integer' => 'The :attribute field must be an integer.',
'ip' => 'The :attribute must be a valid IP address.', 'ip' => 'The :attribute field must be a valid IP address.',
'ipv4' => 'The :attribute must be a valid IPv4 address.', 'ipv4' => 'The :attribute field must be a valid IPv4 address.',
'ipv6' => 'The :attribute must be a valid IPv6 address.', 'ipv6' => 'The :attribute field must be a valid IPv6 address.',
'is_unique_department' => 'The :attribute must be unique to this Company Location', 'json' => 'The :attribute field must be a valid JSON string.',
'json' => 'The :attribute must be a valid JSON string.', 'list' => 'The :attribute field must be a list.',
'max' => [ 'lowercase' => 'The :attribute field must be lowercase.',
'numeric' => 'The :attribute may not be greater than :max.', 'lt' => [
'file' => 'The :attribute may not be greater than :max kilobytes.', 'array' => 'The :attribute field must have less than :value items.',
'string' => 'The :attribute may not be greater than :max characters.', 'file' => 'The :attribute field must be less than :value kilobytes.',
'array' => 'The :attribute may not have more than :max items.', '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.', 'lte' => [
'mimetypes' => 'The :attribute must be a file of type: :values.', 'array' => 'The :attribute field must not have more than :value items.',
'min' => [ 'file' => 'The :attribute field must be less than or equal to :value kilobytes.',
'numeric' => 'The :attribute must be at least :min.', 'numeric' => 'The :attribute field must be less than or equal to :value.',
'file' => 'The :attribute must be at least :min kilobytes.', 'string' => 'The :attribute field must be less than or equal to :value characters.',
'string' => 'The :attribute must be at least :min characters.',
'array' => 'The :attribute must have at least :min items.',
], ],
'starts_with' => 'The :attribute must start with one of the following: :values.', 'mac_address' => 'The :attribute field must be a valid MAC address.',
'ends_with' => 'The :attribute must end with one of the following: :values.', 'max' => [
'array' => 'The :attribute field must not have more than :max items.',
'not_in' => 'The selected :attribute is invalid.', 'file' => 'The :attribute field must not be greater than :max kilobytes.',
'numeric' => 'The :attribute must be a number.', 'numeric' => 'The :attribute field must not be greater than :max.',
'present' => 'The :attribute field must be present.', 'string' => 'The :attribute field must not be greater than :max characters.',
'valid_regex' => 'That is not a valid regex. ', ],
'regex' => 'The :attribute format is invalid.', 'max_digits' => 'The :attribute field must not have more than :max digits.',
'required' => 'The :attribute field is required.', 'mimes' => 'The :attribute field must be a file of type: :values.',
'required_if' => 'The :attribute field is required when :other is :value.', 'mimetypes' => 'The :attribute field must be a file of type: :values.',
'required_unless' => 'The :attribute field is required unless :other is in :values.', 'min' => [
'required_with' => 'The :attribute field is required when :values is present.', 'array' => 'The :attribute field must have at least :min items.',
'required_with_all' => 'The :attribute field is required when :values is present.', 'file' => 'The :attribute field must be at least :min kilobytes.',
'required_without' => 'The :attribute field is required when :values is not present.', '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.', 'required_without_all' => 'The :attribute field is required when none of :values are present.',
'same' => 'The :attribute and :other must match.', 'same' => 'The :attribute field must match :other.',
'size' => [ 'size' => [
'numeric' => 'The :attribute must be :size.', 'array' => 'The :attribute field must contain :size items.',
'file' => 'The :attribute must be :size kilobytes.', 'file' => 'The :attribute field must be :size kilobytes.',
'string' => 'The :attribute must be :size characters.', 'numeric' => 'The :attribute field must be :size.',
'array' => 'The :attribute must contain :size items.', '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.', '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. ', '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.', 'unique_undeleted' => 'The :attribute must be unique.',
'non_circular' => 'The :attribute must not create a circular reference.', 'non_circular' => 'The :attribute must not create a circular reference.',
'not_array' => ':attribute cannot be an array.', 'not_array' => ':attribute cannot be an array.',
@ -102,12 +163,13 @@ return [
'numbers' => 'Password must contain at least one number.', 'numbers' => 'Password must contain at least one number.',
'case_diff' => 'Password must use mixed case.', 'case_diff' => 'Password must use mixed case.',
'symbols' => 'Password must contain symbols.', 'symbols' => 'Password must contain symbols.',
'gte' => [ 'timezone' => 'The :attribute field must be a valid timezone.',
'numeric' => 'Value cannot be negative' 'unique' => 'The :attribute has already been taken.',
], 'uploaded' => 'The :attribute failed to upload.',
'checkboxes' => ':attribute contains invalid options.', 'uppercase' => 'The :attribute field must be uppercase.',
'radio_buttons' => ':attribute is invalid.', '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 :( // 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 // 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', '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', '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', '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', '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', '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', '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 | Custom Validation Attributes
@ -158,5 +221,5 @@ return [
| Generic Validation Messages | Generic Validation Messages
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
*/ */
'invalid_value_in_field' => 'Invalid value included in this field',
]; ];

View file

@ -49,9 +49,35 @@
</div> </div>
@endif @endif
<!-- total -->
<div class="form-group">
<label class="col-sm-3 control-label">{{ trans('admin/components/general.total') }}</label>
<div class="col-md-6">
<p class="form-control-static">{{ $accessory->qty }}</p>
</div>
</div>
<!-- remaining -->
<div class="form-group">
<label class="col-sm-3 control-label">{{ trans('admin/components/general.remaining') }}</label>
<div class="col-md-6">
<p class="form-control-static">{{ $accessory->numRemaining() }}</p>
</div>
</div>
<!-- User --> <!-- User -->
@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'])
<!-- Checkout QTY -->
<div class="form-group {{ $errors->has('checkout_qty') ? 'error' : '' }} ">
<label for="checkout_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="{{ old('checkout_qty', 1) }}" min="1" max="{{ $accessory->numRemaining() }}" />
</div>
</div>
{!! $errors->first('checkout_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>
@if ($accessory->requireAcceptance() || $accessory->getEula() || ($snipeSettings->webhook_endpoint!='')) @if ($accessory->requireAcceptance() || $accessory->getEula() || ($snipeSettings->webhook_endpoint!=''))

View file

@ -20,6 +20,9 @@
@if (isset($item->model_no)) @if (isset($item->model_no))
| **{{ trans('general.model_no') }}** | {{ $item->model_no }} | | **{{ trans('general.model_no') }}** | {{ $item->model_no }} |
@endif @endif
@if (isset($checkout_qty))
| **{{ trans('general.qty') }}** | {{ $checkout_qty }} |
@endif
@if ($note) @if ($note)
| **{{ trans('mail.additional_notes') }}** | {{ $note }} | | **{{ trans('mail.additional_notes') }}** | {{ $note }} |
@endif @endif

View file

@ -13,7 +13,7 @@ Route::group(['prefix' => 'accessories', 'middleware' => ['auth']], function ()
)->name('accessories.checkout.show'); )->name('accessories.checkout.show');
Route::post( Route::post(
'{accessoryID}/checkout', '{accessory}/checkout',
[Accessories\AccessoryCheckoutController::class, 'store'] [Accessories\AccessoryCheckoutController::class, 'store']
)->name('accessories.checkout.store'); )->name('accessories.checkout.store');

View file

@ -33,20 +33,110 @@ class AccessoryCheckoutTest extends TestCase
->postJson(route('api.accessories.checkout', Accessory::factory()->withoutItemsRemaining()->create()), [ ->postJson(route('api.accessories.checkout', Accessory::factory()->withoutItemsRemaining()->create()), [
'assigned_to' => User::factory()->create()->id, '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(); $accessory = Accessory::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAsForApi(User::factory()->checkoutAccessories()->create()) $this->actingAsForApi(User::factory()->checkoutAccessories()->create())
->postJson(route('api.accessories.checkout', $accessory), [ ->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() public function testUserSentNotificationUponCheckout()

View file

@ -20,23 +20,36 @@ class AccessoryCheckoutTest extends TestCase
public function testValidationWhenCheckingOutAccessory() public function testValidationWhenCheckingOutAccessory()
{ {
$this->actingAs(User::factory()->checkoutAccessories()->create()) $accessory = Accessory::factory()->create();
->post(route('accessories.checkout.store', 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 // 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, '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(); $accessory = Accessory::factory()->create();
$user = User::factory()->create(); $user = User::factory()->create();
@ -44,9 +57,44 @@ class AccessoryCheckoutTest extends TestCase
$this->actingAs(User::factory()->checkoutAccessories()->create()) $this->actingAs(User::factory()->checkoutAccessories()->create())
->post(route('accessories.checkout.store', $accessory), [ ->post(route('accessories.checkout.store', $accessory), [
'assigned_to' => $user->id, 'assigned_to' => $user->id,
'note' => 'oh hi there',
]); ]);
$this->assertTrue($accessory->users->contains($user)); $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() public function testUserSentNotificationUponCheckout()
@ -57,6 +105,7 @@ class AccessoryCheckoutTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs(User::factory()->checkoutAccessories()->create()) $this->actingAs(User::factory()->checkoutAccessories()->create())
->from(route('accessories.checkout.show', $accessory))
->post(route('accessories.checkout.store', $accessory), [ ->post(route('accessories.checkout.store', $accessory), [
'assigned_to' => $user->id, 'assigned_to' => $user->id,
]); ]);
@ -71,6 +120,7 @@ class AccessoryCheckoutTest extends TestCase
$user = User::factory()->create(); $user = User::factory()->create();
$this->actingAs($actor) $this->actingAs($actor)
->from(route('accessories.checkout.show', $accessory))
->post(route('accessories.checkout.store', $accessory), [ ->post(route('accessories.checkout.store', $accessory), [
'assigned_to' => $user->id, 'assigned_to' => $user->id,
'note' => 'oh hi there', 'note' => 'oh hi there',