diff --git a/.github/workflows/tests-mysql.yml b/.github/workflows/tests-mysql.yml index 237220b337..bc1c9275bb 100644 --- a/.github/workflows/tests-mysql.yml +++ b/.github/workflows/tests-mysql.yml @@ -76,4 +76,16 @@ jobs: DB_DATABASE: snipeit DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_USERNAME: root + LOG_CHANNEL: single + LOG_LEVEL: debug run: php artisan test + + - name: Upload Laravel logs as artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }} + path: | + storage/logs/*.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tests-postgres.yml b/.github/workflows/tests-postgres.yml index 97379ec2bc..5d7d4d0001 100644 --- a/.github/workflows/tests-postgres.yml +++ b/.github/workflows/tests-postgres.yml @@ -75,4 +75,16 @@ jobs: DB_PORT: ${{ job.services.postgresql.ports[5432] }} DB_USERNAME: snipeit DB_PASSWORD: password + LOG_CHANNEL: single + LOG_LEVEL: debug run: php artisan test + + - name: Upload Laravel logs as artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }} + path: | + storage/logs/*.log + if-no-files-found: ignore + retention-days: 7 diff --git a/.github/workflows/tests-sqlite.yml b/.github/workflows/tests-sqlite.yml index fdf4ea2ce9..e00e0f7319 100644 --- a/.github/workflows/tests-sqlite.yml +++ b/.github/workflows/tests-sqlite.yml @@ -61,4 +61,16 @@ jobs: - name: Execute tests (Unit and Feature tests) via PHPUnit env: DB_CONNECTION: sqlite + LOG_CHANNEL: single + LOG_LEVEL: debug run: php artisan test + + - name: Upload Laravel logs as artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: laravel-logs-php-${{ matrix.php-version }}-run-${{ github.run_attempt }} + path: | + storage/logs/*.log + if-no-files-found: ignore + retention-days: 7 diff --git a/app/Helpers/Helper.php b/app/Helpers/Helper.php index 9db9e4cb2a..5e954a24c5 100644 --- a/app/Helpers/Helper.php +++ b/app/Helpers/Helper.php @@ -1706,5 +1706,5 @@ class Helper } } return $mismatched; - } + } } diff --git a/app/Http/Controllers/Api/ImportController.php b/app/Http/Controllers/Api/ImportController.php index 79bffd1206..69703770f3 100644 --- a/app/Http/Controllers/Api/ImportController.php +++ b/app/Http/Controllers/Api/ImportController.php @@ -69,7 +69,7 @@ class ImportController extends Controller if (function_exists('iconv')) { $file_contents = $file->getContent(); //TODO - this *does* load the whole file in RAM, but we need that to be able to 'iconv' it? $encoding = $detector->getEncoding($file_contents); - \Log::warning("Discovered encoding: $encoding in uploaded CSV"); + \Log::debug("Discovered encoding: $encoding in uploaded CSV"); $reader = null; if (strcasecmp($encoding, 'UTF-8') != 0) { $transliterated = false; @@ -103,7 +103,7 @@ class ImportController extends Controller $reader = Reader::createFromFileObject($file->openFile('r')); //file pointer leak? try { - $import->header_row = $reader->fetchOne(0); + $import->header_row = $reader->nth(0); } catch (JsonEncodingException $e) { return response()->json( Helper::formatStandardApiResponse( @@ -136,7 +136,7 @@ class ImportController extends Controller try { // Grab the first row to display via ajax as the user picks fields - $import->first_row = $reader->fetchOne(1); + $import->first_row = $reader->nth(1); } catch (JsonEncodingException $e) { return response()->json( Helper::formatStandardApiResponse( diff --git a/app/Http/Controllers/Api/LicenseSeatsController.php b/app/Http/Controllers/Api/LicenseSeatsController.php index 934261e97a..247f71ff26 100644 --- a/app/Http/Controllers/Api/LicenseSeatsController.php +++ b/app/Http/Controllers/Api/LicenseSeatsController.php @@ -128,7 +128,9 @@ class LicenseSeatsController extends Controller // nothing to update return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); } - + if( $touched && $licenseSeat->unreassignable_seat) { + return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/licenses/message.checkout.unavailable'))); + } // the logging functions expect only one "target". if both asset and user are present in the request, // we simply let assets take precedence over users... if ($licenseSeat->isDirty('assigned_to')) { @@ -145,7 +147,11 @@ class LicenseSeatsController extends Controller if ($licenseSeat->save()) { if ($is_checkin) { - $licenseSeat->logCheckin($target, $request->input('notes')); + if(!$licenseSeat->license->reassignable){ + $licenseSeat->unreassignable_seat = true; + $licenseSeat->save(); + } + $licenseSeat->logCheckin($target, $licenseSeat->notes); return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success'))); } diff --git a/app/Http/Controllers/Licenses/LicenseCheckinController.php b/app/Http/Controllers/Licenses/LicenseCheckinController.php index 2a17c06d62..2bb2d5e68e 100644 --- a/app/Http/Controllers/Licenses/LicenseCheckinController.php +++ b/app/Http/Controllers/Licenses/LicenseCheckinController.php @@ -64,12 +64,7 @@ class LicenseCheckinController extends Controller $this->authorize('checkout', $license); - if (! $license->reassignable) { - // Not allowed to checkin - Session::flash('error', trans('admin/licenses/message.checkin.not_reassignable') . '.'); - return redirect()->back()->withInput(); - } // Declare the rules for the form validation $rules = [ @@ -98,6 +93,9 @@ class LicenseCheckinController extends Controller $licenseSeat->assigned_to = null; $licenseSeat->asset_id = null; $licenseSeat->notes = $request->input('notes'); + if (! $licenseSeat->license->reassignable) { + $licenseSeat->unreassignable_seat = true; + } session()->put(['redirect_option' => $request->get('redirect_option')]); if ($request->get('redirect_option') === 'target'){ @@ -106,7 +104,7 @@ class LicenseCheckinController extends Controller // Was the asset updated? if ($licenseSeat->save()) { - event(new CheckoutableCheckedIn($licenseSeat, $return_to, auth()->user(), $request->input('notes'))); + event(new CheckoutableCheckedIn($licenseSeat, $return_to, auth()->user(), $licenseSeat->notes)); return Helper::getRedirectOption($request, $license->id, 'Licenses') @@ -132,21 +130,17 @@ class LicenseCheckinController extends Controller $license = License::findOrFail($licenseId); $this->authorize('checkin', $license); - if (! $license->reassignable) { - // Not allowed to checkin - Session::flash('error', 'License not reassignable.'); - - return redirect()->back()->withInput(); - } - $licenseSeatsByUser = LicenseSeat::where('license_id', '=', $licenseId) ->whereNotNull('assigned_to') - ->with('user') + ->with('user', 'license') ->get(); + $license = $licenseSeatsByUser->first()?->license; foreach ($licenseSeatsByUser as $user_seat) { $user_seat->assigned_to = null; - + if ($license && ! $license->reassignable) { + $user_seat->unreassignable_seat = true; + } if ($user_seat->save()) { Log::debug('Checking in '.$license->name.' from user '.$user_seat->username); $user_seat->logCheckin($user_seat->user, trans('admin/licenses/general.bulk.checkin_all.log_msg')); @@ -159,9 +153,12 @@ class LicenseCheckinController extends Controller ->get(); $count = 0; + $license = $licenseSeatsByAsset->first()?->license; foreach ($licenseSeatsByAsset as $asset_seat) { $asset_seat->asset_id = null; - + if ($license && ! $license->reassignable) { + $asset_seat->unreassignable_seat = true; + } if ($asset_seat->save()) { Log::debug('Checking in '.$license->name.' from asset '.$asset_seat->asset_tag); $asset_seat->logCheckin($asset_seat->asset, trans('admin/licenses/general.bulk.checkin_all.log_msg')); diff --git a/app/Http/Controllers/Licenses/LicensesController.php b/app/Http/Controllers/Licenses/LicensesController.php index 98a65f5ad4..b1728469b4 100755 --- a/app/Http/Controllers/Licenses/LicensesController.php +++ b/app/Http/Controllers/Licenses/LicensesController.php @@ -245,16 +245,25 @@ class LicensesController extends Controller $license = License::with('assignedusers')->find($license->id); $users_count = User::where('autoassign_licenses', '1')->count(); - $total_seats_count = $license->totalSeatsByLicenseID(); + + $total_seats_count = (int) $license->totalSeatsByLicenseID(); $available_seats_count = $license->availCount()->count(); - $checkedout_seats_count = ($total_seats_count - $available_seats_count); + $unreassignable_seats_count = License::unReassignableCount($license); + + if(!$license->reassignable){ + $checkedout_seats_count = ($total_seats_count - $available_seats_count - $unreassignable_seats_count ); + } + else { + $checkedout_seats_count = ($total_seats_count - $available_seats_count); + } $this->authorize('view', $license); return view('licenses.view', compact('license')) ->with('users_count', $users_count) ->with('total_seats_count', $total_seats_count) ->with('available_seats_count', $available_seats_count) - ->with('checkedout_seats_count', $checkedout_seats_count); + ->with('checkedout_seats_count', $checkedout_seats_count) + ->with('unreassignable_seats_count', $unreassignable_seats_count); } diff --git a/app/Http/Controllers/ReportsController.php b/app/Http/Controllers/ReportsController.php index 89a0a07a21..4dce536f91 100644 --- a/app/Http/Controllers/ReportsController.php +++ b/app/Http/Controllers/ReportsController.php @@ -856,7 +856,7 @@ class ReportsController extends Controller } if ($request->filled('assigned_to')) { - $row[] = ($asset->checkedOutToUser() && $asset->assigned) ?? $asset->assigned->display_name; + $row[] = ($asset->checkedOutToUser() && $asset->assigned) ? $asset->assigned->display_name : ''; $row[] = ($asset->checkedOutToUser() && $asset->assigned) ? 'user' : $asset->assignedType(); } diff --git a/app/Http/Transformers/LicenseSeatsTransformer.php b/app/Http/Transformers/LicenseSeatsTransformer.php index 57f4087e9f..17025e7f9f 100644 --- a/app/Http/Transformers/LicenseSeatsTransformer.php +++ b/app/Http/Transformers/LicenseSeatsTransformer.php @@ -7,7 +7,6 @@ use App\Models\License; use App\Models\LicenseSeat; use Illuminate\Support\Facades\Gate; use Illuminate\Database\Eloquent\Collection; - class LicenseSeatsTransformer { public function transformLicenseSeats(Collection $seats, $total) @@ -52,6 +51,7 @@ class LicenseSeatsTransformer 'reassignable' => (bool) $seat->license->reassignable, 'notes' => e($seat->notes), 'user_can_checkout' => (($seat->assigned_to == '') && ($seat->asset_id == '')), + 'disabled' => $seat->unreassignable_seat, ]; $permissions_array['available_actions'] = [ diff --git a/app/Http/Transformers/LicensesTransformer.php b/app/Http/Transformers/LicensesTransformer.php index 678c491257..24822efeca 100644 --- a/app/Http/Transformers/LicensesTransformer.php +++ b/app/Http/Transformers/LicensesTransformer.php @@ -37,7 +37,7 @@ class LicensesTransformer 'notes' => Helper::parseEscapedMarkedownInline($license->notes), 'expiration_date' => Helper::getFormattedDateObject($license->expiration_date, 'date'), 'seats' => (int) $license->seats, - 'free_seats_count' => (int) $license->free_seats_count, + 'free_seats_count' => (int) $license->free_seats_count - License::unReassignableCount($license), 'remaining' => (int) $license->free_seats_count, 'min_amt' => ($license->min_amt) ? (int) ($license->min_amt) : null, 'license_name' => ($license->license_name) ? e($license->license_name) : null, diff --git a/app/Importer/AssetModelImporter.php b/app/Importer/AssetModelImporter.php index 7cfd8a530d..b458742313 100644 --- a/app/Importer/AssetModelImporter.php +++ b/app/Importer/AssetModelImporter.php @@ -40,11 +40,32 @@ class AssetModelImporter extends ItemImporter { $editingAssetModel = false; - $assetModel = AssetModel::where('name', '=', $this->findCsvMatch($row, 'name'))->first(); + + /** + * This part gets a little confusing, since folks might be importing multiple models with the same name and different model numbers for the first time + * or they might be wanting to update existing models with new model numbers. + */ + + // They are not trying to update existing models, so we'll check for duplicates with model name *and* number + if (! $this->updating) { + $this->log('Finding model by name and model number: '.$this->findCsvMatch($row, 'name').' / '.$this->findCsvMatch($row, 'model_number')); + $assetModel = AssetModel::where('name', '=', $this->findCsvMatch($row, 'name'))->where('model_number', '=', $this->findCsvMatch($row, 'model_number'))->first(); + } else { + + if ($this->findCsvMatch($row, 'id')!='') { + // Override model if an ID was given + $this->log('Finding model by ID: '.$this->findCsvMatch($row, 'id')); + $assetModel = AssetModel::find($this->findCsvMatch($row, 'id')); + } else { + $this->log('Finding model by name: '.$this->findCsvMatch($row, 'name')); + $assetModel = AssetModel::where('name', '=', $this->findCsvMatch($row, 'name'))->first(); + } + } + if ($assetModel) { if (! $this->updating) { - $this->log('A matching Model '.$this->item['name'].' already exists'); + $this->log('A matching Model '.$this->item['name'].' already exists and we are not updating. Skipping.'); return; } @@ -66,6 +87,7 @@ class AssetModelImporter extends ItemImporter $this->item['fieldset'] = trim($this->findCsvMatch($row, 'fieldset')); $this->item['depreciation'] = trim($this->findCsvMatch($row, 'depreciation')); $this->item['requestable'] = trim(($this->fetchHumanBoolean($this->findCsvMatch($row, 'requestable'))) == 1) ? 1 : 0; + $this->item['require_serial'] = trim(($this->fetchHumanBoolean($this->findCsvMatch($row, 'require_serial'))) == 1) ? 1 : 0; if (!empty($this->item['category'])) { if ($category = $this->createOrFetchCategory($this->item['category'])) { diff --git a/app/Livewire/Importer.php b/app/Livewire/Importer.php index d86b2469c1..dd80eec2cc 100644 --- a/app/Livewire/Importer.php +++ b/app/Livewire/Importer.php @@ -403,6 +403,7 @@ class Importer extends Component $this->assetmodels_fields = [ + 'id' => trans('general.id'), 'category' => trans('general.category'), 'eol' => trans('general.eol'), 'fieldset' => trans('admin/models/general.fieldset'), @@ -412,6 +413,7 @@ class Importer extends Component 'model_number' => trans('general.model_no'), 'notes' => trans('general.item_notes', ['item' => trans('admin/hardware/form.model')]), 'requestable' => trans('admin/models/general.requestable'), + 'require_serial' => trans('admin/hardware/general.require_serial'), ]; @@ -535,6 +537,10 @@ class Importer extends Component 'product key', 'key', ], + 'require_serial' => + [ + 'serial required', + ], 'model_number' => [ 'model', diff --git a/app/Models/License.php b/app/Models/License.php index ddcac30d4c..ecd1b003e3 100755 --- a/app/Models/License.php +++ b/app/Models/License.php @@ -534,6 +534,7 @@ class License extends Depreciable return $this->licenseSeatsRelation() ->whereNull('asset_id') ->whereNull('assigned_to') + ->where('unreassignable_seat', '=', false) ->whereNull('deleted_at'); } @@ -585,7 +586,22 @@ class License extends Depreciable return 0; } - + /** + * Calculates the number of unreassignable seats + * + * @author G. Martinez + * @since [v7.1.15] + */ + public static function unReassignableCount($license) : int + { + $count = 0; + if (!$license->reassignable) { + $count = licenseSeat::query()->where('unreassignable_seat', '=', true) + ->where('license_id', '=', $license->id) + ->count(); + } + return $count; + } /** * Calculates the number of remaining seats * @@ -593,11 +609,12 @@ class License extends Depreciable * @since [v1.0] * @return int */ - public function remaincount() + public function remaincount() : int { $total = $this->licenseSeatsCount; $taken = $this->assigned_seats_count; - $diff = ($total - $taken); + $unreassignable = self::unReassignableCount($this); + $diff = ($total - $taken - $unreassignable); return (int) $diff; } @@ -655,12 +672,11 @@ class License extends Depreciable { return $this->licenseseats() ->whereNull('deleted_at') - ->where( - function ($query) { - $query->whereNull('assigned_to') - ->whereNull('asset_id'); - } - ) + ->where('unreassignable_seat', '=', false) + ->where(function ($query) { + $query->whereNull('assigned_to') + ->whereNull('asset_id'); + }) ->orderBy('id', 'asc') ->first(); } diff --git a/app/Models/LicenseSeat.php b/app/Models/LicenseSeat.php index f98028a2f0..9ddd3fb431 100755 --- a/app/Models/LicenseSeat.php +++ b/app/Models/LicenseSeat.php @@ -22,6 +22,9 @@ class LicenseSeat extends SnipeModel implements ICompanyableChild protected $guarded = 'id'; protected $table = 'license_seats'; + protected $casts = [ + 'unreassignable_seat' => 'boolean', + ]; /** * The attributes that are mass assignable. diff --git a/database/factories/LicenseSeatFactory.php b/database/factories/LicenseSeatFactory.php index aaf75bdc2d..ee516b6dac 100644 --- a/database/factories/LicenseSeatFactory.php +++ b/database/factories/LicenseSeatFactory.php @@ -14,6 +14,7 @@ class LicenseSeatFactory extends Factory { return [ 'license_id' => License::factory(), + 'unreassignable_seat' => false, ]; } diff --git a/database/migrations/2025_01_15_190348_adds_unavailable_to_license_seats_tables.php b/database/migrations/2025_01_15_190348_adds_unavailable_to_license_seats_tables.php new file mode 100644 index 0000000000..40c8ad5367 --- /dev/null +++ b/database/migrations/2025_01_15_190348_adds_unavailable_to_license_seats_tables.php @@ -0,0 +1,26 @@ +addColumn('boolean', 'unreassignable_seat')->default(false)->after('assigned_to'); + }); + } + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('license_seats', function (Blueprint $table) { + $table->dropColumn('unreassignable_seat'); + }); + } +}; diff --git a/resources/lang/en-US/admin/licenses/message.php b/resources/lang/en-US/admin/licenses/message.php index 74e1d7af5a..9a0219d857 100644 --- a/resources/lang/en-US/admin/licenses/message.php +++ b/resources/lang/en-US/admin/licenses/message.php @@ -50,7 +50,7 @@ return array( 'checkin' => array( 'error' => 'There was an issue checking in the license. Please try again.', - 'not_reassignable' => 'License not reassignable', + 'not_reassignable' => 'Seat has been used', 'success' => 'The license was checked in successfully' ), diff --git a/resources/views/licenses/view.blade.php b/resources/views/licenses/view.blade.php index 8466aebb11..591870c544 100755 --- a/resources/views/licenses/view.blade.php +++ b/resources/views/licenses/view.blade.php @@ -582,20 +582,13 @@ {{ trans('admin/licenses/general.bulk.checkin_all.button') }} - @elseif (! $license->reassignable) - - - - @else - - @endif - @endcan + @else + + @endif + @endcan @can('delete', $license) diff --git a/resources/views/locations/print.blade.php b/resources/views/locations/print.blade.php index 0f7f3b8c45..1214ea0864 100644 --- a/resources/views/locations/print.blade.php +++ b/resources/views/locations/print.blade.php @@ -142,7 +142,7 @@
| {{ trans('mail.name') }} | {{ trans('mail.serial') }} | {{ trans('mail.Days') }} | {{ trans('mail.expires') }} | {{ trans('mail.supplier') }} | {{ trans('mail.assigned_to') }} | |
| {{ $icon }} | {{ $asset->display_name }} {{trans('mail.serial').': '.$asset->serial}} | {{ $diff }} {{ trans('mail.Days') }} | {{ !is_null($expires) ? $expires['formatted'] : '' }} | {{ ($asset->supplier ? e($asset->supplier->name) : '') }} | {{ ($asset->assignedTo ? e($asset->assignedTo->present()->display_name) : '') }} |