Merge remote-tracking branch 'origin/develop'

This commit is contained in:
snipe
2025-10-04 14:09:54 +01:00
16 changed files with 886 additions and 693 deletions

View File

@@ -60,19 +60,57 @@ class SendExpirationAlerts extends Command
Mail::to($recipients)->send(new ExpiringAssetsMail($assets, $alert_interval));
$this->table(
['ID', 'Tag', 'Model', 'Model Number', 'EOL', 'EOL Months', 'Warranty Expires', 'Warranty Months'],
$assets->map(fn($item) => ['ID' => $item->id, 'Tag' => $item->asset_tag, 'Model' => $item->model->name, 'Model Number' => $item->model->model_number, 'EOL' => $item->asset_eol_date, 'EOL Months' => $item->model->eol, 'Warranty Expires' => $item->warranty_expires, 'Warranty Months' => $item->warranty_months])
);
[
trans('general.id'),
trans('admin/hardware/form.tag'),
trans('admin/hardware/form.model'),
trans('general.model_no'),
trans('general.purchase_date'),
trans('admin/hardware/form.eol_rate'),
trans('admin/hardware/form.eol_date'),
trans('admin/hardware/form.warranty_expires'),
],
$assets->map(fn($item) =>
[
trans('general.id') => $item->id,
trans('admin/hardware/form.tag') => $item->asset_tag,
trans('admin/hardware/form.model') => $item->model->name,
trans('general.model_no') => $item->model->model_number,
trans('general.purchase_date') => $item->purchase_date_formatted,
trans('admin/hardware/form.eol_rate') => $item->model->eol,
trans('admin/hardware/form.eol_date') => $item->eol_date ? $item->eol_formatted_date .' ('.$item->eol_diff_for_humans.')' : '',
trans('admin/hardware/form.warranty_expires') => $item->warranty_expires ? $item->warranty_expires_formatted_date .' ('.$item->warranty_expires_diff_for_humans.')' : '',
])
);
}
// Expiring licenses
$licenses = License::getExpiringLicenses($alert_interval);
$licenses = License::query()->ExpiringLicenses($alert_interval)
->with('manufacturer','category')
->orderBy('expiration_date', 'ASC')
->orderBy('termination_date', 'ASC')
->get();
if ($licenses->count() > 0) {
Mail::to($recipients)->send(new ExpiringLicenseMail($licenses, $alert_interval));
$this->table(
['ID', 'Name', 'Expires', 'Termination Date'],
$licenses->map(fn($item) => ['ID' => $item->id, 'Name' => $item->name, 'Expires' => $item->expiration_date, 'Termination Date' => $item->termination_date])
[
trans('general.id'),
trans('general.name'),
trans('general.purchase_date'),
trans('admin/licenses/form.expiration'),
trans('mail.expires'),
trans('admin/licenses/form.termination_date'),
trans('mail.terminates')],
$licenses->map(fn($item) => [
trans('general.id') => $item->id,
trans('general.name') => $item->name,
trans('general.purchase_date') => $item->purchase_date_formatted,
trans('admin/licenses/form.expiration') => $item->expires_formatted_date,
trans('mail.expires') => $item->expires_diff_for_humans,
trans('admin/licenses/form.termination_date') => $item->terminates_formatted_date,
trans('mail.terminates') => $item->terminates_diff_for_humans
])
);
}

View File

@@ -7,6 +7,7 @@ use App\Http\Controllers\Controller;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\License;
use App\Models\Setting;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Http\JsonResponse;
@@ -25,9 +26,12 @@ class LicensesController extends Controller
$this->authorize('view', License::class);
$licenses = License::with('company', 'manufacturer', 'supplier','category', 'adminuser')->withCount('freeSeats as free_seats_count');
$settings = Setting::getSettings();
if ($request->input('status')=='inactive') {
$licenses->ExpiredLicenses();
} elseif ($request->input('status')=='expiring') {
$licenses->ExpiringLicenses($settings->alert_interval);
} else {
$licenses->ActiveLicenses();
}

View File

@@ -227,7 +227,6 @@ class Asset extends Depreciable
}
public function customFieldValidationRules()
{
@@ -266,7 +265,6 @@ class Asset extends Depreciable
return parent::save($params);
}
public function getDisplayNameAttribute()
{
return $this->present()->name();
@@ -277,20 +275,79 @@ class Asset extends Depreciable
*
* @return \Carbon\Carbon|null
*/
public function getWarrantyExpiresAttribute()
protected function warrantyExpires(): Attribute
{
if (isset($this->attributes['warranty_months']) && isset($this->attributes['purchase_date'])) {
if (is_string($this->attributes['purchase_date']) || is_string($this->attributes['purchase_date'])) {
$purchase_date = \Carbon\Carbon::parse($this->attributes['purchase_date']);
} else {
$purchase_date = \Carbon\Carbon::instance($this->attributes['purchase_date']);
return Attribute:: make(
get: fn(mixed $value, array $attributes) => ($attributes['warranty_months'] && $attributes['purchase_date']) ? Carbon::parse($attributes['purchase_date'])->addMonths($attributes['warranty_months']) : null,
);
}
protected function warrantyExpiresFormattedDate(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => Helper::getFormattedDateObject($this->warrantyExpires, 'date', false)
);
}
protected function warrantyExpiresDiff(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $this->warrantyExpires ? round((Carbon::now()->diffInDays($this->warrantyExpires))) : null,
);
}
protected function warrantyExpiresDiffForHumans(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $this->warrantyExpires ? Carbon::parse($this->warrantyExpires)->diffForHumans() : null,
);
}
protected function eolDate(): Attribute
{
return Attribute:: make(
get: function(mixed $value, array $attributes) {
if ($attributes['asset_eol_date'] && $attributes['eol_explicit'] == '1') {
return Carbon::parse($attributes['asset_eol_date']);
} elseif ($attributes['purchase_date'] && $this->model && ((int) $this->model->eol > 0)) {
return Carbon::parse($attributes['purchase_date'])->addMonths((int) $this->model->eol);
} else {
return null;
}
}
$purchase_date->setTime(0, 0, 0);
);
return $purchase_date->addMonths((int) $this->attributes['warranty_months']);
}
}
protected function eolFormattedDate(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $this->eolDate ? Helper::getFormattedDateObject($this->eolDate, 'date', false) : null,
);
}
protected function eolDiffInDays(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $this->eolDate ? round((Carbon::now()->diffInDays(Carbon::parse($this->eolDate), false, 1))) : null,
);
}
protected function eolDiffForHumans(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $this->eolDate ? Carbon::parse($this->eolDate)->diffForHumans() : null,
);
return null;
}

View File

@@ -8,6 +8,7 @@ use App\Models\Traits\HasUploads;
use App\Models\Traits\Searchable;
use App\Presenters\Presentable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
@@ -156,6 +157,29 @@ class License extends Depreciable
);
}
protected function terminatesFormattedDate(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['termination_date'] ? Helper::getFormattedDateObject($attributes['termination_date'], 'date', false) : null,
);
}
protected function terminatesDiffInDays(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['termination_date'] ? Carbon::now()->diffInDays($attributes['termination_date']) : null,
);
}
protected function terminatesDiffForHumans(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['termination_date'] ? Carbon::parse($attributes['termination_date'])->diffForHumans() : null,
);
}
public function prepareLimitChangeRule($parameters, $field)
{
$actual_seat_count = $this->licenseseats()->count(); //we use the *actual* seat count here, in case your license has gone wonky
@@ -706,49 +730,6 @@ class License extends Depreciable
return $this->hasMany(\App\Models\LicenseSeat::class)->whereNull('assigned_to')->whereNull('deleted_at')->whereNull('asset_id');
}
/**
* Returns expiring licenses.
*
* This checks if:
*
* 1) The license has not been deleted
* 2) The expiration date is between now and the number of days specified
* 3) There is an expiration date set and the termination date has not passed
* 4) The license termination date is null or has not passed
*
* @author A. Gianotto <snipe@snipe.net>
* @since [v1.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
* @see \App\Console\Commands\SendExpiringLicenseNotifications
*/
public static function getExpiringLicenses($days = 60)
{
return self::whereNull('licenses.deleted_at')
// The termination date is null or within range
->where(function ($query) use ($days) {
$query->whereNull('termination_date')
->orWhereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]);
})
->where(function ($query) use ($days) {
$query->whereNotNull('expiration_date')
// Handle expired licenses without termination dates
->where(function ($query) use ($days) {
$query->whereNull('termination_date')
->whereBetween('expiration_date', [Carbon::now(), Carbon::now()->addDays($days)]);
})
// Handle expired licenses with termination dates in the future
->orWhere(function ($query) use ($days) {
$query->whereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]);
});
})
->orderBy('expiration_date', 'ASC')
->orderBy('termination_date', 'ASC')
->get();
}
public function scopeActiveLicenses($query)
{
@@ -765,19 +746,57 @@ class License extends Depreciable
});
}
/**
* Expiried/terminated licenses scope
*
* @author A. Gianotto <snipe@snipe.net>
* @since [v1.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
* @see \App\Console\Commands\SendExpiringLicenseNotifications
*/
public function scopeExpiredLicenses($query)
{
return $query->whereDate('termination_date', '<=', Carbon::now())// The termination date is null or within range
->orWhere(function ($query) {
$query->whereDate('expiration_date', '<=', Carbon::now());
})
->whereNull('deleted_at');
}
return $query->whereNull('licenses.deleted_at')
/**
* Expiring/terminating licenses scope
*
* This checks if:
*
* 1) The license has not been deleted
* 2) The expiration date is between now and the number of days specified
* 3) There is an expiration date set and the termination date has not passed
* 4) The license termination date is null or has not passed
*
* @author A. Gianotto <snipe@snipe.net>
* @since [v1.0]
* @return \Illuminate\Database\Eloquent\Relations\Relation
* @see \App\Console\Commands\SendExpiringLicenseNotifications
*/
public function scopeExpiringLicenses($query, $days = 60)
{
return $query// The termination date is null or within range
->where(function ($query) use ($days) {
$query->whereNull('termination_date')
->orWhereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]);
})
->where(function ($query) use ($days) {
$query->whereNotNull('expiration_date')
// Handle expiring licenses without termination dates
->where(function ($query) use ($days) {
$query->whereNull('termination_date')
->whereBetween('expiration_date', [Carbon::now(), Carbon::now()->addDays($days)]);
})
// The termination date is null or within range
->where(function ($query) {
$query->whereNull('termination_date')
->orWhereDate('termination_date', '<=', [Carbon::now()]);
})
->orWhere(function ($query) {
$query->whereNull('expiration_date')
->orWhereDate('expiration_date', '<=', [Carbon::now()]);
// Handle expiring licenses with termination dates in the future
->orWhere(function ($query) use ($days) {
$query->whereBetween('termination_date', [Carbon::now(), Carbon::now()->addDays($days)]);
});
});
}

View File

@@ -3,6 +3,7 @@
namespace App\Models;
use App\Helpers\Helper;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model;
@@ -19,6 +20,37 @@ class SnipeModel extends Model
$this->attributes['purchase_date'] = $value;
}
protected function purchaseDateFormatted(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['purchase_date'] ? Helper::getFormattedDateObject(Carbon::parse($attributes['purchase_date']), 'date', false) : null,
);
}
protected function expiresDiffInDays(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['expiration_date'] ? Carbon::now()->diffInDays($attributes['expiration_date']) : null,
);
}
protected function expiresDiffForHumans(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['expiration_date'] ? Carbon::parse($attributes['expiration_date'])->diffForHumans() : null,
);
}
protected function expiresFormattedDate(): Attribute
{
return Attribute:: make(
get: fn(mixed $value, array $attributes) => $attributes['expiration_date'] ? Helper::getFormattedDateObject($attributes['expiration_date'], 'date', false) : null,
);
}
/**
* @param $value
*/
@@ -180,6 +212,7 @@ class SnipeModel extends Model
);
}
public function getEula()
{

View File

@@ -48,6 +48,13 @@ class LicensePresenter extends Presenter
'sortable' => true,
'title' => trans('admin/licenses/form.expiration'),
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'termination_date',
'searchable' => true,
'sortable' => true,
'visible' => false,
'title' => trans('admin/licenses/form.termination_date'),
'formatter' => 'dateDisplayFormatter',
], [
'field' => 'license_email',
'searchable' => true,
@@ -110,14 +117,6 @@ class LicensePresenter extends Presenter
'title' => trans('general.purchase_date'),
'formatter' => 'dateDisplayFormatter',
],
[
'field' => 'termination_date',
'searchable' => true,
'sortable' => true,
'visible' => false,
'title' => trans('admin/licenses/form.termination_date'),
'formatter' => 'dateDisplayFormatter',
],
[
'field' => 'depreciation',
'searchable' => true,

View File

@@ -350,10 +350,15 @@ class BreadcrumbsServiceProvider extends ServiceProvider
* Licenses Breadcrumbs
*/
if ((request()->is('licenses*')) && (request()->status=='inactive')) {
Breadcrumbs::for('licenses.index', fn(Trail $trail) => $trail->parent('home', route('home'))
->push(trans('general.licenses'), route('licenses.index'))
->push(trans('general.show_inactive'), route('licenses.index'))
);
} elseif ((request()->is('licenses*')) && (request()->status=='expiring')) {
Breadcrumbs::for('licenses.index', fn (Trail $trail) =>
$trail->parent('home', route('home'))
->push(trans('general.licenses'), route('licenses.index'))
->push(trans('general.show_inactive'), route('licenses.index'))
->push(trans('general.show_expiring'), route('licenses.index'))
);
} else {
Breadcrumbs::for('licenses.index', fn (Trail $trail) =>

View File

@@ -398,6 +398,7 @@ return [
'permissions' => 'Permissions',
'managed_ldap' => '(Managed via LDAP)',
'export' => 'Export',
'export_all_to_csv' => 'Export all to CSV',
'ldap_sync' => 'LDAP Sync',
'ldap_user_sync' => 'LDAP User Sync',
'synchronize' => 'Synchronize',
@@ -595,6 +596,7 @@ return [
],
'show_inactive' => 'Expired or Terminated',
'show_expiring' => 'Expiring or Terminating Soon',
'more_info' => 'More Info',
'quickscan_bulk_help' => 'Checking this box will edit the asset record to reflect this new location. Leaving it unchecked will simply note the location in the audit log. Note that if this asset is checked out, it will not change the location of the person, asset or location it is checked out to.',
'whoops' => 'Whoops!',

View File

@@ -59,6 +59,7 @@ return [
'days' => 'Days',
'expecting_checkin_date' => 'Expected Checkin Date',
'expires' => 'Expires',
'terminates' => 'Terminates',
'following_accepted' => 'The following was accepted',
'following_declined' => 'The following was declined',
'hello' => 'Hello',

View File

@@ -63,7 +63,6 @@
data-columns="{{ \App\Presenters\AssetPresenter::dataTableLayout() }}"
data-cookie-id-table="{{ request()->has('status') ? e(request()->input('status')) : '' }}assetsListingTable"
data-id-table="{{ request()->has('status') ? e(request()->input('status')) : '' }}assetsListingTable"
data-search-text="{{ e(Session::get('search')) }}"
data-side-pagination="server"
data-show-footer="true"
data-sort-order="asc"

View File

@@ -1,38 +1,22 @@
@component('mail::message')
{{ trans_choice('mail.assets_warrantee_alert', $assets->count(), ['count'=>$assets->count(), 'threshold' => $threshold]) }}
<style>
th, td {
vertical-align: top;
}
hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #ccc;
margin: 1em 0;
padding: 0;
}
</style>
<x-mail::table>
| | | |
| ------------- | ------------- | ------------- |
@foreach ($assets as $asset)
@php
$warranty_expires = \App\Helpers\Helper::getFormattedDateObject($asset->present()->warranty_expires, 'date');
$eol_date = \App\Helpers\Helper::getFormattedDateObject($asset->asset_eol_date, 'date');
$warranty_diff = ($asset->present()->warranty_expires) ? round(\Carbon\Carbon::now()->diffInDays(\Carbon\Carbon::parse($warranty_expires['date']), false), 1) : '';
$eol_diff = round(\Carbon\Carbon::now()->diffInDays(\Carbon\Carbon::parse($asset->asset_eol_date), false), 1);
$icon = ($warranty_diff <= $threshold && $warranty_diff >= 0) ? '⚠️' : (($eol_diff <= $threshold && $eol_diff >= 0) ? '🚨' : '');
@endphp
| {{ $icon }} **{{ trans('mail.name') }}** | <a href="{{ route('hardware.show', $asset->id) }}">{{ $asset->display_name }}</a> <br><small>{{trans('mail.serial').': '.$asset->serial}}</small> |
@if ($warranty_expires)
| **{{ trans('mail.expires') }}** | {{ !is_null($warranty_expires) ? $warranty_expires['formatted'] : '' }} (<strong>{{ $warranty_diff }} {{ trans('mail.Days') }}</strong>) |
| {{ ($asset->eol_diff_in_days <= ($threshold / 2)) ? '🚨' : (($asset->eol_diff_in_days <= $threshold) ? '⚠️' : ' ') }} **{{ trans('mail.name') }}** | <a href="{{ route('hardware.show', $asset->id) }}">{{ $asset->display_name }}</a> |
@if ($asset->serial)
| **{{ trans('general.serial_number') }}** | {{ $asset->serial }} |
@endif
@if ($eol_date)
| **{{ trans('mail.eol') }}** | {{ !is_null($eol_date) ? $eol_date['formatted'] : '' }} (<strong>{{ $eol_diff }} {{ trans('mail.Days') }}</strong>) |
@if ($asset->purchase_date)
| **{{ trans('general.purchase_date') }}** | {{ $asset->purchase_date_formatted }} |
@endif
@if ($asset->warranty_expires)
| **{{ trans('mail.expires') }}** | {{ $asset->warranty_expires_formatted_date }} ({{ $asset->warranty_expires_diff_for_humans }}) |
@endif
@if ($asset->eol_date && $asset->eol_diff_for_humans)
| **{{ trans('mail.eol') }}** | {{ $asset->eol_formatted_date }} ({{ $asset->eol_diff_for_humans }}) |
@endif
@if ($asset->supplier)
| **{{ trans('mail.supplier') }}** | {{ ($asset->supplier ? e($asset->supplier->name) : '') }} |

View File

@@ -1,17 +1,13 @@
@component('mail::message')
{{ trans_choice('mail.license_expiring_alert', $licenses->count(), ['count'=>$licenses->count(), 'threshold' => $threshold]) }}
@component('mail::table')
<table width="100%">
<tr><td>&nbsp;</td><td>{{ trans('mail.name') }}</td><td>{{ trans('mail.Days') }}</td><td>{{ trans('mail.expires') }}</td></tr>
<x-mail::table>
| | {{ trans('mail.name') }} | {{ trans('general.category') }} | {{ trans('mail.expires') }} | {{ trans('mail.terminates') }} |
| :------------- | :------------- | :------------- | :------------- | :------------- |
@foreach ($licenses as $license)
@php
$expires = Helper::getFormattedDateObject($license->expiration_date, 'date');
$diff = round(abs(strtotime($license->expiration_date->format('Y-m-d')) - strtotime(date('Y-m-d')))/86400);
$icon = ($diff <= ($threshold / 2)) ? '🚨' : (($diff <= $threshold) ? '⚠️' : ' ');
@endphp
<tr><td>{{ $icon }} </td><td> <a href="{{ route('licenses.show', $license->id) }}">{{ $license->name }}</a> </td><td> {{ $diff }} {{ trans('mail.Days') }} </td><td>{{ $expires['formatted'] }}</td></tr>
| {{ (($license->isExpired()) || ($license->isTerminated()) || ($license->terminates_diff_in_days <= ($threshold / 2)) || ($license->expires_diff_in_days <= ($threshold / 2))) ? '🚨' : (($license->expires_diff_in_days <= $threshold) ? '⚠️' : ' ') }} | <a href="{{ route('licenses.show', $license->id) }}">{{ $license->name }}</a> {{ $license->manufacturer ? '('.$license->manufacturer->name.')' : '' }} | {{ $license->category ? $license->category->name : '' }} | {{ $license->expires_formatted_date }} {!! $license->expires_diff_for_humans ? ' ('.$license->expires_diff_for_humans .')' : '' !!} | {{ $license->terminates_formatted_date }} {{ $license->terminates_diff_for_humans ? ' ('.$license->terminates_diff_for_humans .')' : '' }}|
| <hr> | <hr> | <hr> | <hr> | <hr> |
@endforeach
</table>
</x-mail::table>
@endcomponent
@endcomponent

File diff suppressed because it is too large Load Diff

View File

@@ -43,6 +43,7 @@
data-toolbar="#userBulkEditToolbar"
data-bulk-button-id="#bulkUserEditButton"
data-bulk-form-id="#usersBulkForm"
data-show-columns-search="true"
id="usersTable"
data-buttons="userButtons"
class="table table-striped snipe-table"

View File

@@ -3,7 +3,20 @@
@slot('header')
{{-- Check that the $snipeSettings variable is set, images are set to be shown, and setup is complete --}}
<style>
th, td {
vertical-align: top;
}
hr {
display: block;
height: 1px;
border: 0;
border-top: 1px solid #edeff2;
margin: 1em 0;
padding: 0;
}
</style>
@if (isset($snipeSettings) && ($snipeSettings::setupCompleted()))

View File

@@ -10,9 +10,12 @@ class SnipeModelTest extends TestCase
{
$c = new SnipeModel;
$c->purchase_date = '';
$this->assertTrue($c->purchase_date === null);
$c->purchase_date = '2016-03-25 12:35:50';
$this->assertTrue($c->purchase_date === '2016-03-25 12:35:50');
$this->assertNull($c->purchase_date);
$c->purchase_date = null;
$this->assertNull($c->purchase_date);
$c->purchase_date = '2016-03-25';
$this->assertTrue($c->purchase_date === '2016-03-25');
$this->assertEquals('2016-03-25', $c->purchase_date);
}
public function testSetsPurchaseCostsAppropriately()