Merge pull request #19058 from grokability/bulk-seat-checkin-toolbar

🎥 Bulk checkin license seats
This commit is contained in:
snipe
2026-05-22 11:56:19 +01:00
committed by GitHub
13 changed files with 416 additions and 51 deletions
@@ -13,6 +13,7 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
@@ -127,10 +128,45 @@ class LicenseCheckinController extends Controller
* @see LicenseCheckinController::create() method that provides the form view
* @since [v6.1.1]
*
* @return RedirectResponse
*
* @throws AuthorizationException
*/
public function bulkCheckinSelected(Request $request): RedirectResponse
{
$this->authorize('checkin', License::class);
$seatIds = $request->input('ids', []);
if (empty($seatIds)) {
return redirect()->back()->with('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
$seats = LicenseSeat::whereIn('id', $seatIds)
->where(function ($query) {
$query->whereNotNull('assigned_to')->orWhereNotNull('asset_id');
})
->with('license', 'user', 'asset')
->get();
$count = 0;
foreach ($seats as $seat) {
if (! $seat->license || ! Gate::allows('checkin', $seat->license)) {
continue;
}
$target = $seat->user ?? $seat->asset;
$seat->assigned_to = null;
$seat->asset_id = null;
if (! $seat->license->reassignable) {
$seat->unreassignable_seat = true;
}
if ($seat->save()) {
event(new CheckoutableCheckedIn($seat, $target, auth()->user(), null));
$count++;
}
}
return redirect()->back()->with('success', trans_choice('admin/licenses/general.bulk.checkin_selected.success', $count, ['count' => $count]));
}
public function bulkCheckin(Request $request, $licenseId)
{
@@ -388,6 +388,9 @@ class AssetsTransformer
$permissions_array['available_actions'] = [
'checkout' => false,
'checkin' => Gate::allows('checkin', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class),
],
];
$array += $permissions_array;
@@ -70,6 +70,9 @@ class LicenseSeatsTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => Gate::allows('delete', License::class),
'bulk_selectable' => [
'checkin' => Gate::allows('checkin', License::class) && ($seat->assigned_to || $seat->asset_id),
],
];
$array += $permissions_array;
@@ -66,7 +66,6 @@ class LicensesTransformer
'created_at' => Helper::getFormattedDateObject($license->created_at, 'datetime'),
'updated_at' => Helper::getFormattedDateObject($license->updated_at, 'datetime'),
'deleted_at' => Helper::getFormattedDateObject($license->deleted_at, 'datetime'),
'user_can_checkout' => (bool) ($license->free_seats_count > 0),
'disabled' => $license->isInactive(),
];
@@ -76,6 +75,7 @@ class LicensesTransformer
'clone' => Gate::allows('create', License::class),
'update' => Gate::allows('update', License::class),
'delete' => $license->isDeletable(),
'user_can_checkout' => (bool) (($license->free_seats_count - License::unReassignableCount($license)) > 0),
'bulk_selectable' => [
'delete' => $license->isDeletable(),
],
+55 -30
View File
@@ -240,33 +240,45 @@ class LicensePresenter extends Presenter
*
* @return string
*/
public static function dataTableLayoutSeats()
public static function dataTableLayoutSeats(bool $withCheckbox = true)
{
$layout = [
[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'assigned_user',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/licenses/general.user'),
'visible' => true,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'assigned_user.email',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/users/table.email'),
'visible' => true,
'formatter' => 'emailFormatter',
],
$layout = [];
if ($withCheckbox) {
$layout[] = [
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
];
}
$layout = array_merge($layout, [[
'field' => 'id',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('general.id'),
'visible' => false,
], [
'field' => 'assigned_user',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/licenses/general.user'),
'visible' => true,
'formatter' => 'usersLinkObjFormatter',
], [
'field' => 'assigned_user.email',
'searchable' => false,
'sortable' => false,
'switchable' => true,
'title' => trans('admin/users/table.email'),
'visible' => true,
'formatter' => 'emailFormatter',
],
[
'field' => 'assigned_user.company',
'searchable' => false,
@@ -328,14 +340,27 @@ class LicensePresenter extends Presenter
'printIgnore' => true,
'class' => 'hidden-print',
],
];
]);
return json_encode($layout);
}
public static function dataTableLayoutSeatsCheckedOutToAssets()
public static function dataTableLayoutSeatsCheckedOutToAssets($hide_fields = [])
{
$layout = [
$layout = [];
if (! in_array('checkbox', $hide_fields)) {
$layout[] = [
'field' => 'checkbox',
'checkbox' => true,
'formatter' => 'checkboxEnabledFormatter',
'titleTooltip' => trans('general.select_all_none'),
'printIgnore' => true,
'class' => 'hidden-print',
];
}
$layout = array_merge($layout, [
[
'field' => 'id',
'searchable' => false,
@@ -386,7 +411,7 @@ class LicensePresenter extends Presenter
'printIgnore' => true,
'class' => 'hidden-print',
],
];
]);
return json_encode($layout);
}
+5
View File
@@ -290,6 +290,11 @@ class UserFactory extends Factory
return $this->appendPermission(['licenses.checkout' => '1']);
}
public function checkinLicenses()
{
return $this->appendPermission(['licenses.checkin' => '1']);
}
public function viewKeysLicenses()
{
return $this->appendPermission(['licenses.keys' => '1']);
@@ -31,6 +31,11 @@ return [
'log_msg' => 'Checked in via bulk license checkin in license GUI',
],
'checkin_selected' => [
'success' => ':count seat checked in successfully. | :count seats checked in successfully.',
'no_seats_selected' => 'No seats were selected.',
],
'checkout_all' => [
'button' => 'Checkout All Seats',
'modal' => 'This action will checkout one seat to the first available user. | This action will checkout all :available_seats_count seats to the first available users. A user is considered available for this seat if they do not already have this license checked out to them, and the Auto-Assign License property is enabled on their user account.',
+23 -1
View File
@@ -330,7 +330,29 @@
</x-tabs.pane>
<x-tabs.pane name="licenses" :count="$asset->licenses->count()">
<x-table.licenses show_search="false" :route="route('api.assets.licenselist', $asset)" :presenter="\App\Presenters\LicensePresenter::dataTableLayoutSeatsCheckedOutToAssets()"/>
@can('view', \App\Models\License::class)
<x-slot:table_header>{{ trans('general.licenses') }}</x-slot:table_header>
@endcan
@can('checkin', \App\Models\License::class)
<x-slot:bulkactions>
<x-table.bulk-actions
action_route="{{ route('licenses.bulkcheckin.selected') }}"
model_name="seat"
>
<option value="checkin">{{ trans('general.checkin') }}</option>
</x-table.bulk-actions>
</x-slot:bulkactions>
@endcan
@can('view', \App\Models\License::class)
<x-table
show_search="false"
api_url="{{ route('api.assets.licenselist', $asset) }}"
:presenter="\App\Presenters\LicensePresenter::dataTableLayoutSeatsCheckedOutToAssets()"
export_filename="export-licenses-{{ str_slug($asset->asset_tag) }}-{{ date('Y-m-d') }}"
/>
@endcan
</x-tabs.pane>
<x-tabs.pane name="components" :count="$asset->components->sum('assigned_qty')">
+12 -1
View File
@@ -46,6 +46,17 @@
{{ trans('general.assigned') }}
</x-slot:table_header>
@can('checkin', $license)
<x-slot:bulkactions>
<x-table.bulk-actions
action_route="{{ route('licenses.bulkcheckin.selected') }}"
model_name="seat"
>
<option value="checkin">{{ trans('general.checkin') }}</option>
</x-table.bulk-actions>
</x-slot:bulkactions>
@endcan
<x-table
fixed_right_number="1"
fixed_number="1"
@@ -66,7 +77,7 @@
<x-table
show_search="false"
api_url="{{ route('api.licenses.seats.index', [$license->id, 'status' => 'available']) }}"
:presenter="\App\Presenters\LicensePresenter::dataTableLayoutSeats()"
:presenter="\App\Presenters\LicensePresenter::dataTableLayoutSeats(false)"
export_filename="export-{{ str_slug($license->name) }}-available-{{ date('Y-m-d') }}"
/>
@@ -1486,7 +1486,6 @@
});
// This specifies the footer columns that should have special styles associated
// (usually numbers)
window.footerStyle = column => ({
@@ -1814,29 +1813,23 @@
// However since different bulk actions have different requirements, we have to walk through the available_actions object
// to determine whether to disable it
function checkboxEnabledFormatter (value, row) {
// add some stuff to get the value of the select2 option here?
if ((row.available_actions) && (row.available_actions.bulk_selectable) && (row.available_actions.bulk_selectable.delete !== true)) {
return {
disabled:true,
//checked: false, <-- not sure this will work the way we want?
if (row.available_actions && row.available_actions.bulk_selectable) {
var values = Object.values(row.available_actions.bulk_selectable);
if (values.length > 0 && !values.some(function (v) { return v === true; })) {
return { disabled: true };
}
}
}
function licenseInOutFormatter(value, row) {
var user_can_checkout = row.available_actions && row.available_actions.user_can_checkout;
// check that checkin is not disabled
if (row.user_can_checkout === false) {
return '<span class="btn btn-sm bg-maroon btn-checkout disabled" data-tooltip="true" title="{{ trans('admin/licenses/message.checkout.unavailable') }}">{{ trans('general.checkout') }}</span>';
} else if (row.disabled === true) {
if (row.disabled === true) {
return '<span class="btn btn-sm bg-maroon btn-checkout disabled" data-tooltip="true" title="{{ trans('admin/licenses/message.checkout.license_is_inactive') }}">{{ trans('general.checkout') }}</span>';
} else
// The user is allowed to check the license seat out and it's available
if ((row.available_actions.checkout === true) && (row.user_can_checkout === true) && (row.disabled === false)) {
} else if ((row.available_actions.checkout === true) && (user_can_checkout === true) && (row.disabled === false)) {
return '<a href="{{ config('app.url') }}/licenses/' + row.id + '/checkout" class="btn btn-sm bg-maroon btn-checkout" data-tooltip="true" title="{{ trans('general.checkout_tooltip') }}">{{ trans('general.checkout') }}</a>';
} else if (row.available_actions.checkout === true) {
return '<span class="btn btn-sm bg-maroon btn-checkout disabled" data-tooltip="true" title="{{ trans('admin/licenses/message.checkout.unavailable') }}">{{ trans('general.checkout') }}</span>';
}
}
// We need a special formatter for license seats, since they don't work exactly the same
+44
View File
@@ -310,6 +310,24 @@
</x-tabs.pane>
<x-tabs.pane name="licenses" :count="$user->licenses()->count()">
@can('checkin', \App\Models\License::class)
<x-slot:table_header>{{ trans('general.licenses') }}</x-slot:table_header>
<x-slot:bulkactions>
<div class="hidden-print" style="padding-top:10px; min-width:400px;">
<form method="POST" action="{{ route('licenses.bulkcheckin.selected') }}" id="userLicenseBulkCheckinForm" class="form-inline">
@csrf
<label for="userLicenseBulkActions"><span class="sr-only">{{ trans('button.bulk_actions') }}</span></label>
<select name="bulk_actions" id="userLicenseBulkActions" class="form-control select2" style="min-width:350px;">
<option value="checkin">{{ trans('general.checkin') }}</option>
</select>
<button type="submit" id="userLicenseBulkCheckinButton" class="btn btn-theme" disabled>{{ trans('button.go') }}</button>
<span id="userLicenseBulkCheckinCount" style="display:none; margin-left:8px; line-height:34px;">&mdash; <span class="badge">0</span> {{ trans('general.selected') }}</span>
</form>
</div>
</x-slot:bulkactions>
@endcan
<table
data-cookie-id-table="userLicenseTable"
data-id-table="userLicenseTable"
@@ -326,6 +344,9 @@
<thead>
<tr>
@can('checkin', \App\Models\License::class)
<th class="hidden-print"><input type="checkbox" id="userLicenseSelectAll"></th>
@endcan
<th>{{ trans('general.name') }}</th>
<th>{{ trans('admin/licenses/form.license_key') }}</th>
<th data-footer-formatter="sumFormatter" data-fieldname="purchase_cost">{{ trans('general.purchase_cost') }}</th>
@@ -337,6 +358,11 @@
<tbody>
@foreach ($user->licenses as $license)
<tr>
@can('checkin', \App\Models\License::class)
<td class="hidden-print">
<input type="checkbox" class="user-license-seat-checkbox hidden-print" form="userLicenseBulkCheckinForm" name="ids[]" value="{{ $license->pivot->id }}">
</td>
@endcan
<td class="col-md-4">
{!! $license->present()->nameUrl() !!}
</td>
@@ -652,6 +678,24 @@ $(function () {
var optional_info_open = $('#optional_info_icon').hasClass('fa-caret-down');
document.cookie = "optional_info_open="+optional_info_open+'; path=/';
});
$(document).on('change', '.user-license-seat-checkbox', function () {
var count = $('.user-license-seat-checkbox:checked').length;
$('#userLicenseBulkCheckinButton').prop('disabled', count === 0);
$('#userLicenseBulkCheckinCount .badge').text(count);
if (count > 0) {
$('#userLicenseBulkCheckinCount').show();
} else {
$('#userLicenseBulkCheckinCount').hide();
}
var total = $('.user-license-seat-checkbox').length;
$('#userLicenseSelectAll').prop('indeterminate', count > 0 && count < total);
$('#userLicenseSelectAll').prop('checked', count === total);
});
$(document).on('change', '#userLicenseSelectAll', function () {
$('.user-license-seat-checkbox').prop('checked', $(this).is(':checked')).trigger('change');
});
});
</script>
+5
View File
@@ -36,6 +36,11 @@ Route::group(['prefix' => 'licenses', 'middleware' => ['auth']], function () {
[Licenses\LicenseCheckinController::class, 'bulkCheckin']
)->name('licenses.bulkcheckin');
Route::post(
'bulkcheckin/selected',
[Licenses\LicenseCheckinController::class, 'bulkCheckinSelected']
)->name('licenses.bulkcheckin.selected');
Route::post(
'{licenseId}/bulkcheckout',
[Licenses\LicenseCheckoutController::class, 'bulkCheckout']
@@ -0,0 +1,213 @@
<?php
namespace Tests\Feature\Licenses\Ui;
use App\Events\CheckoutableCheckedIn;
use App\Models\Asset;
use App\Models\Company;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Support\Facades\Event;
use Tests\Concerns\TestsPermissionsRequirement;
use Tests\TestCase;
class BulkCheckinSelectedLicenseSeatsTest extends TestCase implements TestsPermissionsRequirement
{
public function test_requires_permission()
{
$seat = LicenseSeat::factory()->assignedToUser()->create();
$this->actingAs(User::factory()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]])
->assertForbidden();
}
public function test_can_bulk_checkin_seats_assigned_to_users()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 3]);
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$seat1 = LicenseSeat::factory()->assignedToUser($user1)->create(['license_id' => $license->id]);
$seat2 = LicenseSeat::factory()->assignedToUser($user2)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat1->id, $seat2->id]])
->assertRedirect()
->assertSessionHas('success');
$this->assertNull($seat1->fresh()->assigned_to);
$this->assertNull($seat2->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedIn::class, 2);
}
public function test_can_bulk_checkin_seats_assigned_to_assets()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 2]);
$asset = Asset::factory()->create();
$seat = LicenseSeat::factory()->assignedToAsset($asset)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]])
->assertRedirect()
->assertSessionHas('success');
$this->assertNull($seat->fresh()->asset_id);
Event::assertDispatched(CheckoutableCheckedIn::class, 1);
}
public function test_empty_ids_returns_warning()
{
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => []])
->assertRedirect()
->assertSessionHas('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
public function test_missing_ids_returns_warning()
{
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), [])
->assertRedirect()
->assertSessionHas('warning', trans('admin/licenses/general.bulk.checkin_selected.no_seats_selected'));
}
public function test_unassigned_seats_in_submitted_ids_are_skipped()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 2]);
$unassignedSeat = LicenseSeat::factory()->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$unassignedSeat->id]])
->assertRedirect()
->assertSessionHas('success');
$this->assertNull($unassignedSeat->fresh()->assigned_to);
$this->assertNull($unassignedSeat->fresh()->asset_id);
Event::assertNotDispatched(CheckoutableCheckedIn::class);
}
public function test_non_reassignable_license_marks_unreassignable_seat()
{
$license = License::factory()->create(['seats' => 2, 'reassignable' => false]);
$user = User::factory()->create();
$seat = LicenseSeat::factory()->assignedToUser($user)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]])
->assertRedirect()
->assertSessionHas('success');
$this->assertTrue((bool) $seat->fresh()->unreassignable_seat);
}
public function test_reassignable_license_does_not_mark_unreassignable_seat()
{
$license = License::factory()->create(['seats' => 2, 'reassignable' => true]);
$user = User::factory()->create();
$seat = LicenseSeat::factory()->assignedToUser($user)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]])
->assertRedirect()
->assertSessionHas('success');
$this->assertFalse((bool) $seat->fresh()->unreassignable_seat);
}
public function test_only_submitted_seat_ids_are_processed()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 3]);
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$seat1 = LicenseSeat::factory()->assignedToUser($user1)->create(['license_id' => $license->id]);
$seat2 = LicenseSeat::factory()->assignedToUser($user2)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat1->id]])
->assertRedirect();
$this->assertNull($seat1->fresh()->assigned_to);
$this->assertNotNull($seat2->fresh()->assigned_to);
Event::assertDispatched(CheckoutableCheckedIn::class, 1);
}
public function test_success_message_is_pluralized_correctly()
{
$license = License::factory()->create(['seats' => 3]);
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$seat1 = LicenseSeat::factory()->assignedToUser($user1)->create(['license_id' => $license->id]);
$seat2 = LicenseSeat::factory()->assignedToUser($user2)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat1->id, $seat2->id]])
->assertSessionHas('success', trans_choice('admin/licenses/general.bulk.checkin_selected.success', 2, ['count' => 2]));
}
public function test_checkin_event_contains_correct_target_for_user_seat()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 2]);
$targetUser = User::factory()->create();
$seat = LicenseSeat::factory()->assignedToUser($targetUser)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]]);
Event::assertDispatched(CheckoutableCheckedIn::class, function ($event) use ($targetUser) {
return $event->checkedOutTo->id === $targetUser->id;
});
}
public function test_checkin_event_contains_correct_target_for_asset_seat()
{
Event::fake([CheckoutableCheckedIn::class]);
$license = License::factory()->create(['seats' => 2]);
$targetAsset = Asset::factory()->create();
$seat = LicenseSeat::factory()->assignedToAsset($targetAsset)->create(['license_id' => $license->id]);
$this->actingAs(User::factory()->checkinLicenses()->create())
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]]);
Event::assertDispatched(CheckoutableCheckedIn::class, function ($event) use ($targetAsset) {
return $event->checkedOutTo->id === $targetAsset->id;
});
}
public function test_fmcs_prevents_checkin_of_seat_from_other_company()
{
Event::fake([CheckoutableCheckedIn::class]);
[$myCompany, $otherCompany] = Company::factory()->count(2)->create();
$actor = User::factory()->checkinLicenses()->create(['company_id' => $myCompany->id]);
$otherLicense = License::factory()->create(['company_id' => $otherCompany->id, 'seats' => 2]);
$targetUser = User::factory()->create(['company_id' => $otherCompany->id]);
$seat = LicenseSeat::factory()->assignedToUser($targetUser)->create(['license_id' => $otherLicense->id]);
$this->settings->enableMultipleFullCompanySupport();
$this->actingAs($actor)
->post(route('licenses.bulkcheckin.selected'), ['ids' => [$seat->id]])
->assertRedirect();
$this->assertNotNull($seat->fresh()->assigned_to);
Event::assertNotDispatched(CheckoutableCheckedIn::class);
}
}