Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe 2023-09-14 14:32:06 +01:00
commit 467609e561
32 changed files with 308 additions and 63 deletions

View file

@ -26,7 +26,7 @@ jobs:
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v3.3.0
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL

View file

@ -32,7 +32,7 @@ jobs:
steps:
# Checkout the repository to the GitHub Actions runner
- name: Checkout code
uses: actions/checkout@v3.3.0
uses: actions/checkout@v4
# Execute Codacy Analysis CLI and generate a SARIF output with the security issues identified during the analysis
- name: Run Codacy Analysis CLI

View file

@ -9,7 +9,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Crowdin push
uses: crowdin/github-action@v1

View file

@ -41,7 +41,7 @@ jobs:
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
uses: actions/checkout@v3.3.0
uses: actions/checkout@v4
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx

View file

@ -41,7 +41,7 @@ jobs:
steps:
# https://github.com/actions/checkout
- name: Checkout codebase
uses: actions/checkout@v3.3.0
uses: actions/checkout@v4
# https://github.com/docker/setup-buildx-action
- name: Setup Docker Buildx

View file

@ -37,7 +37,7 @@ jobs:
php-version: "${{ matrix.php-version }}"
coverage: none
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Get Composer Cache Directory
id: composer-cache

View file

@ -180,10 +180,6 @@ class LdapSync extends Command
}
}
/* Create user account entries in Snipe-IT */
$tmp_pass = substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 20);
$pass = bcrypt($tmp_pass);
$manager_cache = [];
if($ldap_default_group != null) {
@ -229,7 +225,7 @@ class LdapSync extends Command
} else {
// Creating a new user.
$user = new User;
$user->password = $pass;
$user->password = $user->noPassword();
$user->activated = 1; // newly created users can log in by default, unless AD's UAC is in use, or an active flag is set (below)
$item['createorupdate'] = 'created';
}

View file

@ -2,6 +2,8 @@
namespace App\Helpers;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\CustomField;
@ -643,6 +645,7 @@ class Helper
$consumables = Consumable::withCount('consumableAssignments as consumable_assignments_count')->whereNotNull('min_amt')->get();
$accessories = Accessory::withCount('users as users_count')->whereNotNull('min_amt')->get();
$components = Component::whereNotNull('min_amt')->get();
$asset_models = AssetModel::where('min_amt', '>', 0)->get();
$avail_consumables = 0;
$items_array = [];
@ -705,6 +708,28 @@ class Helper
}
}
foreach ($asset_models as $asset_model){
$asset = new Asset();
$total_owned = $asset->where('model_id', '=', $asset_model->id)->count();
$avail = $asset->where('model_id', '=', $asset_model->id)->whereNull('assigned_to')->count();
if ($avail < ($asset_model->min_amt)+ \App\Models\Setting::getSettings()->alert_threshold) {
if ($avail > 0) {
$percent = number_format((($avail / $total_owned) * 100), 0);
} else {
$percent = 100;
}
$items_array[$all_count]['id'] = $asset_model->id;
$items_array[$all_count]['name'] = $asset_model->name;
$items_array[$all_count]['type'] = 'models';
$items_array[$all_count]['percent'] = $percent;
$items_array[$all_count]['remaining'] = $avail;
$items_array[$all_count]['min_amt'] = $asset_model->min_amt;
$all_count++;
}
}
return $items_array;
}

View file

@ -38,6 +38,7 @@ class AssetModelsController extends Controller
'image',
'name',
'model_number',
'min_amt',
'eol',
'notes',
'created_at',
@ -52,6 +53,7 @@ class AssetModelsController extends Controller
'models.image',
'models.name',
'model_number',
'min_amt',
'eol',
'requestable',
'models.notes',

View file

@ -27,7 +27,7 @@ class DepartmentsController extends Controller
$this->authorize('view', Department::class);
$allowed_columns = ['id', 'name', 'image', 'users_count'];
$departments = Company::scopeCompanyables(Department::select(
$departments = Department::select(
'departments.id',
'departments.name',
'departments.phone',
@ -37,8 +37,8 @@ class DepartmentsController extends Controller
'departments.manager_id',
'departments.created_at',
'departments.updated_at',
'departments.image'),
"company_id", "departments")->with('users')->with('location')->with('manager')->with('company')->withCount('users as users_count');
'departments.image'
)->with('users')->with('location')->with('manager')->with('company')->withCount('users as users_count');
if ($request->filled('search')) {
$departments = $departments->TextSearch($request->input('search'));

View file

@ -363,8 +363,12 @@ class UsersController extends Controller
$user->permissions = $permissions_array;
}
$tmp_pass = substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 40);
$user->password = bcrypt($request->get('password', $tmp_pass));
//
if ($request->filled('password')) {
$user->password = bcrypt($request->get('password'));
} else {
$user->password = $user->noPassword();
}
app('App\Http\Requests\ImageUploadRequest')->handleImages($user, 600, 'image', 'avatars', 'avatar');

View file

@ -76,6 +76,7 @@ class AssetModelsController extends Controller
$model->depreciation_id = $request->input('depreciation_id');
$model->name = $request->input('name');
$model->model_number = $request->input('model_number');
$model->min_amt = $request->input('min_amt');
$model->manufacturer_id = $request->input('manufacturer_id');
$model->category_id = $request->input('category_id');
$model->notes = $request->input('notes');
@ -153,6 +154,7 @@ class AssetModelsController extends Controller
$model->eol = $request->input('eol');
$model->name = $request->input('name');
$model->model_number = $request->input('model_number');
$model->min_amt = $request->input('min_amt');
$model->manufacturer_id = $request->input('manufacturer_id');
$model->category_id = $request->input('category_id');
$model->notes = $request->input('notes');

View file

@ -191,9 +191,11 @@ class LoginController extends Controller
$ldap_attr = Ldap::parseAndMapLdapAttributes($ldap_user);
$user->password = $user->noPassword();
if (Setting::getSettings()->ldap_pw_sync=='1') {
$user->password = bcrypt($request->input('password'));
}
$user->email = $ldap_attr['email'];
$user->first_name = $ldap_attr['firstname'];
$user->last_name = $ldap_attr['lastname']; //FIXME (or TODO?) - do we need to map additional fields that we now support? E.g. country, phone, etc.

View file

@ -128,6 +128,13 @@ 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')

View file

@ -1021,7 +1021,12 @@ class ReportsController extends Controller
$assetsForReport = $acceptances
->filter(function ($acceptance) {
return $acceptance->checkoutable_type == 'App\Models\Asset' && $acceptance->checkoutable->checkedOutToUser();
$acceptance_checkoutable_flag = false;
if ($acceptance->checkoutable){
$acceptance_checkoutable_flag = $acceptance->checkoutable->checkedOutToUser();
}
return $acceptance->checkoutable_type == 'App\Models\Asset' && $acceptance_checkoutable_flag;
})
->map(function($acceptance) {
return ['assetItem' => $acceptance->checkoutable, 'acceptance' => $acceptance];

View file

@ -68,8 +68,8 @@ class ActionlogsTransformer
}
}
$clean_meta = $this->changedInfo($clean_meta);
}
$clean_meta= $this->changedInfo($clean_meta);
}
$file_url = '';

View file

@ -47,6 +47,7 @@ class AssetModelsTransformer
] : null,
'image' => ($assetmodel->image != '') ? Storage::disk('public')->url('models/'.e($assetmodel->image)) : null,
'model_number' => e($assetmodel->model_number),
'min_amt' => ($assetmodel->min_amt) ? (int) $assetmodel->min_amt : null,
'depreciation' => ($assetmodel->depreciation) ? [
'id' => (int) $assetmodel->depreciation->id,
'name'=> e($assetmodel->depreciation->name),

View file

@ -79,13 +79,15 @@ class CheckoutableListener
/**
* Send the appropriate notification
*/
$acceptances = CheckoutAcceptance::where('checkoutable_id', $event->checkoutable->id)
->where('assigned_to_id', $event->checkedOutTo->id)
->get();
if ($event->checkedOutTo && $event->checkoutable){
$acceptances = CheckoutAcceptance::where('checkoutable_id', $event->checkoutable->id)
->where('assigned_to_id', $event->checkedOutTo->id)
->get();
foreach($acceptances as $acceptance){
if($acceptance->isPending()){
$acceptance->delete();
foreach($acceptances as $acceptance){
if($acceptance->isPending()){
$acceptance->delete();
}
}
}
@ -142,9 +144,11 @@ class CheckoutableListener
$notifiables = collect();
/**
* Notify the user who checked out the item
* Notify who checked out the item as long as the model can route notifications
*/
$notifiables->push($event->checkedOutTo);
if (method_exists($event->checkedOutTo, 'routeNotificationFor')) {
$notifiables->push($event->checkedOutTo);
}
/**
* Notify Admin users if the settings is activated

View file

@ -29,6 +29,7 @@ class AssetModel extends SnipeModel
protected $rules = [
'name' => 'required|min:1|max:255',
'model_number' => 'max:255|nullable',
'min_amt' => 'integer|min:0|nullable',
'category_id' => 'required|integer|exists:categories,id',
'manufacturer_id' => 'integer|exists:manufacturers,id|nullable',
'eol' => 'integer:min:0|max:240|nullable',
@ -65,6 +66,7 @@ class AssetModel extends SnipeModel
'fieldset_id',
'image',
'manufacturer_id',
'min_amt',
'model_number',
'name',
'notes',

View file

@ -9,6 +9,7 @@ use Watson\Validating\ValidatingTrait;
class Department extends SnipeModel
{
use CompanyableTrait;
use HasFactory;
/**

View file

@ -252,13 +252,10 @@ class Ldap extends Model
$user->last_name = $item['lastname'];
$user->username = $item['username'];
$user->email = $item['email'];
$user->password = $user->noPassword();
if (Setting::getSettings()->ldap_pw_sync == '1') {
$user->password = bcrypt($password);
} else {
$pass = substr(str_shuffle('0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'), 0, 25);
$user->password = bcrypt($pass);
}
$user->activated = 1;
@ -268,7 +265,7 @@ class Ldap extends Model
if ($user->save()) {
return $user;
} else {
LOG::debug('Could not create user.'.$user->getErrors());
\Log::debug('Could not create user.'.$user->getErrors());
throw new Exception('Could not create user: '.$user->getErrors());
}
}

View file

@ -9,8 +9,7 @@ class SCIMUser extends User
protected $throwValidationExceptions = true; // we want model-level validation to fully THROW, not just return false
public function __construct(array $attributes = []) {
$attributes['password'] = "*NO PASSWORD*";
// $attributes['activated'] = 1;
$attributes['password'] = $this->noPassword();
parent::__construct($attributes);
}
}

View file

@ -456,6 +456,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
return $this->belongsToMany(Asset::class, 'checkout_requests', 'user_id', 'requestable_id')->whereNull('canceled_at');
}
/**
* Set a common string when the user has been imported/synced from:
*
* - LDAP without password syncing
* - SCIM
* - CSV import where no password was provided
*
* @author A. Gianotto <snipe@snipe.net>
* @since [v6.2.0]
* @return string
*/
public function noPassword()
{
return "*** NO PASSWORD ***";
}
/**
* Query builder scope to return NOT-deleted users

View file

@ -65,6 +65,14 @@ class AssetModelPresenter extends Presenter
'title' => trans('admin/models/table.modelnumber'),
'visible' => true,
],
[
'field' => 'min_amt',
'searchable' => false,
'sortable' => true,
'switchable' => true,
'title' => trans('mail.min_QTY'),
'visible' => true,
],
[
'field' => 'assets_count',
'searchable' => false,

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddMinAmtToModelsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('models', function (Blueprint $table) {
$table->integer('min_amt')->after('model_number')->default(null);;
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('models', function (Blueprint $table) {
$table->dropColumn('min_amt');
});
}
}

View file

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class FixAssetModelMinQtyNullability extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('models', function (Blueprint $table) {
$table->integer('min_amt')->nullable()->change();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('models', function (Blueprint $table) {
$table->integer('min_amt')->nullable(false)->change();
});
}
}

View file

@ -454,12 +454,18 @@ $(document).ready(function () {
$('#assigned_location').hide();
$('.notification-callout').fadeOut();
$('[name="assigned_location"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
} else if (assignto_type == 'location') {
$('#current_assets_box').fadeOut();
$('#assigned_asset').hide();
$('#assigned_user').hide();
$('#assigned_location').show();
$('.notification-callout').fadeOut();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_user"]').val('').trigger('change.select2');
} else {
$('#assigned_asset').hide();
@ -470,6 +476,8 @@ $(document).ready(function () {
}
$('.notification-callout').fadeIn();
$('[name="assigned_asset"]').val('').trigger('change.select2');
$('[name="assigned_location"]').val('').trigger('change.select2');
}
});
});

View file

@ -26,6 +26,7 @@ return array(
'modal' => 'This will action checkin one seat. | This action will checkin all :checkedout_seats_count seats for this license.',
'enabled_tooltip' => 'Checkin ALL seats for this license from both users and assets',
'disabled_tooltip' => 'This is disabled because there are no seats currently checked out',
'disabled_tooltip_reassignable' => 'This is disabled because the License is not reassignable',
'success' => 'License successfully checked in! | All licenses were successfully checked in!',
'log_msg' => 'Checked in via bulk license checkout in license GUI',
],

View file

@ -583,16 +583,22 @@
@can('checkin', $license)
@if (($license->seats - $license->availCount()->count()) > 0 )
<a href="#" class="btn btn-block bg-purple" style="margin-bottom: 25px;" data-toggle="modal" data-tooltip="true" data-target="#checkinFromAllModal" data-content="{{ trans('general.sure_to_delete') }} data-title="{{ trans('general.delete') }}" onClick="return false;">
{{ trans('admin/licenses/general.bulk.checkin_all.button') }}
</a>
@else
@if (($license->seats - $license->availCount()->count()) <= 0 )
<span data-tooltip="true" title=" {{ trans('admin/licenses/general.bulk.checkin_all.disabled_tooltip') }}">
<a href="#" class="btn btn-block bg-purple disabled" style="margin-bottom: 25px;">
{{ trans('admin/licenses/general.bulk.checkin_all.button') }}
</a>
</span>
</span>
@elseif (! $license->reassignable)
<span data-tooltip="true" title=" {{ trans('admin/licenses/general.bulk.checkin_all.disabled_tooltip_reassignable') }}">
<a href="#" class="btn btn-block bg-purple disabled" style="margin-bottom: 25px;">
{{ trans('admin/licenses/general.bulk.checkin_all.button') }}
</a>
</span>
@else
<a href="#" class="btn btn-block bg-purple" style="margin-bottom: 25px;" data-toggle="modal" data-tooltip="true" data-target="#checkinFromAllModal" data-content="{{ trans('general.sure_to_delete') }} data-title="{{ trans('general.delete') }}" onClick="return false;">
{{ trans('admin/licenses/general.bulk.checkin_all.button') }}
</a>
@endif
@endcan

View file

@ -15,6 +15,7 @@
@include ('partials.forms.edit.manufacturer-select', ['translated_name' => trans('general.manufacturer'), 'fieldname' => 'manufacturer_id'])
@include ('partials.forms.edit.model_number')
@include ('partials.forms.edit.depreciation')
@include ('partials.forms.edit.minimum_quantity')
<!-- EOL -->

View file

@ -0,0 +1,94 @@
<?php
namespace Tests\Feature\Api\Departments;
use App\Models\Company;
use App\Models\Department;
use App\Models\User;
use Illuminate\Routing\Route;
use Illuminate\Testing\Fluent\AssertableJson;
use Tests\Support\InteractsWithSettings;
use Tests\TestCase;
class DepartmentIndexTest extends TestCase
{
use InteractsWithSettings;
public function testViewingDepartmentIndexRequiresAuthentication()
{
$this->getJson(route('api.departments.index'))->assertRedirect();
}
public function testViewingDepartmentIndexRequiresPermission()
{
$this->actingAsForApi(User::factory()->create())
->getJson(route('api.departments.index'))
->assertForbidden();
}
public function testDepartmentIndexReturnsExpectedDepartments()
{
Department::factory()->count(3)->create();
$this->actingAsForApi(User::factory()->superuser()->create())
->getJson(
route('api.departments.index', [
'sort' => 'name',
'order' => 'asc',
'offset' => '0',
'limit' => '20',
]))
->assertOk()
->assertJsonStructure([
'total',
'rows',
])
->assertJson(fn(AssertableJson $json) => $json->has('rows', 3)->etc());
}
public function testDepartmentIndexAdheresToCompanyScoping()
{
[$companyA, $companyB] = Company::factory()->count(2)->create();
$departmentA = Department::factory()->for($companyA)->create();
$departmentB = Department::factory()->for($companyB)->create();
$superUser = $companyA->users()->save(User::factory()->superuser()->make());
$userInCompanyA = $companyA->users()->save(User::factory()->viewDepartments()->make());
$userInCompanyB = $companyB->users()->save(User::factory()->viewDepartments()->make());
$this->settings->disableMultipleFullCompanySupport();
$this->actingAsForApi($superUser)
->getJson(route('api.departments.index'))
->assertResponseContainsInRows($departmentA)
->assertResponseContainsInRows($departmentB);
$this->actingAsForApi($userInCompanyA)
->getJson(route('api.departments.index'))
->assertResponseContainsInRows($departmentA)
->assertResponseContainsInRows($departmentB);
$this->actingAsForApi($userInCompanyB)
->getJson(route('api.departments.index'))
->assertResponseContainsInRows($departmentA)
->assertResponseContainsInRows($departmentB);
$this->settings->enableMultipleFullCompanySupport();
$this->actingAsForApi($superUser)
->getJson(route('api.departments.index'))
->assertResponseContainsInRows($departmentA)
->assertResponseContainsInRows($departmentB);
$this->actingAsForApi($userInCompanyA)
->getJson(route('api.departments.index'))
->assertResponseContainsInRows($departmentA)
->assertResponseDoesNotContainInRows($departmentB);
$this->actingAsForApi($userInCompanyB)
->getJson(route('api.departments.index'))
->assertResponseDoesNotContainInRows($departmentA)
->assertResponseContainsInRows($departmentB);
}
}

View file

@ -56,32 +56,32 @@ class UsersUpdateTest extends TestCase
->assertOk();
$user->refresh();
$this->assertEquals('Mabel', $user->first_name);
$this->assertEquals('Mora', $user->last_name);
$this->assertEquals('mabel', $user->username);
$this->assertTrue(Hash::check('super-secret', $user->password));
$this->assertEquals('mabel@onlymurderspod.com', $user->email);
$this->assertArrayHasKey('a.new.permission', $user->decodePermissions());
$this->assertTrue($user->activated);
$this->assertEquals('619-555-5555', $user->phone);
$this->assertEquals('Host', $user->jobtitle);
$this->assertTrue($user->manager->is($manager));
$this->assertEquals('1111', $user->employee_num);
$this->assertEquals('Pretty good artist', $user->notes);
$this->assertTrue($user->company->is($company));
$this->assertTrue($user->department->is($department));
$this->assertTrue($user->location->is($location));
$this->assertEquals(1, $user->remote);
$this->assertTrue($user->groups->contains($groupA));
$this->assertTrue($user->vip);
$this->assertEquals('2021-08-01', $user->start_date);
$this->assertEquals('2025-12-31', $user->end_date);
$this->assertEquals('Mabel', $user->first_name, 'First name was not updated');
$this->assertEquals('Mora', $user->last_name, 'Last name was not updated');
$this->assertEquals('mabel', $user->username, 'Username was not updated');
$this->assertTrue(Hash::check('super-secret', $user->password), 'Password was not updated');
$this->assertEquals('mabel@onlymurderspod.com', $user->email, 'Email was not updated');
$this->assertArrayHasKey('a.new.permission', $user->decodePermissions(), 'Permissions were not updated');
$this->assertTrue((bool)$user->activated, 'User not marked as activated');
$this->assertEquals('619-555-5555', $user->phone, 'Phone was not updated');
$this->assertEquals('Host', $user->jobtitle, 'Job title was not updated');
$this->assertTrue($user->manager->is($manager), 'Manager was not updated');
$this->assertEquals('1111', $user->employee_num, 'Employee number was not updated');
$this->assertEquals('Pretty good artist', $user->notes, 'Notes was not updated');
$this->assertTrue($user->company->is($company), 'Company was not updated');
$this->assertTrue($user->department->is($department), 'Department was not updated');
$this->assertTrue($user->location->is($location), 'Location was not updated');
$this->assertEquals(1, $user->remote, 'Remote was not updated');
$this->assertTrue($user->groups->contains($groupA), 'Groups were not updated');
$this->assertEquals(1, $user->vip, 'VIP was not updated');
$this->assertEquals('2021-08-01', $user->start_date, 'Start date was not updated');
$this->assertEquals('2025-12-31', $user->end_date, 'End date was not updated');
// `groups` can be an id or array or ids
$this->patch(route('api.users.update', $user), ['groups' => [$groupA->id, $groupB->id]]);
$user->refresh();
$this->assertTrue($user->groups->contains($groupA));
$this->assertTrue($user->groups->contains($groupB));
$this->assertTrue($user->groups->contains($groupA), 'Not part of expected group');
$this->assertTrue($user->groups->contains($groupB), 'Not part of expected group');
}
}