Compare commits
272 Commits
security-fixes
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3680198d61 | |||
| da86e919d9 | |||
| c98eea1ce5 | |||
| 45d6a491cb | |||
| 3f77a6eeab | |||
| 3dc90f89f6 | |||
| 5333837b20 | |||
| e2bea57146 | |||
| 43a32071f1 | |||
| e3a9872d28 | |||
| 75f86cd669 | |||
| 73f72cbbb0 | |||
| 2033f25386 | |||
| 8d0a6af2aa | |||
| 2aae0cb793 | |||
| caf5e347e3 | |||
| a698ba3082 | |||
| b57d286b15 | |||
| 3cd5e86527 | |||
| 31cf7ba6b5 | |||
| bccba46332 | |||
| 70357ada3d | |||
| 6769424aa2 | |||
| 043ad713e7 | |||
| bb178b0a5c | |||
| 288bded7d9 | |||
| 5c87735218 | |||
| d12ad3d538 | |||
| f269cfec34 | |||
| 905d498ecd | |||
| 802067f398 | |||
| a212f87acd | |||
| b40e227ad3 | |||
| b89504e1c3 | |||
| c8ae09cd43 | |||
| 8ebddd95ff | |||
| c14880dfca | |||
| a27c551f64 | |||
| e71453cb5d | |||
| bb19add3b6 | |||
| 9bd6396a15 | |||
| b060327219 | |||
| 8b383df13f | |||
| 6a0ec69451 | |||
| 66bf6275b8 | |||
| 6fc2ff7252 | |||
| 0f6367bb17 | |||
| e3190c3922 | |||
| 53628d6ae3 | |||
| d03f68ae34 | |||
| 87bc834885 | |||
| 9f89dffaae | |||
| ab1a5c0241 | |||
| 758c1cabc5 | |||
| 3dd5358e73 | |||
| 8cc4ad27c9 | |||
| cd1f6b8e73 | |||
| 07e70cf7a9 | |||
| a9d1069705 | |||
| 9967d1e784 | |||
| 10703263a8 | |||
| 1697d10f16 | |||
| b0aa21bee7 | |||
| 82fa1d7a26 | |||
| be446e97d7 | |||
| c44f3319e3 | |||
| 678d1c1428 | |||
| 535d7c0ff6 | |||
| df4069375f | |||
| e430e4e6e2 | |||
| 0d2af35420 | |||
| df92076e15 | |||
| e2ba35ee80 | |||
| f4cac96358 | |||
| 8e8a3f2d24 | |||
| 5257c2ce84 | |||
| b378cf31f4 | |||
| 0f184840df | |||
| 3df21df85b | |||
| 7b679b7df4 | |||
| 0d870d540d | |||
| d626770c91 | |||
| 144772cfbe | |||
| 80c8aa41dc | |||
| 5658cd6dd4 | |||
| 374f426f0c | |||
| 2af0c237a9 | |||
| dafd72af59 | |||
| 4a3bd51d9c | |||
| cbc6dc94a5 | |||
| 10ceb5a858 | |||
| f74e7510c5 | |||
| 80fd4798ce | |||
| d87cd7cbb9 | |||
| 910b726e34 | |||
| 9a8cbd6e00 | |||
| abc4363e83 | |||
| 006981cccf | |||
| df0ee6020a | |||
| 000cea0a62 | |||
| 53599544af | |||
| b5ec9e080d | |||
| beb593b37e | |||
| 8f98c8a862 | |||
| 9d518ec39f | |||
| 0959d87534 | |||
| 1252681d55 | |||
| 9bc4efa5ff | |||
| 6116f1a9fe | |||
| 5656e4f5b7 | |||
| 86ba1f56fb | |||
| a966198a75 | |||
| 4ff214ac47 | |||
| 5169d174ad | |||
| 9c849c337f | |||
| d0685464f6 | |||
| 10b5a8ef21 | |||
| f0a9a49753 | |||
| 1afde946d2 | |||
| a2a2de9718 | |||
| e8ba1feddc | |||
| 18e9b5c5bf | |||
| f186dc20f6 | |||
| 80a722d465 | |||
| 765487f62e | |||
| 1d186fffaa | |||
| 6295b7726e | |||
| e7c45644b9 | |||
| a5fa1f5b97 | |||
| 356a0d4c12 | |||
| bc80f5eb55 | |||
| 00d4d6c7a8 | |||
| ed1e89d5be | |||
| 371d44b2a7 | |||
| 79732a9151 | |||
| a6e55fb462 | |||
| d032a51a3d | |||
| 9c2495af29 | |||
| dbcd2e54ea | |||
| d7bc6c45f6 | |||
| 4382e01f57 | |||
| bab5294399 | |||
| 7122a79afe | |||
| a161fa8519 | |||
| 5e5bd7a17d | |||
| 285717ab12 | |||
| 81d91da0b8 | |||
| b017e9382f | |||
| db325483fe | |||
| eb5334e865 | |||
| 8d9e3444c4 | |||
| 01b1c3923d | |||
| 861f061f0f | |||
| 780fb76af8 | |||
| 85df607edc | |||
| ab90fc16e0 | |||
| 990c50c5b9 | |||
| 2e91b3dc9a | |||
| 211bd02786 | |||
| e8d000a17a | |||
| 8fc373abfc | |||
| a473ca737e | |||
| ff6fc68981 | |||
| f133a67550 | |||
| b0a6cdc29f | |||
| 8f605e04cb | |||
| edcb429366 | |||
| 8b52b4684d | |||
| ba5a674526 | |||
| e84496f8b1 | |||
| 5f9212383a | |||
| a5493f11bc | |||
| ce434b3d04 | |||
| ade07b411b | |||
| d877688f32 | |||
| 3868e469c0 | |||
| ea939acbd3 | |||
| 522544c131 | |||
| fad2655357 | |||
| 445fb6f253 | |||
| 7bf8fd5eeb | |||
| c758fb4c83 | |||
| 4145f64399 | |||
| 4120ab6fe6 | |||
| 0170fb7711 | |||
| 42df2f6c31 | |||
| 9b522b69ff | |||
| 135db70b0f | |||
| 048e97f9a9 | |||
| 18d8f257ee | |||
| ec67195014 | |||
| 0d745ad10f | |||
| 89ce71b350 | |||
| 63c1f7922f | |||
| 5809ac7997 | |||
| 92b6e46249 | |||
| 46c11d8599 | |||
| 7651365ff6 | |||
| 35caa0e68d | |||
| e0a7fe443d | |||
| c31190a128 | |||
| de50ec30b7 | |||
| c0fe308d7d | |||
| 0a20141b7c | |||
| 4d0282ca0a | |||
| e61143f746 | |||
| 69dc91d225 | |||
| 6f1c49e14d | |||
| 78acc3685d | |||
| bf5013e527 | |||
| cbd961e922 | |||
| fa26e23383 | |||
| 7abe1bed50 | |||
| 155df0a94d | |||
| 4d06e81768 | |||
| 9bf1e2401d | |||
| 4edf40acaf | |||
| a54ed750a3 | |||
| c02a6c105a | |||
| 4d2416ab96 | |||
| 61ae30528a | |||
| e883eb70b9 | |||
| a5b1379cdb | |||
| ef64210ed2 | |||
| f29846ec20 | |||
| d2b4d84374 | |||
| dfe3f5fb9f | |||
| 8dbb19eb82 | |||
| 45cdff6920 | |||
| cfa8069953 | |||
| b3be2baf40 | |||
| f4b9138a3f | |||
| f5dbf27592 | |||
| 069912d051 | |||
| 86245ad4ae | |||
| c8bafdad79 | |||
| c94fce2367 | |||
| 653b1327cb | |||
| 849b217300 | |||
| 371f096e54 | |||
| 72a11113e7 | |||
| b0635f24db | |||
| 96088c416e | |||
| c8f3e833e5 | |||
| 5307a44fab | |||
| 2d6eb5d80a | |||
| 90e2c105cd | |||
| 0c59ca70cf | |||
| 875b0bbdec | |||
| be1f1bd1c5 | |||
| c9be696c84 | |||
| 187f160b21 | |||
| 8908b67b3d | |||
| 4373f761c7 | |||
| 8e9bd5dbb1 | |||
| 751541a54d | |||
| 3972799e56 | |||
| db2afd0dc7 | |||
| 460daf71b6 | |||
| 3074bae47c | |||
| 0f80950a91 | |||
| 2620b60048 | |||
| 81b1cdc6e9 | |||
| 0304933c53 | |||
| f0d84f5350 | |||
| 1ad562f8b9 | |||
| a5cea247f1 | |||
| 571bc39495 | |||
| 8ea78fae21 | |||
| ed6b3c04ab | |||
| a4ca0a592f | |||
| 90c8689596 |
@@ -37,6 +37,7 @@ MYSQL_ROOT_PASSWORD=changeme1234
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=true
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@ DB_PASSWORD=null
|
||||
DB_PREFIX=null
|
||||
DB_DUMP_PATH='/usr/bin'
|
||||
DB_DUMP_SKIP_SSL=false
|
||||
DB_DUMP_SINGLE_TRANSACTION=false
|
||||
DB_CHARSET=utf8mb4
|
||||
DB_COLLATION=utf8mb4_unicode_ci
|
||||
DB_SANITIZE_BY_DEFAULT=false
|
||||
@@ -133,7 +134,7 @@ BS_TABLE_DEEPLINK=true
|
||||
APP_TRUSTED_PROXIES=192.168.1.1,10.0.0.1
|
||||
ALLOW_IFRAMING=false
|
||||
REFERRER_POLICY=same-origin
|
||||
ENABLE_CSP=false
|
||||
ENABLE_CSP=true
|
||||
ADDITIONAL_CSP_URLS=null
|
||||
CORS_ALLOWED_ORIGINS=null
|
||||
ENABLE_HSTS=false
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Events\CheckoutableCheckedIn;
|
||||
use App\Mail\BulkDeleteReportMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\Console\Helper\ProgressBar;
|
||||
|
||||
use function Laravel\Prompts\confirm;
|
||||
use function Laravel\Prompts\error;
|
||||
use function Laravel\Prompts\info;
|
||||
use function Laravel\Prompts\multisearch;
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\search;
|
||||
use function Laravel\Prompts\select;
|
||||
use function Laravel\Prompts\warning;
|
||||
|
||||
class BulkDelete extends Command
|
||||
{
|
||||
protected $signature = 'snipeit:checkin-delete-items';
|
||||
|
||||
protected $description = 'Interactively check in and/or delete items by company and type';
|
||||
|
||||
private const CHECKIN_NOTE = 'Checked in via bulk CLI operation';
|
||||
|
||||
private array $reportLines = [];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
// Step 1: Dry run?
|
||||
$dryRun = confirm(
|
||||
label: 'Is this a dry run?',
|
||||
default: true,
|
||||
yes: 'Yes — preview only, no changes will be made',
|
||||
no: 'No — LIVE RUN, changes WILL be made',
|
||||
);
|
||||
|
||||
// Step 2: Who are you?
|
||||
$adminId = search(
|
||||
label: 'Who are you? Search by username, first or last name.',
|
||||
placeholder: 'Type to search users...',
|
||||
options: function (string $value): array {
|
||||
if (strlen($value) < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return User::where('activated', 1)
|
||||
->whereNull('deleted_at')
|
||||
->onlySuperAdmins()
|
||||
->where(function ($query) use ($value) {
|
||||
$query->where('username', 'like', "%{$value}%")
|
||||
->orWhere('first_name', 'like', "%{$value}%")
|
||||
->orWhere('last_name', 'like', "%{$value}%")
|
||||
->orWhereRaw("CONCAT(first_name, ' ', last_name) LIKE ?", ["%{$value}%"]);
|
||||
})
|
||||
->get()
|
||||
->mapWithKeys(fn (User $u) => [$u->id => "{$u->first_name} {$u->last_name} ({$u->username})"])
|
||||
->toArray();
|
||||
},
|
||||
validate: fn (mixed $value) => ! $value ? 'A valid active user is required.' : null,
|
||||
);
|
||||
|
||||
/** @var User $admin */
|
||||
$admin = User::findOrFail((int) $adminId);
|
||||
|
||||
// Step 3: Which companies?
|
||||
if (! Company::exists()) {
|
||||
error('No companies found. Please create at least one company before using this command.');
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
$selectedCompanyKeys = multisearch(
|
||||
label: 'Which companies would you like to check in and delete items for?',
|
||||
placeholder: 'Type to search companies...',
|
||||
options: function (string $value): array {
|
||||
$results = [];
|
||||
|
||||
if ($value === '' || str_contains('(no company / unassigned)', strtolower($value))) {
|
||||
$results['__null__'] = '(No Company / Unassigned)';
|
||||
}
|
||||
|
||||
$query = Company::orderBy('name');
|
||||
if ($value !== '') {
|
||||
$query->where('name', 'like', "%{$value}%");
|
||||
}
|
||||
|
||||
$query->get()->each(function (Company $c) use (&$results) {
|
||||
$results[$c->id] = "{$c->name} (ID: {$c->id})";
|
||||
});
|
||||
|
||||
return $results;
|
||||
},
|
||||
scroll: 10,
|
||||
required: 'Please select at least one company.',
|
||||
hint: 'If you\'re searching on several differently named companies, use the up-arrow to go back to the search box to search again. ',
|
||||
);
|
||||
|
||||
$includeNullCompany = in_array('__null__', $selectedCompanyKeys);
|
||||
$selectedCompanyIds = array_values(array_filter(
|
||||
$selectedCompanyKeys,
|
||||
fn ($k) => $k !== '__null__'
|
||||
));
|
||||
|
||||
$companyNamesById = Company::whereIn('id', $selectedCompanyIds)->pluck('name', 'id')->toArray();
|
||||
$selectedCompanyNames = array_map(
|
||||
fn ($id) => $id === '__null__' ? '(No Company)' : ($companyNamesById[$id] ?? "(ID: {$id})"),
|
||||
$selectedCompanyKeys
|
||||
);
|
||||
|
||||
// Step 4: Which item types?
|
||||
$rawTypeSelection = multiselect(
|
||||
label: 'What item types would you like to check in and delete?',
|
||||
options: [
|
||||
'all' => 'All Items (assets, licenses, accessories, components, consumables, users)',
|
||||
'assets' => 'Assets',
|
||||
'licenses' => 'Licenses',
|
||||
'accessories' => 'Accessories',
|
||||
'components' => 'Components',
|
||||
'consumables' => 'Consumables',
|
||||
'users' => 'Users',
|
||||
],
|
||||
required: 'Please select at least one item type.',
|
||||
hint: 'Select "All Items" to process every supported type.',
|
||||
);
|
||||
|
||||
$allSubTypes = ['assets', 'licenses', 'accessories', 'components', 'consumables', 'users'];
|
||||
$selectedTypes = in_array('all', $rawTypeSelection)
|
||||
? $allSubTypes
|
||||
: array_values(array_intersect($allSubTypes, $rawTypeSelection));
|
||||
|
||||
// Compute and display counts now so the user can see what will be affected
|
||||
$counts = $this->getCounts($selectedTypes, $selectedCompanyIds, $includeNullCompany);
|
||||
|
||||
$skipAdminUser = false;
|
||||
|
||||
$this->line('');
|
||||
$this->line(' Items that would be affected:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
|
||||
if (in_array('users', $selectedTypes)) {
|
||||
$userInScope = $this->buildUserQuery($selectedCompanyIds, $includeNullCompany)
|
||||
->where('users.id', $admin->id)
|
||||
->exists();
|
||||
|
||||
if ($userInScope) {
|
||||
$skipAdminUser = true;
|
||||
$counts['users'] = max(0, ($counts['users'] ?? 0) - 1);
|
||||
warning(" Your user ({$admin->username}) is within the selected scope and will be skipped during user deletion.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->line('');
|
||||
|
||||
// Step 5: Hard delete, soft delete, or no delete?
|
||||
$deleteType = select(
|
||||
label: 'How should items be deleted?',
|
||||
options: [
|
||||
'soft' => 'Soft delete — items moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
'none' => 'No delete — check in only, items remain in inventory',
|
||||
],
|
||||
default: 'soft',
|
||||
);
|
||||
|
||||
// Step 6: Send checkin notifications? (not applicable to users or consumables)
|
||||
$notifiableTypes = array_intersect($selectedTypes, ['assets', 'licenses', 'accessories', 'components']);
|
||||
$sendNotifications = false;
|
||||
|
||||
if (! empty($notifiableTypes)) {
|
||||
$sendNotifications = confirm(
|
||||
label: 'Should we send checkin notifications?',
|
||||
default: true,
|
||||
hint: 'Applies to: '.implode(', ', $notifiableTypes).'. Users and consumables are excluded.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 7: Clear related action_logs?
|
||||
$clearLogs = confirm(
|
||||
label: 'Should we clear related action logs?',
|
||||
default: false,
|
||||
hint: 'This removes all history for affected items, as if the data never existed.',
|
||||
);
|
||||
|
||||
// Step 8: Delete associated files?
|
||||
$deleteFiles = false;
|
||||
if ($deleteType !== 'none') {
|
||||
$deleteFiles = confirm(
|
||||
label: 'Should we also delete associated image and upload files?',
|
||||
default: $deleteType === 'hard',
|
||||
hint: 'Permanently removes images, avatars, signatures, EULAs, and action log uploads from disk.',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 9: Delete the companies themselves?
|
||||
$deleteCompanyType = 'keep';
|
||||
if (! empty($selectedCompanyIds)) {
|
||||
$deleteCompanyType = select(
|
||||
label: 'Should the selected companies also be deleted?',
|
||||
options: [
|
||||
'keep' => 'Keep — do not delete the companies',
|
||||
'soft' => 'Soft delete — companies moved to trash (recoverable)',
|
||||
'hard' => 'Hard delete — permanently removed (cannot be recovered)',
|
||||
],
|
||||
default: 'keep',
|
||||
);
|
||||
}
|
||||
|
||||
// Step 10: Backup first?
|
||||
$doBackup = confirm(
|
||||
label: 'Should we run a backup before proceeding?',
|
||||
default: true,
|
||||
hint: 'Strongly recommended. Saved as backup-before-bulk-delete-cli-[datetime].zip',
|
||||
);
|
||||
|
||||
// Step 11: Summary + final confirmation
|
||||
$this->line('');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(' SUMMARY OF ACTIONS');
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line(" Admin user: {$admin->first_name} {$admin->last_name} ({$admin->username})");
|
||||
$this->line(' Companies: '.implode(', ', $selectedCompanyNames));
|
||||
$this->line(' Item types: '.implode(', ', $selectedTypes));
|
||||
$this->line(" Delete mode: {$deleteType}");
|
||||
$this->line(' Notifications: '.($sendNotifications ? 'Yes' : 'No'));
|
||||
$this->line(' Clear logs: '.($clearLogs ? 'Yes' : 'No'));
|
||||
$this->line(' Delete files: '.($deleteFiles ? 'Yes' : 'No'));
|
||||
$this->line(' Delete companies: '.($deleteCompanyType === 'keep' ? 'No' : ucfirst($deleteCompanyType).' delete'));
|
||||
$this->line(' Backup first: '.($doBackup ? 'Yes' : 'No'));
|
||||
$this->line(' Dry run: '.($dryRun ? 'Yes' : 'No'));
|
||||
$this->line('');
|
||||
$this->line(' Items to be processed:');
|
||||
foreach ($counts as $type => $count) {
|
||||
$this->line(sprintf(' %-14s %d', ucfirst($type).':', $count));
|
||||
}
|
||||
if ($skipAdminUser) {
|
||||
$this->line(' * Your user account will be skipped during user deletion.');
|
||||
}
|
||||
$this->line(' ════════════════════════════════════════════════════');
|
||||
$this->line('');
|
||||
|
||||
// Step 10.5: Email report?
|
||||
$sendEmailReport = false;
|
||||
if ($admin->email) {
|
||||
$sendEmailReport = confirm(
|
||||
label: "Send an email report to {$admin->email}?",
|
||||
default: false,
|
||||
hint: 'A summary of all '.($dryRun ? 'would-be ' : '').'actions will be emailed to you.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$confirmed = confirm(
|
||||
label: 'Are you sure you want to proceed? This cannot be undone.',
|
||||
default: false,
|
||||
);
|
||||
|
||||
if (! $confirmed) {
|
||||
info('Aborted. No changes were made.');
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Run backup if requested
|
||||
if ($doBackup && ! $dryRun) {
|
||||
$backupFilename = 'backup-before-bulk-delete-cli-'.now()->format('Y-m-d-H-i-s');
|
||||
info("Running backup ({$backupFilename}.zip)...");
|
||||
$result = $this->callSilently('snipeit:backup', ['--filename' => $backupFilename]);
|
||||
if ($result === 0) {
|
||||
info("Backup completed: {$backupFilename}.zip");
|
||||
} else {
|
||||
warning("Backup may have failed (exit code {$result}). Proceeding anyway.");
|
||||
}
|
||||
}
|
||||
|
||||
// Step 11: Execute with progress bar
|
||||
$totalItems = array_sum($counts);
|
||||
$bar = $this->output->createProgressBar($totalItems > 0 ? $totalItems : 1);
|
||||
$bar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %message%');
|
||||
$bar->setMessage('Starting...');
|
||||
$bar->start();
|
||||
|
||||
foreach ($selectedTypes as $type) {
|
||||
match ($type) {
|
||||
'assets' => $this->processAssets($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'licenses' => $this->processLicenses($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'accessories' => $this->processAccessories($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'components' => $this->processComponents($selectedCompanyIds, $includeNullCompany, $sendNotifications, $admin, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'consumables' => $this->processConsumables($selectedCompanyIds, $includeNullCompany, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
'users' => $this->processUsers($selectedCompanyIds, $includeNullCompany, $admin, $skipAdminUser, $dryRun, $deleteType, $clearLogs, $deleteFiles, $bar),
|
||||
};
|
||||
}
|
||||
|
||||
$bar->setMessage('Done.');
|
||||
$bar->finish();
|
||||
$this->line('');
|
||||
$this->line('');
|
||||
|
||||
// Delete companies if requested
|
||||
if ($deleteCompanyType !== 'keep' && ! empty($selectedCompanyIds)) {
|
||||
$companies = Company::whereIn('id', $selectedCompanyIds)->get();
|
||||
foreach ($companies as $company) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would {$deleteCompanyType}-delete company {$company->name}");
|
||||
$this->reportLines[] = "Would {$deleteCompanyType}-delete company {$company->name}";
|
||||
} else {
|
||||
if ($deleteCompanyType === 'soft') {
|
||||
$company->delete();
|
||||
} else {
|
||||
$company->forceDelete();
|
||||
}
|
||||
// Remove any remaining pivot associations (e.g. the admin user who was
|
||||
// skipped during user processing but is still a member of this company)
|
||||
DB::table('company_user')->where('company_id', $company->id)->delete();
|
||||
$this->reportLines[] = ucfirst($deleteCompanyType)."-deleted company {$company->name}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dryRun) {
|
||||
warning('Dry run complete — no changes were made.');
|
||||
} else {
|
||||
info('All actions completed successfully.');
|
||||
}
|
||||
|
||||
if ($sendEmailReport && $admin->email) {
|
||||
Mail::to($admin->email)->send(new BulkDeleteReportMail(
|
||||
admin: $admin,
|
||||
dryRun: $dryRun,
|
||||
companyNames: $selectedCompanyNames,
|
||||
selectedTypes: $selectedTypes,
|
||||
deleteType: $deleteType,
|
||||
reportLines: $this->reportLines,
|
||||
runAt: now(),
|
||||
));
|
||||
info("Report sent to {$admin->email}.");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function getCounts(array $types, array $companyIds, bool $includeNull): array
|
||||
{
|
||||
$counts = [];
|
||||
|
||||
if (in_array('assets', $types)) {
|
||||
$counts['assets'] = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('licenses', $types)) {
|
||||
$counts['licenses'] = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('accessories', $types)) {
|
||||
$counts['accessories'] = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('components', $types)) {
|
||||
$counts['components'] = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('consumables', $types)) {
|
||||
$counts['consumables'] = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->count();
|
||||
}
|
||||
if (in_array('users', $types)) {
|
||||
$counts['users'] = $this->buildUserQuery($companyIds, $includeNull)->count();
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
|
||||
private function buildCompanyQuery(Builder $query, array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return $query->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function buildUserQuery(array $companyIds, bool $includeNull): Builder
|
||||
{
|
||||
return User::query()
|
||||
->where('activated', 1)
|
||||
->where(function (Builder $q) use ($companyIds, $includeNull) {
|
||||
if (! empty($companyIds)) {
|
||||
$q->whereIn('company_id', $companyIds);
|
||||
}
|
||||
if ($includeNull) {
|
||||
$method = ! empty($companyIds) ? 'orWhereNull' : 'whereNull';
|
||||
$q->{$method}('company_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function processAssets(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$assets = $this->buildCompanyQuery(Asset::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($assets as $asset) {
|
||||
$bar->setMessage("Assets: {$asset->asset_tag}");
|
||||
|
||||
if ($asset->assignedTo) {
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in asset {$asset->asset_tag} from {$asset->assignedTo->name}");
|
||||
$this->reportLines[] = "Would check in asset {$asset->asset_tag} (assigned to {$asset->assignedTo->name})";
|
||||
} else {
|
||||
$target = $asset->assignedTo;
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($asset, $target, $admin, self::CHECKIN_NOTE, $checkinAt, $originalValues));
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
} else {
|
||||
DB::table('assets')->where('id', $asset->id)->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
$asset->logCheckin($target, self::CHECKIN_NOTE, $checkinAt, $originalValues);
|
||||
}
|
||||
|
||||
$this->reportLines[] = "Checked in asset {$asset->asset_tag} from {$target->name}";
|
||||
$asset->licenseseats()->update(['assigned_to' => null]);
|
||||
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->whereNull('accepted_at')
|
||||
->whereNull('declined_at')
|
||||
->forceDelete();
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $asset->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Delete checkout acceptance files, then hard-remove all acceptances
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->forceDelete();
|
||||
|
||||
// Hard-delete-only cleanup: maintenance records, accessory checkouts to this
|
||||
// asset, and any other assets that were assigned to this one
|
||||
$maintenanceImages = [];
|
||||
if ($deleteType === 'hard') {
|
||||
if ($deleteFiles) {
|
||||
$maintenanceImages = $asset->maintenances()
|
||||
->whereNotNull('image')
|
||||
->pluck('image')
|
||||
->toArray();
|
||||
}
|
||||
$asset->maintenances()->forceDelete();
|
||||
AccessoryCheckout::where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->delete();
|
||||
DB::table('assets')
|
||||
->where('assigned_to', $asset->id)
|
||||
->where('assigned_type', Asset::class)
|
||||
->update(['assigned_to' => null, 'assigned_type' => null]);
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $asset->delete(),
|
||||
'hard' => $asset->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$asset->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($asset->image) {
|
||||
$this->deleteStorageFile('public', app('assets_upload_path').$asset->image);
|
||||
}
|
||||
foreach ($maintenanceImages as $img) {
|
||||
$this->deleteStorageFile('public', app('maintenances_upload_path').$img);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete asset {$asset->asset_tag}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete asset {$asset->asset_tag}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processLicenses(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$licenses = $this->buildCompanyQuery(License::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
$bar->setMessage("Licenses: {$license->name}");
|
||||
|
||||
$seats = LicenseSeat::where('license_id', $license->id)
|
||||
->where(fn ($q) => $q->whereNotNull('assigned_to')->orWhereNotNull('asset_id'))
|
||||
->get();
|
||||
|
||||
foreach ($seats as $seat) {
|
||||
$target = $seat->assigned_to ? $seat->user : $seat->asset;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$seat->assigned_to = null;
|
||||
$seat->asset_id = null;
|
||||
$seat->save();
|
||||
|
||||
$this->reportLines[] = "Checked in license seat for {$license->name} from ".($target?->name ?? $target?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($seat, $target, $admin, self::CHECKIN_NOTE));
|
||||
} else {
|
||||
$seat->logCheckin($target, self::CHECKIN_NOTE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $license->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($deleteType === 'soft') {
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
$this->reportLines[] = "Soft-deleted license {$license->name}";
|
||||
} elseif ($deleteType === 'hard') {
|
||||
$seatIds = $license->licenseseats()->pluck('id');
|
||||
if ($deleteFiles) {
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->get()
|
||||
->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
}
|
||||
CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->whereIn('checkoutable_id', $seatIds)
|
||||
->forceDelete();
|
||||
$license->licenseseats()->forceDelete();
|
||||
DB::table('kits_licenses')->where('license_id', $license->id)->delete();
|
||||
$license->forceDelete();
|
||||
$this->reportLines[] = "Hard-deleted license {$license->name}";
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$license->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete license {$license->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete license {$license->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processAccessories(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$accessories = $this->buildCompanyQuery(Accessory::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($accessories as $accessory) {
|
||||
$bar->setMessage("Accessories: {$accessory->name}");
|
||||
|
||||
$checkouts = AccessoryCheckout::where('accessory_id', $accessory->id)->get();
|
||||
|
||||
foreach ($checkouts as $checkout) {
|
||||
$target = $checkout->assignedTo;
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
$checkout->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in accessory {$accessory->name} from ".($target?->name ?? 'unknown');
|
||||
|
||||
if ($target) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($accessory, $target, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$accessory->logCheckin($target, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $accessory->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$accessory->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_accessories')->where('accessory_id', $accessory->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $accessory->delete(),
|
||||
'hard' => $accessory->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($accessory->image) {
|
||||
$this->deleteStorageFile('public', app('accessories_upload_path').$accessory->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete accessory {$accessory->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete accessory {$accessory->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processComponents(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $sendNotifications,
|
||||
User $admin,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$components = $this->buildCompanyQuery(Component::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($components as $component) {
|
||||
$bar->setMessage("Components: {$component->name}");
|
||||
|
||||
$assignments = DB::table('components_assets')
|
||||
->where('component_id', $component->id)
|
||||
->get();
|
||||
|
||||
foreach ($assignments as $assignment) {
|
||||
$asset = Asset::find($assignment->asset_id);
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown'));
|
||||
$this->reportLines[] = "Would check in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
} else {
|
||||
$checkinAt = now()->format('Y-m-d H:i:s');
|
||||
DB::table('components_assets')->where('id', $assignment->id)->delete();
|
||||
|
||||
$this->reportLines[] = "Checked in component {$component->name} from asset ".($asset?->asset_tag ?? 'unknown');
|
||||
|
||||
if ($asset) {
|
||||
if ($sendNotifications) {
|
||||
event(new CheckoutableCheckedIn($component, $asset, $admin, self::CHECKIN_NOTE, $checkinAt));
|
||||
} else {
|
||||
$component->logCheckin($asset, self::CHECKIN_NOTE, $checkinAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $component->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$component->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $component->delete(),
|
||||
'hard' => $component->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted component {$component->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($component->image) {
|
||||
$this->deleteStorageFile('public', app('components_upload_path').$component->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete component {$component->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete component {$component->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processConsumables(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$consumables = $this->buildCompanyQuery(Consumable::query(), $companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($consumables as $consumable) {
|
||||
$bar->setMessage("Consumables: {$consumable->name}");
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect action log file paths before logs may be cleared
|
||||
$actionLogPaths = $deleteFiles
|
||||
? $consumable->assetlog()->whereNotNull('filename')->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
if ($clearLogs) {
|
||||
$consumable->assetlog()->forceDelete();
|
||||
}
|
||||
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('kits_consumables')->where('consumable_id', $consumable->id)->delete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $consumable->delete(),
|
||||
'hard' => $consumable->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($consumable->image) {
|
||||
$this->deleteStorageFile('public', app('consumables_upload_path').$consumable->image);
|
||||
}
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete consumable {$consumable->name}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete consumable {$consumable->name}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function processUsers(
|
||||
array $companyIds,
|
||||
bool $includeNull,
|
||||
User $admin,
|
||||
bool $skipAdminUser,
|
||||
bool $dryRun,
|
||||
string $deleteType,
|
||||
bool $clearLogs,
|
||||
bool $deleteFiles,
|
||||
ProgressBar $bar,
|
||||
): void {
|
||||
$users = $this->buildUserQuery($companyIds, $includeNull)->get();
|
||||
|
||||
foreach ($users as $user) {
|
||||
if ($skipAdminUser && $user->id === $admin->id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$bar->setMessage("Users: {$user->username}");
|
||||
|
||||
// If real companies were selected, check whether this user also belongs to
|
||||
// companies outside the selected scope. If so, only remove the selected-company
|
||||
// associations and skip full deletion to avoid orphaning them from their other companies.
|
||||
if (! empty($companyIds)) {
|
||||
$allUserCompanyIds = array_unique(array_filter(array_merge(
|
||||
$user->companies()->pluck('companies.id')->toArray(),
|
||||
$user->company_id ? [$user->company_id] : [],
|
||||
)));
|
||||
$outsideCompanyIds = array_values(array_diff($allUserCompanyIds, $companyIds));
|
||||
|
||||
if (! empty($outsideCompanyIds)) {
|
||||
$outsideNames = Company::whereIn('id', $outsideCompanyIds)->pluck('name')->implode(', ');
|
||||
|
||||
if ($dryRun) {
|
||||
$this->line(" [dry-run] Would partially disassociate user {$user->username} (also belongs to: {$outsideNames})");
|
||||
$this->reportLines[] = "Would partially disassociate user {$user->username} — also belongs to: {$outsideNames}";
|
||||
} else {
|
||||
$user->companies()->detach($companyIds);
|
||||
warning(" Skipped full deletion of {$user->username}: they also belong to {$outsideNames}. Removed selected company associations only.");
|
||||
$this->reportLines[] = "Partially disassociated user {$user->username} — also belongs to: {$outsideNames}. Full deletion skipped.";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
// Collect file paths and acceptance records before deleting pivot data
|
||||
$acceptancesToDelete = $deleteFiles
|
||||
? CheckoutAcceptance::where('assigned_to_id', $user->id)->get()
|
||||
: collect();
|
||||
|
||||
$actionLogPaths = $deleteFiles
|
||||
? Actionlog::where('item_type', User::class)
|
||||
->where('item_id', $user->id)
|
||||
->where('action_type', 'uploaded')
|
||||
->whereNotNull('filename')
|
||||
->get()
|
||||
->map(fn (Actionlog $log) => $log->uploads_file_path())
|
||||
->filter()
|
||||
->values()
|
||||
->toArray()
|
||||
: [];
|
||||
|
||||
// Clear pivot/assignment data that will orphan on deletion
|
||||
LicenseSeat::where('assigned_to', $user->id)->update(['assigned_to' => null]);
|
||||
AccessoryCheckout::where('assigned_to', $user->id)
|
||||
->where('assigned_type', User::class)
|
||||
->delete();
|
||||
DB::table('consumables_users')->where('assigned_to', $user->id)->delete();
|
||||
CheckoutAcceptance::where('assigned_to_id', $user->id)->forceDelete();
|
||||
if ($deleteType === 'hard') {
|
||||
DB::table('company_user')->where('user_id', $user->id)->delete();
|
||||
}
|
||||
|
||||
if ($clearLogs) {
|
||||
$user->userlog()->forceDelete();
|
||||
}
|
||||
|
||||
match ($deleteType) {
|
||||
'soft' => $user->delete(),
|
||||
'hard' => $user->forceDelete(),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($deleteType !== 'none') {
|
||||
$this->reportLines[] = ucfirst($deleteType)."-deleted user {$user->username}";
|
||||
}
|
||||
|
||||
if ($deleteFiles) {
|
||||
if ($user->avatar) {
|
||||
$this->deleteStorageFile('public', app('users_upload_path').$user->avatar);
|
||||
}
|
||||
$acceptancesToDelete->each(fn (CheckoutAcceptance $ca) => $this->deleteAcceptanceFiles($ca));
|
||||
foreach ($actionLogPaths as $path) {
|
||||
$this->deleteStorageFile('local', $path);
|
||||
}
|
||||
}
|
||||
} elseif ($deleteType !== 'none') {
|
||||
$this->line(" [dry-run] Would {$deleteType}-delete user {$user->username}");
|
||||
$this->reportLines[] = "Would {$deleteType}-delete user {$user->username}";
|
||||
}
|
||||
|
||||
$bar->advance();
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteStorageFile(string $disk, ?string $path): void
|
||||
{
|
||||
if (! $path) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$storage = $disk === 'public'
|
||||
? Storage::disk('public')
|
||||
: Storage::disk(config('filesystems.default'));
|
||||
if ($storage->exists($path)) {
|
||||
$storage->delete($path);
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::warning("Could not delete file {$path}: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private function deleteAcceptanceFiles(CheckoutAcceptance $acceptance): void
|
||||
{
|
||||
if ($acceptance->signature_filename) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/signatures/'.$acceptance->signature_filename);
|
||||
}
|
||||
if ($acceptance->stored_eula_file) {
|
||||
$this->deleteStorageFile('local', 'private_uploads/eula-pdfs/'.$acceptance->stored_eula_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ use Illuminate\Validation\ValidationException;
|
||||
use Intervention\Image\Exception\NotSupportedException;
|
||||
use JsonException;
|
||||
use League\OAuth2\Server\Exception\OAuthServerException;
|
||||
use Livewire\Exceptions\ComponentNotFoundException;
|
||||
use Livewire\Exceptions\PublicPropertyNotFoundException;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
use Throwable;
|
||||
@@ -43,6 +44,7 @@ class Handler extends ExceptionHandler
|
||||
SCIMException::class, // these generally don't need to be reported
|
||||
InvalidFormatException::class,
|
||||
PublicPropertyNotFoundException::class,
|
||||
ComponentNotFoundException::class,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -78,6 +80,12 @@ class Handler extends ExceptionHandler
|
||||
return response()->json(['message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
// A request named a Livewire component that doesn't exist in this app (e.g. bots probing
|
||||
// for Filament endpoints). Return 404 so it doesn't surface as a 500.
|
||||
if ($e instanceof ComponentNotFoundException) {
|
||||
return response()->json(['message' => 'Component not found.'], 404);
|
||||
}
|
||||
|
||||
// CSRF token mismatch error
|
||||
if ($e instanceof TokenMismatchException) {
|
||||
return redirect()->back()->with('error', trans('general.token_expired'));
|
||||
|
||||
+87
-9
@@ -14,6 +14,7 @@ use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -663,7 +664,7 @@ class Helper
|
||||
*/
|
||||
public static function depreciationList()
|
||||
{
|
||||
$depreciation_list = ['' => 'Do Not Depreciate'] + Depreciation::orderBy('name', 'asc')
|
||||
$depreciation_list = ['' => trans('admin/licenses/form.no_depreciation')] + Depreciation::orderBy('name', 'asc')
|
||||
->pluck('name', 'id')->toArray();
|
||||
|
||||
return $depreciation_list;
|
||||
@@ -1268,6 +1269,7 @@ class Helper
|
||||
$allowedExtensionMap = [
|
||||
// Images
|
||||
'jpg' => 'far fa-image',
|
||||
'jfif' => 'far fa-image',
|
||||
'jpeg' => 'far fa-image',
|
||||
'gif' => 'far fa-image',
|
||||
'png' => 'far fa-image',
|
||||
@@ -1596,7 +1598,17 @@ class Helper
|
||||
$checkout_to_type = session('checkout_to_type') ?? null;
|
||||
$checkedInFrom = session('checkedInFrom');
|
||||
$other_redirect = session('other_redirect');
|
||||
$backUrl = session()->pull('url.intended', 'home');
|
||||
$backUrl = str_replace(["\r", "\n"], '', session()->pull('url.intended', 'home'));
|
||||
|
||||
// Reject any stored back-URL that points off-site. redirect()->intended() performs
|
||||
// no host validation, and url.intended can be written from the SAML RelayState POST
|
||||
// parameter (SamlController), which an attacker-controlled IdP could set to an
|
||||
// off-site URL.
|
||||
$backHost = parse_url($backUrl, PHP_URL_HOST);
|
||||
$appHost = parse_url(config('app.url'), PHP_URL_HOST);
|
||||
if ($backHost && $backHost !== $appHost) {
|
||||
$backUrl = route('home');
|
||||
}
|
||||
|
||||
// return to previous page
|
||||
if ($redirect_option == 'back') {
|
||||
@@ -1689,6 +1701,8 @@ class Helper
|
||||
return [];
|
||||
}
|
||||
|
||||
$floater = (bool) Setting::getSettings()->null_company_is_floater;
|
||||
|
||||
foreach ($locations as $location) {
|
||||
// in case of an update of a single location, use the newly requested company_id
|
||||
if ($new_company_id) {
|
||||
@@ -1723,26 +1737,51 @@ class Helper
|
||||
foreach ($keywords as $keyword) {
|
||||
if ($relation == 'many') {
|
||||
$items = $location->{$keyword}->all();
|
||||
// assignedAccessories returns AccessoryCheckout records (no company_id);
|
||||
// resolve each to its parent Accessory so the comparison is valid.
|
||||
if ($keyword === 'assignedAccessories') {
|
||||
$items = collect($items)->map(fn ($checkout) => $checkout->accessory)->filter()->values()->all();
|
||||
}
|
||||
} else {
|
||||
$items = collect([])->push($location->$keyword);
|
||||
}
|
||||
|
||||
$count = 0;
|
||||
foreach ($items as $item) {
|
||||
if (! $item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($item && $item->company_id != $location_company) {
|
||||
// Users belong to companies via the many-to-many pivot (company_user).
|
||||
// canReceiveFromCompany() returns true only when the user's pivot
|
||||
// contains the location's company, so !canReceiveFromCompany() is
|
||||
// the correct mismatch signal.
|
||||
if ($item instanceof User) {
|
||||
$isMismatch = ! $item->canReceiveFromCompany((int) $location_company);
|
||||
} elseif ($item->company_id == $location_company) {
|
||||
$isMismatch = false;
|
||||
} elseif (is_null($item->company_id) || is_null($location_company)) {
|
||||
$isMismatch = ! $floater;
|
||||
} else {
|
||||
$isMismatch = true;
|
||||
}
|
||||
|
||||
if ($isMismatch) {
|
||||
if ($item instanceof User) {
|
||||
$itemCompanyIds = $item->companies->pluck('id')->implode(', ');
|
||||
$itemCompanyNames = $item->companies->pluck('name')->implode(', ');
|
||||
} else {
|
||||
$itemCompanyIds = $item->company_id ?? null;
|
||||
$itemCompanyNames = $item->company->name ?? null;
|
||||
}
|
||||
|
||||
$mismatched[] = [
|
||||
class_basename(get_class($item)),
|
||||
$item->id,
|
||||
$item->name ?? $item->asset_tag ?? $item->serial ?? $item->username,
|
||||
$item->assigned_type ? str_replace('App\\Models\\', '', $item->assigned_type) : null,
|
||||
$item->company_id ?? null,
|
||||
$item->company->name ?? null,
|
||||
// $item->defaultLoc->id ?? null,
|
||||
// $item->defaultLoc->name ?? null,
|
||||
// $item->defaultLoc->company->id ?? null,
|
||||
// $item->defaultLoc->company->name ?? null,
|
||||
$itemCompanyIds,
|
||||
$itemCompanyNames,
|
||||
$item->location->name ?? null,
|
||||
$item->location->company->name ?? null,
|
||||
$location_company ?? null,
|
||||
@@ -1856,4 +1895,43 @@ class Helper
|
||||
return 'App\\Models\\'.ucwords($model);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a markdown-textarea value as HTML.
|
||||
*
|
||||
* Soft line breaks (single newlines) are rendered as <br> so that line
|
||||
* breaks typed in the textarea are preserved in the output.
|
||||
*
|
||||
* When $inline is true, block-level elements are suppressed and hard
|
||||
* breaks are pre-processed manually — used for the encrypted reveal span
|
||||
* where block HTML cannot be placed inside a font-size-toggled <span>.
|
||||
*/
|
||||
public static function renderMarkdown(?string $text, bool $inline = false): string
|
||||
{
|
||||
if (empty($text)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if ($inline) {
|
||||
// Convert newlines to CommonMark hard breaks for inline rendering
|
||||
$text = preg_replace('/(?<! {2})\n/', " \n", $text);
|
||||
|
||||
return Str::inlineMarkdown($text, ['html_input' => 'escape', 'allow_unsafe_links' => false]);
|
||||
}
|
||||
|
||||
$html = trim(Str::markdown($text, [
|
||||
'html_input' => 'escape',
|
||||
'allow_unsafe_links' => false,
|
||||
'renderer' => ['soft_break' => "<br>\n"],
|
||||
]));
|
||||
|
||||
// If the entire output is a single <p> block, unwrap it so the content
|
||||
// renders inline-ish without the <p> adding unwanted top spacing in the
|
||||
// compact detail-view layout.
|
||||
if (str_starts_with($html, '<p>') && str_ends_with($html, '</p>') && substr_count($html, '<p>') === 1) {
|
||||
return substr($html, 3, -4);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,20 @@ class AccessoryCheckoutController extends Controller
|
||||
$target = $this->determineCheckoutTarget();
|
||||
session()->put(['checkout_to_type' => $target]);
|
||||
|
||||
if (! $accessory->canCheckoutTo($target)) {
|
||||
$targetType = match (class_basename($target)) {
|
||||
'User' => trans('general.user'),
|
||||
'Location' => trans('general.location'),
|
||||
default => trans('general.asset'),
|
||||
};
|
||||
|
||||
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.accessory').' "'.$accessory->name.'"',
|
||||
'item_company' => $accessory->company?->name ?? trans('general.unassigned'),
|
||||
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||
]));
|
||||
}
|
||||
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
@@ -107,7 +107,7 @@ class AccessoriesController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $accessories->count()) ? $accessories->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
@@ -133,7 +133,8 @@ class AssetModelsController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $assetmodels->count()) ? $assetmodels->count() : abs($request->input('offset'));
|
||||
$total = $assetmodels->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
@@ -157,7 +158,6 @@ class AssetModelsController extends Controller
|
||||
break;
|
||||
}
|
||||
|
||||
$total = $assetmodels->count();
|
||||
$assetmodels = $assetmodels->skip($offset)->take($limit)->get();
|
||||
|
||||
return (new AssetModelsTransformer)->transformAssetModels($assetmodels, $total);
|
||||
|
||||
@@ -371,6 +371,12 @@ class AssetsController extends Controller
|
||||
$assets->where('assets.order_number', '=', strval($request->input('order_number')));
|
||||
}
|
||||
|
||||
foreach ($all_custom_fields as $field) {
|
||||
if ($request->filled($field->db_column_name())) {
|
||||
$assets->where($field->db_column_name(), '=', $request->input($field->db_column_name()));
|
||||
}
|
||||
}
|
||||
|
||||
// This is kinda gross, but we need to do this because the Bootstrap Tables
|
||||
// API passes custom field ordering as custom_fields.fieldname, and we have to strip
|
||||
// that out to let the default sorter below order them correctly on the assets table.
|
||||
@@ -603,6 +609,11 @@ class AssetsController extends Controller
|
||||
])->with('model', 'status', 'assignedTo')
|
||||
->NotArchived();
|
||||
|
||||
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
|
||||
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
|
||||
$assets = Company::scopeCompanyables($assets);
|
||||
|
||||
// Allow further narrowing to a specific company passed via data-company-id on the select.
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
||||
if (! empty($companyIds)) {
|
||||
@@ -610,6 +621,10 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$assets->where('assets.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
|
||||
$assets = $assets->RTD();
|
||||
}
|
||||
@@ -898,11 +913,7 @@ class AssetsController extends Controller
|
||||
|
||||
private function checkoutCompanyMismatchResponse(Asset $asset, User|Asset|Location $target): ?JsonResponse
|
||||
{
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1')
|
||||
&& (! is_null($asset->company_id))
|
||||
&& (! is_null($target->company_id))
|
||||
&& ((int) $asset->company_id !== (int) $target->company_id)
|
||||
) {
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
@@ -1056,13 +1067,8 @@ class AssetsController extends Controller
|
||||
}
|
||||
|
||||
// In FMCS mode, enforce explicit same-company target checks before mutating checkout state.
|
||||
$targetCompanyId = data_get($target, 'company_id');
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1')
|
||||
&& (! is_null($asset->company_id))
|
||||
&& (! is_null($targetCompanyId))
|
||||
&& ((int) $asset->company_id !== (int) $targetCompanyId)
|
||||
) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, trans('general.error_user_company')));
|
||||
if ($mismatch = $this->checkoutCompanyMismatchResponse($asset, $target)) {
|
||||
return $mismatch;
|
||||
}
|
||||
|
||||
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
|
||||
@@ -1120,7 +1126,9 @@ class AssetsController extends Controller
|
||||
$asset->assignedTo()->disassociate($asset);
|
||||
$asset->accepted = null;
|
||||
|
||||
if ($request->has('name')) {
|
||||
if ($request->input('clear_name') == '1') {
|
||||
$asset->name = null;
|
||||
} elseif ($request->has('name')) {
|
||||
$asset->name = $request->input('name');
|
||||
}
|
||||
|
||||
@@ -1263,6 +1271,10 @@ class AssetsController extends Controller
|
||||
|
||||
$asset->last_audit_date = date('Y-m-d H:i:s');
|
||||
|
||||
if ($request->input('clear_name') == '1') {
|
||||
$asset->name = null;
|
||||
}
|
||||
|
||||
// Set up the payload for re-display in the API response
|
||||
$payload = [
|
||||
'id' => $asset->id,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\CompaniesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -206,6 +207,16 @@ class CompaniesController extends Controller
|
||||
'companies.tag_color',
|
||||
]);
|
||||
|
||||
// When FMCS is enabled and the user is not a superuser, restrict the list to
|
||||
// companies they belong to (primary company_id + pivot companies). This lets
|
||||
// non-superusers select a company from their own set when creating assets, etc.
|
||||
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! auth()->user()->isSuperUser()) {
|
||||
$userCompanyIds = auth()->user()->allCompanies()->pluck('id');
|
||||
if ($userCompanyIds->isNotEmpty()) {
|
||||
$companies->whereIn('companies.id', $userCompanyIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$companies = $companies->where('companies.name', 'LIKE', '%'.$request->input('search').'%');
|
||||
}
|
||||
|
||||
@@ -67,7 +67,18 @@ class LocationsController extends Controller
|
||||
'notes',
|
||||
];
|
||||
|
||||
$locations = Location::with('parent', 'manager', 'children')->select([
|
||||
$locations = Location::with([
|
||||
'parent',
|
||||
'children',
|
||||
'manager' => fn ($q) => $q->withCount([
|
||||
'assets as assets_count',
|
||||
'accessories as accessories_count',
|
||||
'licenses as licenses_count',
|
||||
'consumables as consumables_count',
|
||||
'managesUsers as manages_users_count',
|
||||
'managedLocations as manages_locations_count',
|
||||
]),
|
||||
])->select([
|
||||
'locations.id',
|
||||
'locations.name',
|
||||
'locations.address',
|
||||
@@ -103,7 +114,9 @@ class LocationsController extends Controller
|
||||
->withCount('components as components_count')
|
||||
->with('adminuser');
|
||||
|
||||
// Only scope locations if the setting is enabled
|
||||
// scope_locations_fmcs is required for location-level company scoping (locations may not
|
||||
// have company_id assigned unless the compatibility check has been completed in Settings).
|
||||
// Without it, locations are visible to all authenticated users regardless of FMCS state.
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$locations = Company::scopeCompanyables($locations);
|
||||
}
|
||||
@@ -157,8 +170,6 @@ class LocationsController extends Controller
|
||||
$locations->where('tag_color', '=', $request->input('locations.tag_color'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $locations->count()) ? $locations->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
@@ -180,6 +191,7 @@ class LocationsController extends Controller
|
||||
}
|
||||
|
||||
$total = $locations->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$locations = $locations->skip($offset)->take($limit)->get();
|
||||
|
||||
return (new LocationsTransformer)->transformLocations($locations, $total);
|
||||
@@ -199,12 +211,19 @@ class LocationsController extends Controller
|
||||
$location->fill($request->all());
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
// Only scope location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if parent is set and has a different company
|
||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
||||
response()->json(Helper::formatStandardApiResponse('error', null, 'different company than parent'));
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +246,19 @@ class LocationsController extends Controller
|
||||
public function show($id): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Location::class);
|
||||
$location = Location::with('parent', 'manager', 'children', 'company')
|
||||
$location = Location::with([
|
||||
'parent',
|
||||
'children',
|
||||
'company',
|
||||
'manager' => fn ($q) => $q->withCount([
|
||||
'assets as assets_count',
|
||||
'accessories as accessories_count',
|
||||
'licenses as licenses_count',
|
||||
'consumables as consumables_count',
|
||||
'managesUsers as manages_users_count',
|
||||
'managedLocations as manages_locations_count',
|
||||
]),
|
||||
])
|
||||
->select([
|
||||
'locations.id',
|
||||
'locations.name',
|
||||
@@ -279,18 +310,36 @@ class LocationsController extends Controller
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
// Only scope location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if there are related objects with different company
|
||||
if (Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'error scoped locations'));
|
||||
if ($mismatched = Helper::test_locations_fmcs(false, $id, $location->company_id)) {
|
||||
$first = $mismatched[0];
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_scoped_items', [
|
||||
'item_type' => trans('general.'.strtolower($first[0])),
|
||||
'item_name' => $first[2],
|
||||
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
// Runs outside the company_id gate so a parent_id-only update is also validated.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
])));
|
||||
}
|
||||
}
|
||||
|
||||
if ($location->isValid()) {
|
||||
|
||||
$location->save();
|
||||
@@ -422,15 +471,6 @@ class LocationsController extends Controller
|
||||
'locations.tag_color',
|
||||
]);
|
||||
|
||||
// Only scope locations if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$locations = Company::scopeCompanyables($locations);
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$locations->where('locations.company_id', $request->input('companyId'));
|
||||
}
|
||||
|
||||
$page = 1;
|
||||
if ($request->filled('page')) {
|
||||
$page = $request->input('page');
|
||||
@@ -440,6 +480,10 @@ class LocationsController extends Controller
|
||||
$locations = $locations->where('locations.name', 'LIKE', '%'.$request->input('search').'%');
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$locations->where('locations.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
$locations = $locations->orderBy('name', 'ASC')->get();
|
||||
|
||||
$locations_with_children = [];
|
||||
|
||||
@@ -30,7 +30,7 @@ class MaintenanceTypesController extends Controller
|
||||
$types->where('name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
$offset = ($request->input('offset') > $types->count()) ? $types->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $types->count()) ? $types->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
$sort = in_array($request->input('sort'), ['id', 'name', 'created_at', 'updated_at']) ? $request->input('sort') : 'name';
|
||||
|
||||
@@ -102,7 +102,7 @@ class MaintenancesController extends Controller
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $maintenances->count()) ? $maintenances->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$allowed_columns = [
|
||||
@@ -269,17 +269,33 @@ class MaintenancesController extends Controller
|
||||
|
||||
if ($maintenance = Maintenance::with('asset')->find($id)) {
|
||||
|
||||
// Can this user manage this asset?
|
||||
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
|
||||
}
|
||||
|
||||
// The asset this miantenance is attached to is not valid or has been deleted
|
||||
// The asset this maintenance is attached to is not valid or has been deleted
|
||||
if (! $maintenance->asset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $id])));
|
||||
}
|
||||
|
||||
$maintenance->fill($request->all());
|
||||
// Can this user manage the existing asset?
|
||||
if (! Company::isCurrentUserHasAccess($maintenance->asset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('admin/maintenances/general.maintenance'), 'id' => $id, 'action' => trans('general.edit')])));
|
||||
}
|
||||
|
||||
// If the request changes asset_id, verify the new asset is accessible
|
||||
if ($request->filled('asset_id') && (int) $request->input('asset_id') !== $maintenance->asset_id) {
|
||||
$newAsset = Asset::find($request->input('asset_id'));
|
||||
|
||||
if (! $newAsset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.item_not_found', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id')])));
|
||||
}
|
||||
|
||||
if (! Company::isCurrentUserHasAccess($newAsset)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.action_permission_denied', ['item_type' => trans('general.asset'), 'id' => $request->input('asset_id'), 'action' => trans('general.edit')])), 403);
|
||||
}
|
||||
|
||||
$maintenance->fill($request->except('asset_id'));
|
||||
$maintenance->asset_id = $newAsset->id;
|
||||
} else {
|
||||
$maintenance->fill($request->except('asset_id'));
|
||||
}
|
||||
|
||||
if ($maintenance->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $maintenance, trans('admin/maintenances/message.edit.success')));
|
||||
|
||||
@@ -6,6 +6,9 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Transformers\PredefinedKitsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\PredefinedKit;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -183,6 +186,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$license_id = $request->input('license');
|
||||
$license = License::findOrFail($license_id);
|
||||
$this->authorize('view', $license);
|
||||
|
||||
$relation = $kit->licenses();
|
||||
if ($relation->find($license_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['license' => trans('admin/kits/general.license_error')]));
|
||||
@@ -329,6 +335,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$consumable_id = $request->input('consumable');
|
||||
$consumable = Consumable::findOrFail($consumable_id);
|
||||
$this->authorize('view', $consumable);
|
||||
|
||||
$relation = $kit->consumables();
|
||||
if ($relation->find($consumable_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['consumable' => trans('admin/kits/general.consumable_error')]));
|
||||
@@ -402,6 +411,9 @@ class PredefinedKitsController extends Controller
|
||||
}
|
||||
|
||||
$accessory_id = $request->input('accessory');
|
||||
$accessory = Accessory::findOrFail($accessory_id);
|
||||
$this->authorize('view', $accessory);
|
||||
|
||||
$relation = $kit->accessories();
|
||||
if ($relation->find($accessory_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, ['accessory' => trans('admin/kits/general.accessory_error')]));
|
||||
|
||||
@@ -52,7 +52,7 @@ class UploadedFilesController extends Controller
|
||||
$uploads = self::$map_object_type[$object_type]::withTrashed()->find($id)->uploads()
|
||||
->with('adminuser');
|
||||
|
||||
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : abs($request->input('offset'));
|
||||
$offset = ($request->input('offset') > $uploads->count()) ? $uploads->count() : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
|
||||
$sort = in_array($request->input('sort'), $allowed_columns) ? $request->input('sort') : 'created_at';
|
||||
|
||||
@@ -396,13 +396,22 @@ class UsersController extends Controller
|
||||
]
|
||||
)->where('show_in_list', '=', '1');
|
||||
|
||||
// When FMCS is enabled, automatically scope to companies the acting user belongs to.
|
||||
// scopeCompanyables is a no-op for superusers and when FMCS is disabled.
|
||||
$users = Company::scopeCompanyables($users, 'company_id', 'users');
|
||||
|
||||
// Allow further narrowing to a specific company passed via data-company-ids on the select.
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && $request->filled('companyId')) {
|
||||
$companyIds = array_values(array_filter(array_map('intval', explode(',', $request->input('companyId')))));
|
||||
if (! empty($companyIds)) {
|
||||
$users->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
|
||||
$users = Company::scopeUsersByCompanyIds($users, $companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->filled('excludeId')) {
|
||||
$users->where('users.id', '!=', (int) $request->input('excludeId'));
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$users = $users->where(function ($query) use ($request) {
|
||||
$query->SimpleNameSearch($request->input('search'))
|
||||
@@ -617,12 +626,16 @@ class UsersController extends Controller
|
||||
$user->groups()->sync($request->input('groups'));
|
||||
}
|
||||
|
||||
// Sync company memberships when company_ids[] or company_id is provided
|
||||
if ($request->has('company_ids') || $request->filled('company_id')) {
|
||||
$companyIds = array_filter(
|
||||
(array) ($request->input('company_ids') ?? ($request->filled('company_id') ? [$request->input('company_id')] : []))
|
||||
);
|
||||
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser(array_map('intval', $companyIds)));
|
||||
// company_ids (new format) = full replacement sync.
|
||||
// Legacy company_id = add without removing other associations.
|
||||
if ($request->has('company_ids')) {
|
||||
$companyIds = array_filter(array_map('intval', (array) $request->input('company_ids')));
|
||||
$user->syncCompaniesWithLogging(Company::getIdsForCurrentUser($companyIds));
|
||||
} elseif ($request->filled('company_id')) {
|
||||
$filtered = Company::getIdsForCurrentUser([(int) $request->input('company_id')]);
|
||||
if (! empty($filtered)) {
|
||||
$user->companies()->syncWithoutDetaching($filtered);
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', (new UsersTransformer)->transformUser($user), trans('admin/users/message.success.update')));
|
||||
|
||||
@@ -84,7 +84,7 @@ class AssetCheckinController extends Controller
|
||||
public function store(AssetCheckinRequest $request, $assetId = null, $backto = null): RedirectResponse
|
||||
{
|
||||
// Check if the asset exists
|
||||
if (is_null($asset = Asset::find($assetId))) {
|
||||
if (is_null($asset = Asset::withTrashed()->find($assetId))) {
|
||||
// Redirect to the asset management page with error
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Http\Traits\CheckInOutTrait;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@@ -119,13 +118,18 @@ class AssetCheckoutController extends Controller
|
||||
// Add any custom fields that should be included in the checkout
|
||||
$asset->customFieldsForCheckinCheckout('display_checkout');
|
||||
|
||||
$settings = Setting::getSettings();
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
$targetType = match (class_basename($target)) {
|
||||
'User' => trans('general.user'),
|
||||
'Location' => trans('general.location'),
|
||||
default => trans('general.asset'),
|
||||
};
|
||||
|
||||
// We have to check whether $target->company_id is null here since locations don't have a company yet
|
||||
if (($settings->full_multiple_companies_support) && ((! is_null($target->company_id)) && (! is_null($asset->company_id)))) {
|
||||
if ($target->company_id != $asset->company_id) {
|
||||
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_user_company'));
|
||||
}
|
||||
return redirect()->route('hardware.checkout.create', $asset)->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
|
||||
'target' => $targetType.' "'.($target->name ?? $target->username ?? $target->id).'"',
|
||||
]));
|
||||
}
|
||||
|
||||
session()->put([
|
||||
|
||||
@@ -358,7 +358,7 @@ class AssetsController extends Controller
|
||||
|
||||
$qr_code = (object) [
|
||||
'display' => $settings->qr_code == '1',
|
||||
'url' => route('qr_code/hardware', $asset),
|
||||
'url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
|
||||
];
|
||||
|
||||
$total_maintenance_cost = $asset->maintenances?->sum('cost');
|
||||
@@ -443,7 +443,7 @@ class AssetsController extends Controller
|
||||
|
||||
if ($request->filled('image_delete')) {
|
||||
try {
|
||||
unlink(public_path().'/uploads/assets/'.$asset->image);
|
||||
unlink(public_path().'/uploads/assets/'.basename($asset->image));
|
||||
$asset->image = '';
|
||||
} catch (\Exception $e) {
|
||||
Log::info($e);
|
||||
@@ -549,7 +549,7 @@ class AssetsController extends Controller
|
||||
|
||||
if ($asset->image) {
|
||||
try {
|
||||
Storage::disk('public')->delete('assets'.'/'.$asset->image);
|
||||
Storage::disk('public')->delete('assets/'.basename($asset->image));
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\CustomField;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\User;
|
||||
use App\View\Label;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -687,18 +688,25 @@ class BulkAssetsController extends Controller
|
||||
->with('error', trans('general.error_assets_already_checked_out'));
|
||||
}
|
||||
|
||||
// Prevent checking out assets across companies if FMCS enabled
|
||||
if (Setting::getSettings()->full_multiple_companies_support && $target->company_id) {
|
||||
$company_ids = $assets->pluck('company_id')->unique();
|
||||
// Prevent checking out assets across companies if FMCS enabled.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$company_ids = $assets->pluck('company_id')->filter()->unique();
|
||||
|
||||
// if there is more than one unique company id or the singular company id does not match
|
||||
// then the checkout is invalid
|
||||
if ($company_ids->count() > 1 || $company_ids->first() != $target->company_id) {
|
||||
// re-add the asset ids so the assets select is re-populated
|
||||
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
||||
if ($company_ids->isNotEmpty()) {
|
||||
if ($company_ids->count() > 1) {
|
||||
// Selected assets span multiple companies; bulk checkout can't satisfy all of them.
|
||||
$mismatch = true;
|
||||
} else {
|
||||
// All assets share the same company; let the model enforce the checkout rules.
|
||||
$mismatch = ! $assets->first()->canCheckoutTo($target);
|
||||
}
|
||||
|
||||
return redirect(route('hardware.bulkcheckout.show'))
|
||||
->with('error', trans('general.error_user_company_multiple'));
|
||||
if ($mismatch) {
|
||||
$request->session()->flashInput(['selected_assets' => $asset_ids]);
|
||||
|
||||
return redirect(route('hardware.bulkcheckout.show'))
|
||||
->with('error', trans('general.error_user_company_multiple'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -783,7 +791,7 @@ class BulkAssetsController extends Controller
|
||||
$notAssigned = collect();
|
||||
|
||||
if (old('selected_assets') && is_array(old('selected_assets'))) {
|
||||
$assets = Asset::findMany(old('selected_assets'));
|
||||
$assets = Asset::withTrashed()->findMany(old('selected_assets'));
|
||||
|
||||
[$assigned, $notAssigned] = $assets->partition(function (Asset $asset) {
|
||||
return $asset->assigned_to;
|
||||
@@ -814,7 +822,7 @@ class BulkAssetsController extends Controller
|
||||
|
||||
$asset_ids = array_filter($request->input('selected_assets'));
|
||||
|
||||
$assets = Asset::findOrFail($asset_ids);
|
||||
$assets = Asset::withTrashed()->findOrFail($asset_ids);
|
||||
|
||||
$checkin_at = date('Y-m-d H:i:s');
|
||||
if ($request->filled('checkin_at') && $request->input('checkin_at') != date('Y-m-d')) {
|
||||
|
||||
@@ -75,6 +75,7 @@ class SamlController extends Controller
|
||||
{
|
||||
$auth = $this->saml->getAuth();
|
||||
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
|
||||
|
||||
return redirect()->away($ssoUrl);
|
||||
}
|
||||
|
||||
@@ -95,7 +96,7 @@ class SamlController extends Controller
|
||||
$saml = $this->saml;
|
||||
$auth = $saml->getAuth();
|
||||
$saml_exception = false;
|
||||
session()->put('url.intended', $request->post('RelayState'));
|
||||
session()->put('url.intended', str_replace(["\r", "\n"], '', $request->post('RelayState')));
|
||||
try {
|
||||
$auth->processResponse();
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -43,7 +43,8 @@ class ComponentCheckinController extends Controller
|
||||
}
|
||||
$this->authorize('checkin', $component);
|
||||
|
||||
return view('components/checkin', compact('component_assets', 'component', 'asset'));
|
||||
return view('components/checkin', compact('component_assets', 'component', 'asset'))
|
||||
->with('snipe_component', $component);
|
||||
}
|
||||
|
||||
return redirect()->route('components.index')->with('error', trans('admin/components/messages.not_found'));
|
||||
|
||||
@@ -7,7 +7,6 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Component;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -46,7 +45,8 @@ class ComponentCheckoutController extends Controller
|
||||
}
|
||||
|
||||
// Return the checkout view
|
||||
return view('components/checkout', compact('component'));
|
||||
return view('components/checkout', compact('component'))
|
||||
->with('snipe_component', $component);
|
||||
}
|
||||
|
||||
// Invalid category
|
||||
@@ -103,8 +103,12 @@ class ComponentCheckoutController extends Controller
|
||||
// Check if the asset exists
|
||||
$asset = Asset::find($request->input('asset_id'));
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support) && $component->company_id !== $asset->company_id) {
|
||||
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_user_company'));
|
||||
if (! $component->canCheckoutTo($asset)) {
|
||||
return redirect()->route('components.checkout.show', $componentId)->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.component').' "'.$component->name.'"',
|
||||
'item_company' => $component->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
}
|
||||
|
||||
$component->checkout_qty = $request->input('assigned_qty');
|
||||
|
||||
@@ -96,6 +96,14 @@ class ConsumableCheckoutController extends Controller
|
||||
return redirect()->route('consumables.checkout.show', $consumable)->with('error', trans('admin/consumables/message.checkout.user_does_not_exist'))->withInput();
|
||||
}
|
||||
|
||||
if (! $consumable->canCheckoutTo($user)) {
|
||||
return redirect()->back()->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.consumable').' "'.$consumable->name.'"',
|
||||
'item_company' => $consumable->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.user').' "'.$user->username.'"',
|
||||
]));
|
||||
}
|
||||
|
||||
// Update the consumable data
|
||||
$consumable->assigned_to = e($request->input('assigned_to'));
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ class CheckoutKitController extends Controller
|
||||
*/
|
||||
public function store(Request $request, $kit_id)
|
||||
{
|
||||
$this->authorize('checkout', Asset::class);
|
||||
|
||||
$user_id = e($request->input('user_id'));
|
||||
if (is_null($user = User::find($user_id))) {
|
||||
return redirect()->back()->with('error', trans('admin/users/message.user_not_found'));
|
||||
|
||||
@@ -36,7 +36,7 @@ class LicenseCheckinController extends Controller
|
||||
{
|
||||
// Check if the asset exists
|
||||
$license = License::find($licenseSeat->license_id);
|
||||
$this->authorize('checkout', $license);
|
||||
$this->authorize('checkin', $license);
|
||||
|
||||
return view('licenses/checkin', compact('licenseSeat'))->with('backto', $backTo);
|
||||
}
|
||||
@@ -70,7 +70,7 @@ class LicenseCheckinController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkin.error'));
|
||||
}
|
||||
|
||||
$this->authorize('checkout', $license);
|
||||
$this->authorize('checkin', $license);
|
||||
|
||||
// Declare the rules for the form validation
|
||||
$rules = [
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -95,6 +96,28 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->route('licenses.index')->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
|
||||
}
|
||||
|
||||
if (Setting::getSettings()->full_multiple_companies_support == '1') {
|
||||
if ($request->filled('asset_id')) {
|
||||
$fmcsTarget = Asset::find($request->input('asset_id'));
|
||||
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$fmcsTarget->display_name.'"',
|
||||
]));
|
||||
}
|
||||
} elseif ($request->filled('assigned_to')) {
|
||||
$fmcsTarget = User::find($request->input('assigned_to'));
|
||||
if ($fmcsTarget && ! $license->canCheckoutTo($fmcsTarget)) {
|
||||
return redirect()->route('licenses.index')->with('error', trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.user').' "'.$fmcsTarget->username.'"',
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$licenseSeat = null;
|
||||
$checkoutTarget = null;
|
||||
|
||||
@@ -240,14 +263,10 @@ class LicenseCheckoutController extends Controller
|
||||
|
||||
Log::debug('Checking out '.$licenseId.' via bulk');
|
||||
$license = License::findOrFail($licenseId);
|
||||
$this->authorize('checkin', $license);
|
||||
$avail_count = $license->getAvailSeatsCountAttribute();
|
||||
$this->authorize('checkout', $license);
|
||||
|
||||
$users = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses')->get();
|
||||
Log::debug($avail_count.' will be assigned');
|
||||
|
||||
if ($users->count() > $avail_count) {
|
||||
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
|
||||
if ($license->isInactive()) {
|
||||
return redirect()->back()->with('error', trans('admin/licenses/message.checkout.license_is_inactive'));
|
||||
}
|
||||
|
||||
// If the license is valid, check that there is an available seat
|
||||
@@ -255,6 +274,19 @@ class LicenseCheckoutController extends Controller
|
||||
return redirect()->back()->with('error', trans('admin/licenses/general.bulk.checkout_all.error_no_seats'));
|
||||
}
|
||||
|
||||
$avail_count = $license->getAvailSeatsCountAttribute();
|
||||
|
||||
$usersQuery = User::whereNull('deleted_at')->where('autoassign_licenses', '=', 1)->with('licenses');
|
||||
if (Setting::getSettings()->full_multiple_companies_support && $license->company_id) {
|
||||
$usersQuery->where('company_id', '=', $license->company_id);
|
||||
}
|
||||
$users = $usersQuery->get();
|
||||
Log::debug($avail_count.' will be assigned');
|
||||
|
||||
if ($users->count() > $avail_count) {
|
||||
Log::debug('You do not have enough free seats to complete this task, so we will check out as many as we can. ');
|
||||
}
|
||||
|
||||
$assigned_count = 0;
|
||||
|
||||
foreach ($users as $user) {
|
||||
|
||||
@@ -89,19 +89,24 @@ class LocationsController extends Controller
|
||||
$location->fax = request('fax');
|
||||
$location->tag_color = $request->input('tag_color');
|
||||
$location->notes = $request->input('notes');
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
// Only scope the location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if parent is set and has a different company
|
||||
if ($location->parent_id && Location::find($location->parent_id)->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->withInput()->with('error', 'different company than parent');
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('use_cloned_image')) {
|
||||
$cloned_model_img = Location::select('image')->find($request->input('clone_image_from_id'));
|
||||
if ($cloned_model_img) {
|
||||
@@ -171,17 +176,34 @@ class LocationsController extends Controller
|
||||
$location->tag_color = $request->input('tag_color');
|
||||
$location->notes = $request->input('notes');
|
||||
|
||||
// Only scope the location if the setting is enabled
|
||||
if (Setting::getSettings()->scope_locations_fmcs) {
|
||||
$location->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
// check if there are related objects with different company
|
||||
if (Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
||||
return redirect()->back()->withInput()->withInput()->with('error', 'error scoped locations');
|
||||
if ($mismatched = Helper::test_locations_fmcs(false, $location->id, $location->company_id)) {
|
||||
$first = $mismatched[0];
|
||||
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_scoped_items', [
|
||||
'item_type' => trans('general.'.strtolower($first[0])),
|
||||
'item_name' => $first[2],
|
||||
'item_company' => $first[5] ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
} else {
|
||||
$location->company_id = $request->input('company_id');
|
||||
}
|
||||
|
||||
// Parent company check applies whenever FMCS is on, independent of scope_locations_fmcs.
|
||||
if (Setting::getSettings()->full_multiple_companies_support) {
|
||||
$parent = $location->parent_id ? Location::find($location->parent_id) : null;
|
||||
if ($parent && $parent->company_id != $location->company_id) {
|
||||
return redirect()->back()->withInput()->with('error', trans('general.error_location_parent_company', [
|
||||
'parent' => $parent->name,
|
||||
'parent_company' => $parent->company?->name ?? trans('general.unassigned'),
|
||||
'location_company' => $location->company?->name ?? trans('general.unassigned'),
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
$location = $request->handleImages($location);
|
||||
|
||||
if ($location->save()) {
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CurrentInventory;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -63,6 +64,12 @@ class ProfileController extends Controller
|
||||
|
||||
$user->enable_sounds = $request->input('enable_sounds', false);
|
||||
$user->enable_confetti = $request->input('enable_confetti', false);
|
||||
$request->validate([
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$user->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$user->link_dark_color = $request->input('link_dark_color', '#296282');
|
||||
$user->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Setting;
|
||||
use Com\Tecnick\Barcode\Barcode;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
class QrCodeController extends Controller
|
||||
{
|
||||
public static $map_show_route = [
|
||||
'accessories' => 'accessories.show',
|
||||
'assets' => 'hardware.show',
|
||||
'companies' => 'companies.show',
|
||||
'components' => 'components.show',
|
||||
'consumables' => 'consumables.show',
|
||||
'hardware' => 'hardware.show',
|
||||
'licenses' => 'licenses.show',
|
||||
'locations' => 'locations.show',
|
||||
'models' => 'models.show',
|
||||
'users' => 'users.show',
|
||||
];
|
||||
|
||||
public function show($object_type, $id): Response|BinaryFileResponse|string|bool
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if ($settings->label2_2d_type === 'none') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! array_key_exists($object_type, self::$map_show_route)) {
|
||||
return $object_type.' is not a valid type.';
|
||||
}
|
||||
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
|
||||
if (! $object) {
|
||||
return 'That item is invalid';
|
||||
}
|
||||
|
||||
$this->authorize('view', $object);
|
||||
|
||||
$size = Helper::barcodeDimensions($settings->label2_2d_type);
|
||||
$qr_file = public_path().'/uploads/barcodes/qr-'.str_slug($object_type).'-'.str_slug($id).'.png';
|
||||
|
||||
if (file_exists($qr_file)) {
|
||||
return response()->file($qr_file, ['Content-type' => 'image/png']);
|
||||
}
|
||||
|
||||
$barcode = new Barcode;
|
||||
$barcode_obj = $barcode->getBarcodeObj(
|
||||
$settings->label2_2d_type,
|
||||
route(self::$map_show_route[$object_type], $id),
|
||||
$size['height'],
|
||||
$size['width'],
|
||||
'black',
|
||||
[-2, -2, -2, -2]
|
||||
);
|
||||
file_put_contents($qr_file, $barcode_obj->getPngData());
|
||||
|
||||
return response($barcode_obj->getPngData())->header('Content-type', 'image/png');
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,6 @@ use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use League\Csv\EscapeFormula;
|
||||
use League\Csv\Reader;
|
||||
use League\Csv\Writer;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -105,36 +103,46 @@ class ReportsController extends Controller
|
||||
* @see ManufacturersController::getDatatable() method that generates the JSON response
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportAccessoryReport(): Response
|
||||
public function exportAccessoryReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$accessories = Accessory::orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/accessories/table.title'),
|
||||
trans('admin/accessories/general.accessory_category'),
|
||||
trans('admin/accessories/general.total'),
|
||||
trans('admin/accessories/general.remaining'),
|
||||
];
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(', ', $header);
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
|
||||
// Row per accessory
|
||||
foreach ($accessories as $accessory) {
|
||||
$row = [];
|
||||
$row[] = e($accessory->accessory_name);
|
||||
$row[] = e($accessory->accessory_category);
|
||||
$row[] = e($accessory->total);
|
||||
$row[] = e($accessory->remaining);
|
||||
$header = [
|
||||
trans('admin/accessories/table.title'),
|
||||
trans('admin/accessories/general.accessory_category'),
|
||||
trans('admin/accessories/general.total'),
|
||||
trans('admin/accessories/general.remaining'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
Accessory::with('category')->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($accessories) use ($handle, $formatter) {
|
||||
foreach ($accessories as $accessory) {
|
||||
$row = [
|
||||
$accessory->name,
|
||||
$accessory->category?->name,
|
||||
$accessory->qty,
|
||||
$accessory->numRemaining(),
|
||||
];
|
||||
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="accessories-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -163,74 +171,80 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportDeprecationReport(): Response
|
||||
public function exportDeprecationReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
// Grab all the assets
|
||||
$assets = Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
|
||||
->orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$csv = Writer::createFromFileObject(new \SplTempFileObject);
|
||||
$csv->setOutputBOM(Reader::BOM_UTF16_BE);
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/hardware/table.title'),
|
||||
trans('admin/hardware/table.serial'),
|
||||
trans('admin/hardware/table.checkoutto'),
|
||||
trans('admin/hardware/table.location'),
|
||||
trans('admin/hardware/table.purchase_date'),
|
||||
trans('admin/hardware/table.purchase_cost'),
|
||||
trans('admin/hardware/table.book_value'),
|
||||
trans('admin/hardware/table.diff'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
// Create the header row
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/hardware/table.title'),
|
||||
trans('admin/hardware/table.serial'),
|
||||
trans('admin/hardware/table.checkoutto'),
|
||||
trans('admin/hardware/table.location'),
|
||||
trans('admin/hardware/table.purchase_date'),
|
||||
trans('admin/hardware/table.purchase_cost'),
|
||||
trans('admin/hardware/table.book_value'),
|
||||
trans('admin/hardware/table.diff'),
|
||||
];
|
||||
Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($assets) use ($handle, $formatter) {
|
||||
foreach ($assets as $asset) {
|
||||
$currency = $asset->location
|
||||
? $asset->location->currency
|
||||
: Setting::getSettings()->default_currency;
|
||||
|
||||
// we insert the CSV header
|
||||
$csv->insertOne($header);
|
||||
if ($target = $asset->assignedTo) {
|
||||
$assignedTo = $target->display_name;
|
||||
} else {
|
||||
$assignedTo = '';
|
||||
}
|
||||
|
||||
// Create a row per asset
|
||||
foreach ($assets as $asset) {
|
||||
$row = [];
|
||||
$row[] = e($asset->asset_tag);
|
||||
$row[] = e($asset->name);
|
||||
$row[] = e($asset->serial);
|
||||
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
|
||||
if ($location->city) {
|
||||
$locationStr = $location->city.', '.$location->state;
|
||||
} elseif ($location->name) {
|
||||
$locationStr = $location->name;
|
||||
} else {
|
||||
$locationStr = '';
|
||||
}
|
||||
} else {
|
||||
$locationStr = '';
|
||||
}
|
||||
|
||||
if ($target = $asset->assignedTo) {
|
||||
$row[] = e($target->display_name);
|
||||
} else {
|
||||
$row[] = ''; // Empty string if unassigned
|
||||
}
|
||||
$row = [
|
||||
$asset->asset_tag,
|
||||
$asset->name,
|
||||
$asset->serial,
|
||||
$assignedTo,
|
||||
$locationStr,
|
||||
Helper::getFormattedDateObject($asset->purchase_date, 'date', false),
|
||||
$currency.Helper::formatCurrencyOutput($asset->purchase_cost),
|
||||
$currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue()),
|
||||
$currency.Helper::formatCurrencyOutput($asset->purchase_cost - $asset->getDepreciatedValue()),
|
||||
];
|
||||
|
||||
if (($asset->assigned_to > 0) && ($location = $asset->location)) {
|
||||
if ($location->city) {
|
||||
$row[] = e($location->city).', '.e($location->state);
|
||||
} elseif ($location->name) {
|
||||
$row[] = e($location->name);
|
||||
} else {
|
||||
$row[] = '';
|
||||
}
|
||||
} else {
|
||||
$row[] = ''; // Empty string if location is not set
|
||||
}
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if ($asset->location) {
|
||||
$currency = e($asset->location->currency);
|
||||
} else {
|
||||
$currency = e(Setting::getSettings()->default_currency);
|
||||
}
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="depreciation-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
$row[] = Helper::getFormattedDateObject($asset->purchase_date, 'date', false);
|
||||
$row[] = $currency.Helper::formatCurrencyOutput($asset->purchase_cost);
|
||||
$row[] = $currency.Helper::formatCurrencyOutput($asset->getDepreciatedValue());
|
||||
$row[] = $currency.Helper::formatCurrencyOutput(($asset->purchase_cost - $asset->getDepreciatedValue()));
|
||||
$csv->insertOne($row);
|
||||
}
|
||||
|
||||
$csv->output('depreciation-report-'.date('Y-m-d').'.csv');
|
||||
exit;
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -395,45 +409,52 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @since [v1.0]
|
||||
*/
|
||||
public function exportLicenseReport(): Response
|
||||
public function exportLicenseReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$licenses = License::orderBy('created_at', 'DESC')->get();
|
||||
|
||||
$rows = [];
|
||||
$header = [
|
||||
trans('admin/licenses/table.title'),
|
||||
trans('admin/licenses/table.serial'),
|
||||
trans('admin/licenses/form.seats'),
|
||||
trans('admin/licenses/form.remaining_seats'),
|
||||
trans('admin/licenses/form.expiration'),
|
||||
trans('general.purchase_date'),
|
||||
trans('general.depreciation'),
|
||||
trans('general.purchase_cost'),
|
||||
];
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(', ', $header);
|
||||
$header = [
|
||||
trans('admin/licenses/table.title'),
|
||||
trans('admin/licenses/table.serial'),
|
||||
trans('admin/licenses/form.seats'),
|
||||
trans('admin/licenses/form.remaining_seats'),
|
||||
trans('admin/licenses/form.expiration'),
|
||||
trans('general.purchase_date'),
|
||||
trans('general.depreciation'),
|
||||
trans('general.purchase_cost'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
// Row per license
|
||||
foreach ($licenses as $license) {
|
||||
$row = [];
|
||||
$row[] = e($license->name);
|
||||
$row[] = e($license->serial);
|
||||
$row[] = e($license->seats);
|
||||
$row[] = $license->remaincount();
|
||||
$row[] = $license->expiration_date;
|
||||
$row[] = $license->purchase_date;
|
||||
$row[] = ($license->depreciation != '') ? e($license->depreciation->name) : '';
|
||||
$row[] = '"'.Helper::formatCurrencyOutput($license->purchase_cost).'"';
|
||||
License::orderBy('created_at', 'DESC')->chunk(500, function ($licenses) use ($handle, $formatter) {
|
||||
foreach ($licenses as $license) {
|
||||
$row = [
|
||||
$license->name,
|
||||
$license->serial,
|
||||
$license->seats,
|
||||
$license->remaincount(),
|
||||
$license->expiration_date,
|
||||
$license->purchase_date,
|
||||
($license->depreciation != '') ? $license->depreciation->name : '',
|
||||
Helper::formatCurrencyOutput($license->purchase_cost),
|
||||
];
|
||||
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="licenses-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -778,12 +799,11 @@ class ReportsController extends Controller
|
||||
$checkout_start = Carbon::parse($request->input('checkout_date_start'))->startOfDay();
|
||||
$checkout_end = Carbon::parse($request->input('checkout_date_end', now()))->endOfDay();
|
||||
|
||||
$actionlogassets = Actionlog::where('action_type', '=', 'checkout')
|
||||
->where('item_type', 'LIKE', '%Asset%')
|
||||
->whereBetween('action_date', [$checkout_start, $checkout_end])
|
||||
->pluck('item_id');
|
||||
$actionlogassets = Actionlog::select('id')->where('action_type', '=', 'checkout')
|
||||
->where('item_type', '=', Asset::class)
|
||||
->whereBetween('action_date', [$checkout_start, $checkout_end]); // we are *not* doing ->get()...
|
||||
|
||||
$assets->whereIn('assets.id', $actionlogassets);
|
||||
$assets->whereIn('id', $actionlogassets); // ...because this _should_ act as a 'subquery'
|
||||
}
|
||||
|
||||
if (($request->filled('checkin_date_start'))) {
|
||||
@@ -1172,56 +1192,60 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @version v1.0
|
||||
*/
|
||||
public function exportMaintenancesReport(): Response
|
||||
public function exportMaintenancesReport(): StreamedResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
// Grab all the improvements
|
||||
$Maintenances = Maintenance::with('asset', 'supplier')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->get();
|
||||
|
||||
$rows = [];
|
||||
$response = new StreamedResponse(function () {
|
||||
$handle = fopen('php://output', 'w');
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/maintenances/table.asset_name'),
|
||||
trans('general.supplier'),
|
||||
trans('admin/maintenances/form.asset_maintenance_type'),
|
||||
trans('admin/maintenances/form.title'),
|
||||
trans('admin/maintenances/form.start_date'),
|
||||
trans('admin/maintenances/form.completion_date'),
|
||||
trans('admin/maintenances/form.asset_maintenance_time'),
|
||||
trans('admin/maintenances/form.cost'),
|
||||
];
|
||||
$header = [
|
||||
trans('admin/hardware/table.asset_tag'),
|
||||
trans('admin/maintenances/table.asset_name'),
|
||||
trans('general.supplier'),
|
||||
trans('admin/maintenances/form.asset_maintenance_type'),
|
||||
trans('admin/maintenances/form.title'),
|
||||
trans('admin/maintenances/form.start_date'),
|
||||
trans('admin/maintenances/form.completion_date'),
|
||||
trans('admin/maintenances/form.asset_maintenance_time'),
|
||||
trans('admin/maintenances/form.cost'),
|
||||
];
|
||||
fputcsv($handle, $header);
|
||||
|
||||
$header = array_map('trim', $header);
|
||||
$rows[] = implode(',', $header);
|
||||
Maintenance::with('asset', 'supplier')
|
||||
->orderBy('created_at', 'DESC')
|
||||
->chunk(500, function ($maintenances) use ($handle, $formatter) {
|
||||
foreach ($maintenances as $maintenance) {
|
||||
$improvementTime = is_null($maintenance->asset_maintenance_time)
|
||||
? (int) Carbon::now()->diffInDays(Carbon::parse($maintenance->start_date), true)
|
||||
: (int) $maintenance->asset_maintenance_time;
|
||||
|
||||
foreach ($Maintenances as $maintenance) {
|
||||
$row = [];
|
||||
$row[] = str_replace(',', '', e($maintenance->asset->asset_tag));
|
||||
$row[] = str_replace(',', '', e($maintenance->asset->name));
|
||||
$row[] = str_replace(',', '', e($maintenance->supplier->name));
|
||||
$row[] = e($maintenance->improvement_type);
|
||||
$row[] = e($maintenance->name);
|
||||
$row[] = e($maintenance->start_date);
|
||||
$row[] = e($maintenance->completion_date);
|
||||
if (is_null($maintenance->asset_maintenance_time)) {
|
||||
$improvementTime = (int) Carbon::now()
|
||||
->diffInDays(Carbon::parse($maintenance->start_date), true);
|
||||
} else {
|
||||
$improvementTime = (int) $maintenance->asset_maintenance_time;
|
||||
}
|
||||
$row[] = $improvementTime;
|
||||
$row[] = trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost);
|
||||
$rows[] = implode(',', $row);
|
||||
}
|
||||
$row = [
|
||||
$maintenance->asset->asset_tag,
|
||||
$maintenance->asset->name,
|
||||
$maintenance->supplier->name,
|
||||
$maintenance->improvement_type,
|
||||
$maintenance->name,
|
||||
$maintenance->start_date,
|
||||
$maintenance->completion_date,
|
||||
$improvementTime,
|
||||
trans('general.currency').Helper::formatCurrencyOutput($maintenance->cost),
|
||||
];
|
||||
|
||||
// spit out a csv
|
||||
$csv = implode("\n", $rows);
|
||||
$response = response()->make($csv, 200);
|
||||
$response->header('Content-Type', 'text/csv');
|
||||
$response->header('Content-disposition', 'attachment;filename=report.csv');
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
fclose($handle);
|
||||
}, 200, [
|
||||
'Content-Type' => 'text/csv',
|
||||
'Content-Disposition' => 'attachment; filename="maintenances-report-'.date('Y-m-d-his').'.csv"',
|
||||
]);
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -1300,6 +1324,11 @@ class ReportsController extends Controller
|
||||
// Redirect to the unaccepted items report page with error
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
|
||||
}
|
||||
|
||||
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$item = $acceptance->checkoutable;
|
||||
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
|
||||
$email = $assignee?->email;
|
||||
@@ -1334,6 +1363,33 @@ class ReportsController extends Controller
|
||||
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
|
||||
}
|
||||
|
||||
private function currentUserCanAccessAcceptance(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
if (Setting::getSettings()->full_multiple_companies_support != '1') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $user->company_id || $user->isSuperUser()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Bypass Eloquent global scopes so cross-company items are still found
|
||||
$checkoutableType = $acceptance->checkoutable_type;
|
||||
$checkoutable = $checkoutableType::withoutGlobalScopes()->find($acceptance->checkoutable_id);
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
$itemCompanyId = License::withoutGlobalScopes()
|
||||
->where('id', $checkoutable->license_id)
|
||||
->value('company_id');
|
||||
} else {
|
||||
$itemCompanyId = $checkoutable?->company_id;
|
||||
}
|
||||
|
||||
return $itemCompanyId === null || (int) $itemCompanyId === (int) $user->company_id;
|
||||
}
|
||||
|
||||
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
|
||||
{
|
||||
$lookup = [
|
||||
@@ -1366,11 +1422,21 @@ class ReportsController extends Controller
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
|
||||
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
|
||||
$acceptance = CheckoutAcceptance::pending()
|
||||
->with(['checkoutable' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([LicenseSeat::class => ['license']]);
|
||||
}])
|
||||
->find($acceptanceId);
|
||||
|
||||
if (! $acceptance) {
|
||||
// Redirect to the unaccepted assets report page with error
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
|
||||
}
|
||||
|
||||
if (! $this->currentUserCanAccessAcceptance($acceptance)) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
if ($acceptance->delete()) {
|
||||
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
|
||||
} else {
|
||||
|
||||
@@ -19,6 +19,7 @@ use App\Models\Group;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\MailTest;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -92,10 +93,12 @@ class SettingsController extends Controller
|
||||
$old_locations_fmcs = $setting->scope_locations_fmcs;
|
||||
$setting->full_multiple_companies_support = $request->input('full_multiple_companies_support', '0');
|
||||
$setting->scope_locations_fmcs = $request->input('scope_locations_fmcs', '0');
|
||||
$setting->null_company_is_floater = $request->input('null_company_is_floater', '0');
|
||||
|
||||
// Backward compatibility for locations makes no sense without FullMultipleCompanySupport
|
||||
// These options make no sense without FullMultipleCompanySupport
|
||||
if (! $setting->full_multiple_companies_support) {
|
||||
$setting->scope_locations_fmcs = '0';
|
||||
$setting->null_company_is_floater = '0';
|
||||
}
|
||||
|
||||
// check for inconsistencies when activating scoped locations
|
||||
@@ -189,6 +192,13 @@ class SettingsController extends Controller
|
||||
$request->validate(['site_name' => 'required']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'header_color' => ['nullable', new CssColor],
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$setting->header_color = $request->input('header_color', '#3c8dbc');
|
||||
$setting->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$setting->link_dark_color = $request->input('link_dark_color', '#5fa4cc');
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Requests\SetupUserRequest;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\FirstAdminNotification;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -166,6 +167,12 @@ class SetupController extends Controller
|
||||
$settings->alerts_enabled = 1;
|
||||
$settings->pwd_secure_min = 10;
|
||||
$settings->brand = 1;
|
||||
$request->validate([
|
||||
'link_light_color' => ['nullable', new CssColor],
|
||||
'link_dark_color' => ['nullable', new CssColor],
|
||||
'nav_link_color' => ['nullable', new CssColor],
|
||||
]);
|
||||
|
||||
$settings->link_light_color = $request->input('link_light_color', '#296282');
|
||||
$settings->link_dark_color = $request->input('link_dark_color', '#296282');
|
||||
$settings->nav_link_color = $request->input('nav_link_color', '#FFFFFF');
|
||||
|
||||
@@ -101,11 +101,13 @@ class UploadedFilesController extends Controller
|
||||
}
|
||||
|
||||
if (request('inline') == 'true') {
|
||||
$headers = [
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
$path = self::$map_storage_path[$object_type];
|
||||
|
||||
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
|
||||
if (! StorageHelper::allowSafeInline($path.$log->filename)) {
|
||||
return StorageHelper::downloader($path.$log->filename);
|
||||
}
|
||||
|
||||
return Storage::download($path.$log->filename, $log->filename, ['Content-Disposition' => 'inline']);
|
||||
}
|
||||
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
|
||||
|
||||
@@ -175,7 +175,15 @@ class BulkUsersController extends Controller
|
||||
->conditionallyAddItem('start_date')
|
||||
->conditionallyAddItem('end_date')
|
||||
->conditionallyAddItem('city')
|
||||
->conditionallyAddItem('autoassign_licenses');
|
||||
->conditionallyAddItem('autoassign_licenses')
|
||||
->conditionallyAddItem('phone')
|
||||
->conditionallyAddItem('jobtitle')
|
||||
->conditionallyAddItem('address')
|
||||
->conditionallyAddItem('state')
|
||||
->conditionallyAddItem('country')
|
||||
->conditionallyAddItem('zip')
|
||||
->conditionallyAddItem('website')
|
||||
->conditionallyAddItem('notes');
|
||||
|
||||
// If the manager_id is one of the users being updated, generate a warning.
|
||||
if (array_search($request->input('manager_id'), $user_raw_array)) {
|
||||
@@ -220,6 +228,46 @@ class BulkUsersController extends Controller
|
||||
$this->update_array['display_name'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_city') == '1') {
|
||||
$this->update_array['city'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_phone') == '1') {
|
||||
$this->update_array['phone'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_jobtitle') == '1') {
|
||||
$this->update_array['jobtitle'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_employee_num') == '1') {
|
||||
$this->update_array['employee_num'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_address') == '1') {
|
||||
$this->update_array['address'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_state') == '1') {
|
||||
$this->update_array['state'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_country') == '1') {
|
||||
$this->update_array['country'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_zip') == '1') {
|
||||
$this->update_array['zip'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_website') == '1') {
|
||||
$this->update_array['website'] = null;
|
||||
}
|
||||
|
||||
if ($request->input('null_notes') == '1') {
|
||||
$this->update_array['notes'] = null;
|
||||
}
|
||||
|
||||
if (! $manager_conflict) {
|
||||
$this->conditionallyAddItem('manager_id');
|
||||
}
|
||||
@@ -245,7 +293,15 @@ class BulkUsersController extends Controller
|
||||
User::whereIn('id', $user_raw_array)->where('id', '!=', auth()->id())
|
||||
->update(['company_id' => $scalarCompanyId]);
|
||||
foreach ($users as $user) {
|
||||
$user->companies()->sync($allowedIds);
|
||||
if ($clearCompanies && ! auth()->user()->isSuperUser() && Company::isFullMultipleCompanySupportEnabled()) {
|
||||
// Non-superusers can only detach companies they belong to; sync([]) would
|
||||
// also wipe memberships for companies outside their scope.
|
||||
$user->companies()->detach(Company::getIdsForCurrentUser(
|
||||
$user->companies()->pluck('companies.id')->toArray()
|
||||
));
|
||||
} else {
|
||||
$user->companies()->sync($allowedIds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +316,11 @@ class BulkUsersController extends Controller
|
||||
if ($request->filled('ldap_import')) {
|
||||
$authFieldUpdate['ldap_import'] = $request->input('ldap_import');
|
||||
}
|
||||
if ($request->filled('email')) {
|
||||
$authFieldUpdate['email'] = $request->input('email');
|
||||
} elseif ($request->input('null_email') == '1') {
|
||||
$authFieldUpdate['email'] = null;
|
||||
}
|
||||
if (! empty($authFieldUpdate)) {
|
||||
$user->update($authFieldUpdate);
|
||||
}
|
||||
@@ -334,6 +395,31 @@ class BulkUsersController extends Controller
|
||||
return redirect()->route('users.index')->with('error', 'No status selected');
|
||||
}
|
||||
|
||||
// Enforce per-item checkin permissions before touching anything (catches FMCS company scoping).
|
||||
foreach ($assets as $asset) {
|
||||
if (auth()->user()->cannot('checkin', $asset)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
$licenseModels = License::whereIn('id', $licenses->pluck('license_id')->unique())->get();
|
||||
foreach ($licenseModels as $license) {
|
||||
if (auth()->user()->cannot('checkin', $license)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
$accessoryModels = Accessory::whereIn('id', $accessoryUserRows->pluck('accessory_id')->unique())->get();
|
||||
foreach ($accessoryModels as $accessory) {
|
||||
if (auth()->user()->cannot('checkin', $accessory)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->input('delete_user') == '1' && $users->isNotEmpty() && auth()->user()->cannot('delete', User::class)) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$this->logItemCheckinAndDelete($assets, Asset::class);
|
||||
$this->logAccessoriesCheckin($accessoryUserRows);
|
||||
$this->logItemCheckinAndDelete($licenses, License::class);
|
||||
@@ -440,6 +526,10 @@ class BulkUsersController extends Controller
|
||||
$users_to_merge = User::whereIn('id', $user_ids_to_merge)->with('assets', 'manager', 'userlog', 'licenses', 'consumables', 'accessories', 'managedLocations', 'uploads', 'acceptances')->get();
|
||||
$admin = User::find(auth()->id());
|
||||
|
||||
if (! auth()->user()->can('canEditAuthFields', $merge_into_user) || ! auth()->user()->can('editableOnDemo')) {
|
||||
return redirect()->route('users.index')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
// Walk users
|
||||
foreach ($users_to_merge as $user_to_merge) {
|
||||
|
||||
|
||||
@@ -10,11 +10,14 @@ use App\Http\Requests\DeleteUserRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\SaveUserRequest;
|
||||
use App\Mail\UnacceptedAssetReminderMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Group;
|
||||
use App\Models\License;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\CurrentInventory;
|
||||
@@ -702,9 +705,17 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('view', User::class);
|
||||
|
||||
$user = User::withInventoryRelations($id)->first();
|
||||
$actor = auth()->user();
|
||||
$canViewLicenses = $actor->can('view', License::class);
|
||||
$canViewAccessories = $actor->can('view', Accessory::class);
|
||||
$canViewConsumables = $actor->can('view', Consumable::class);
|
||||
|
||||
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
|
||||
$user = User::withInventoryRelations($id, $canViewLicenses, $canViewAccessories, $canViewConsumables)->first();
|
||||
|
||||
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count()
|
||||
+ $user?->assets?->flatMap->components->count()
|
||||
+ ($canViewLicenses ? $user?->assets?->flatMap->licenses->count() : 0)
|
||||
+ ($canViewAccessories ? $user?->assets?->flatMap->assignedAccessories->count() : 0);
|
||||
|
||||
if ($user) {
|
||||
$this->authorize('view', $user);
|
||||
|
||||
@@ -222,6 +222,10 @@ class ViewAssetsController extends Controller
|
||||
|
||||
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
|
||||
} else {
|
||||
if ($fullItemType === Asset::class && is_null(Asset::RequestableAssets()->find($item->id))) {
|
||||
return redirect()->back()->with('error', trans('admin/hardware/message.requests.error'));
|
||||
}
|
||||
|
||||
$item->request();
|
||||
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
|
||||
$logaction->logaction('requested');
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Http;
|
||||
|
||||
use App\Http\Middleware\AssetCountForSidebar;
|
||||
use App\Http\Middleware\CheckColorSettings;
|
||||
use App\Http\Middleware\CheckForDebug;
|
||||
use App\Http\Middleware\CheckForSetup;
|
||||
@@ -75,7 +74,6 @@ class Kernel extends HttpKernel
|
||||
CheckUserIsActivated::class,
|
||||
CheckForTwoFactor::class,
|
||||
CreateFreshApiToken::class,
|
||||
AssetCountForSidebar::class,
|
||||
CheckColorSettings::class,
|
||||
AuthenticateSession::class,
|
||||
SubstituteBindings::class,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class AssetCountForSidebar
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Request $request
|
||||
* @return mixed
|
||||
*/
|
||||
public function handle($request, Closure $next)
|
||||
{
|
||||
/**
|
||||
* This needs to be set for the /setup process, since the tables might not exist yet
|
||||
*/
|
||||
$total_assets = 0;
|
||||
$total_due_for_checkin = 0;
|
||||
$total_overdue_for_checkin = 0;
|
||||
$total_due_for_audit = 0;
|
||||
$total_overdue_for_audit = 0;
|
||||
|
||||
try {
|
||||
$settings = Setting::getSettings();
|
||||
view()->share('settings', $settings);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_assets = Asset::AssetsForShow()->count();
|
||||
view()->share('total_assets', $total_assets);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_rtd_sidebar = Asset::RTD()->count();
|
||||
view()->share('total_rtd_sidebar', $total_rtd_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_deployed_sidebar = Asset::Deployed()->count();
|
||||
view()->share('total_deployed_sidebar', $total_deployed_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_archived_sidebar = Asset::Archived()->count();
|
||||
view()->share('total_archived_sidebar', $total_archived_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_pending_sidebar = Asset::Pending()->count();
|
||||
view()->share('total_pending_sidebar', $total_pending_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_undeployable_sidebar = Asset::Undeployable()->count();
|
||||
view()->share('total_undeployable_sidebar', $total_undeployable_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_byod_sidebar = Asset::where('byod', '=', '1')->count();
|
||||
view()->share('total_byod_sidebar', $total_byod_sidebar);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_due_for_audit = Asset::DueForAudit($settings)->count();
|
||||
view()->share('total_due_for_audit', $total_due_for_audit);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_overdue_for_audit = Asset::OverdueForAudit()->count();
|
||||
view()->share('total_overdue_for_audit', $total_overdue_for_audit);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_due_for_checkin = Asset::DueForCheckin($settings)->count();
|
||||
view()->share('total_due_for_checkin', $total_due_for_checkin);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
try {
|
||||
$total_overdue_for_checkin = Asset::OverdueForCheckin()->count();
|
||||
view()->share('total_overdue_for_checkin', $total_overdue_for_checkin);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
|
||||
view()->share('total_due_and_overdue_for_checkin', ($total_due_for_checkin + $total_overdue_for_checkin));
|
||||
view()->share('total_due_and_overdue_for_audit', ($total_due_for_audit + $total_overdue_for_audit));
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,20 @@
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Location;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use App\Rules\ValidJson;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class FilterRequest extends FormRequest
|
||||
{
|
||||
@@ -23,8 +34,37 @@ class FilterRequest extends FormRequest
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$allowedTypes = [
|
||||
'accessory',
|
||||
Accessory::class,
|
||||
'asset',
|
||||
Asset::class,
|
||||
'assetmodel',
|
||||
'assetModel',
|
||||
'AssetModel',
|
||||
AssetModel::class,
|
||||
'component',
|
||||
Component::class,
|
||||
'consumable',
|
||||
Consumable::class,
|
||||
'license',
|
||||
License::class,
|
||||
'licenseseat',
|
||||
'licenseSeat',
|
||||
'LicenseSeat',
|
||||
LicenseSeat::class,
|
||||
'location',
|
||||
Location::class,
|
||||
'maintenance',
|
||||
Maintenance::class,
|
||||
'user',
|
||||
User::class,
|
||||
];
|
||||
|
||||
return [
|
||||
'filter' => ['nullable', new ValidJson],
|
||||
'item_type' => ['nullable', Rule::in($allowedTypes)],
|
||||
'target_type' => ['nullable', Rule::in($allowedTypes)],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class SaveUserRequest extends FormRequest
|
||||
'department_id' => 'nullable|integer|exists:departments,id',
|
||||
'manager_id' => 'nullable|integer|exists:users,id',
|
||||
'company_id' => ['nullable', 'integer', 'exists:companies,id'],
|
||||
'company_ids' => 'nullable|array',
|
||||
'company_ids.*' => 'integer|exists:companies,id',
|
||||
];
|
||||
|
||||
switch ($this->method()) {
|
||||
@@ -52,13 +54,13 @@ class SaveUserRequest extends FormRequest
|
||||
$rules['first_name'] = 'required|string|min:1';
|
||||
$rules['username'] = 'required_unless:ldap_import,1|string|min:1';
|
||||
$rules['password'] = Setting::passwordComplexityRulesSaving('update').'|confirmed';
|
||||
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
break;
|
||||
|
||||
// Save only what's passed
|
||||
case 'PATCH':
|
||||
$rules['password'] = Setting::passwordComplexityRulesSaving('update');
|
||||
$rules['company_id'] = [new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
$rules['company_id'] = ['nullable', 'integer', 'exists:companies,id', new UserCannotSwitchCompaniesIfItemsAssigned];
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -26,6 +26,7 @@ class AccessoriesTransformer
|
||||
'id' => $accessory->id,
|
||||
'name' => e($accessory->name),
|
||||
'image' => ($accessory->image) ? Storage::disk('public')->url('accessories/'.e($accessory->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'accessories', 'id' => $accessory->id]),
|
||||
'company' => ($accessory->company) ? [
|
||||
'id' => $accessory->company->id,
|
||||
'name' => e($accessory->company->name),
|
||||
|
||||
@@ -48,6 +48,7 @@ class AssetModelsTransformer
|
||||
'tag_color' => ($assetmodel->manufacturer->tag_color) ? e($assetmodel->manufacturer->tag_color) : null,
|
||||
] : null,
|
||||
'image' => ($assetmodel->image != '') ? Storage::disk('public')->url('models/'.e($assetmodel->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'models', 'id' => $assetmodel->id]),
|
||||
'model_number' => ($assetmodel->model_number ? e($assetmodel->model_number) : null),
|
||||
'min_amt' => ($assetmodel->min_amt) ? (int) $assetmodel->min_amt : null,
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ class AssetsTransformer
|
||||
'tag_color' => ($asset->defaultLoc->tag_color) ? e($asset->defaultLoc->tag_color) : null,
|
||||
] : null,
|
||||
'image' => ($asset->getImageUrl()) ? $asset->getImageUrl() : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'hardware', 'id' => $asset->id]),
|
||||
'qr' => ($setting->qr_code == '1') ? Storage::disk('public')->url('barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png') : null,
|
||||
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? Storage::disk('public')->url('barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png') : null,
|
||||
'assigned_to' => $this->transformAssignedTo($asset),
|
||||
@@ -144,7 +145,7 @@ class AssetsTransformer
|
||||
|
||||
$fields_array[$field->name] = [
|
||||
'field' => e($field->db_column),
|
||||
'value' => e($value),
|
||||
'value' => ($field->element == 'markdown-textarea' && Gate::allows('assets.view.encrypted_custom_fields')) ? Helper::renderMarkdown($value) : e($value),
|
||||
'field_format' => $field->format,
|
||||
'element' => $field->element,
|
||||
];
|
||||
@@ -158,7 +159,7 @@ class AssetsTransformer
|
||||
|
||||
$fields_array[$field->name] = [
|
||||
'field' => e($field->db_column),
|
||||
'value' => e($value),
|
||||
'value' => ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value),
|
||||
'field_format' => $field->format,
|
||||
'element' => $field->element,
|
||||
];
|
||||
@@ -274,7 +275,7 @@ class AssetsTransformer
|
||||
$value = Helper::getFormattedDateObject($value, 'date', false);
|
||||
}
|
||||
|
||||
$fields_array[$field->db_column] = e($value);
|
||||
$fields_array[$field->db_column] = ($field->element == 'markdown-textarea') ? Helper::renderMarkdown($value) : e($value);
|
||||
}
|
||||
|
||||
$array['custom_fields'] = $fields_array;
|
||||
|
||||
@@ -30,6 +30,7 @@ class CompaniesTransformer
|
||||
'fax' => ($company->fax != '') ? e($company->fax) : null,
|
||||
'email' => ($company->email != '') ? e($company->email) : null,
|
||||
'image' => ($company->image) ? Storage::disk('public')->url('companies/'.e($company->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'companies', 'id' => $company->id]),
|
||||
'assets_count' => (int) $company->assets_count,
|
||||
'licenses_count' => (int) $company->licenses_count,
|
||||
'accessories_count' => (int) $company->accessories_count,
|
||||
|
||||
@@ -26,6 +26,7 @@ class ComponentsTransformer
|
||||
'id' => (int) $component->id,
|
||||
'name' => e($component->name),
|
||||
'image' => ($component->image) ? Storage::disk('public')->url('components/'.e($component->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'components', 'id' => $component->id]),
|
||||
'serial' => ($component->serial) ? e($component->serial) : null,
|
||||
'location' => ($component->location) ? [
|
||||
'id' => (int) $component->location->id,
|
||||
|
||||
@@ -25,6 +25,7 @@ class ConsumablesTransformer
|
||||
'id' => (int) $consumable->id,
|
||||
'name' => e($consumable->name),
|
||||
'image' => ($consumable->getImageUrl()) ? ($consumable->getImageUrl()) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'consumables', 'id' => $consumable->id]),
|
||||
'category' => ($consumable->category) ? [
|
||||
'id' => $consumable->category->id,
|
||||
'name' => e($consumable->category->name),
|
||||
|
||||
@@ -24,6 +24,7 @@ class LicensesTransformer
|
||||
$array = [
|
||||
'id' => (int) $license->id,
|
||||
'name' => e($license->name),
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'licenses', 'id' => $license->id]),
|
||||
'company' => ($license->company) ? ['id' => (int) $license->company->id, 'name' => e($license->company->name)] : null,
|
||||
'manufacturer' => ($license->manufacturer) ? [
|
||||
'id' => (int) $license->manufacturer->id,
|
||||
|
||||
@@ -39,6 +39,7 @@ class LocationsTransformer
|
||||
'id' => (int) $location->id,
|
||||
'name' => e($location->name),
|
||||
'image' => ($location->image) ? Storage::disk('public')->url('locations/'.e($location->image)) : null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'locations', 'id' => $location->id]),
|
||||
'address' => ($location->address) ? e($location->address) : null,
|
||||
'address2' => ($location->address2) ? e($location->address2) : null,
|
||||
'city' => ($location->city) ? e($location->city) : null,
|
||||
|
||||
@@ -21,7 +21,6 @@ class UsersTransformer
|
||||
|
||||
public function transformUser(User $user)
|
||||
{
|
||||
|
||||
$role = null;
|
||||
if ($user->isSuperUser()) {
|
||||
$role = 'superadmin';
|
||||
@@ -31,6 +30,7 @@ class UsersTransformer
|
||||
$array = [
|
||||
'id' => (int) $user->id,
|
||||
'avatar' => e($user->present()->gravatar) ?? null,
|
||||
'qr_code_url' => route('qr_code/common', ['object_type' => 'users', 'id' => $user->id]),
|
||||
'name' => e($user->getFullNameAttribute()) ?? null,
|
||||
'first_name' => e($user->first_name) ?? null,
|
||||
'last_name' => e($user->last_name) ?? null,
|
||||
|
||||
@@ -111,7 +111,7 @@ class AssetImporter extends ItemImporter
|
||||
}
|
||||
|
||||
$this->item['notes'] = trim($this->findCsvMatch($row, 'asset_notes'));
|
||||
$this->item['image'] = trim($this->findCsvMatch($row, 'image'));
|
||||
$this->item['image'] = basename(trim($this->findCsvMatch($row, 'image')));
|
||||
$this->item['requestable'] = trim(($this->fetchHumanBoolean($this->findCsvMatch($row, 'requestable'))) == 1) ? '1' : 0;
|
||||
$asset->requestable = $this->item['requestable'];
|
||||
$this->item['warranty_months'] = intval(trim($this->findCsvMatch($row, 'warranty_months')));
|
||||
@@ -214,16 +214,25 @@ class AssetImporter extends ItemImporter
|
||||
// -- the class that needs to use it (command importer or GUI importer inside the project).
|
||||
if (isset($target) && ($target !== false)) {
|
||||
$asset = $asset->fresh();
|
||||
$targetType = get_class($target);
|
||||
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
|
||||
|
||||
// Skip duplicate checkout noise when update mode keeps the same assignment target.
|
||||
if (! $alreadyCheckedOutToTarget) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
||||
if (! $asset->canCheckoutTo($target)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
'item_company' => $asset->company?->name ?? trans('general.unassigned'),
|
||||
'target' => ($target->name ?? $target->username ?? $target->id),
|
||||
]));
|
||||
} else {
|
||||
$targetType = get_class($target);
|
||||
$alreadyCheckedOutToTarget = ($asset->assigned_to == $target->id) && ($asset->assigned_type === $targetType);
|
||||
|
||||
// Skip duplicate checkout noise when update mode keeps the same assignment target.
|
||||
if (! $alreadyCheckedOutToTarget) {
|
||||
if (! is_null($asset->assigned_to)) {
|
||||
event(new CheckoutableCheckedIn($asset, $asset->assigned, auth()->user(), 'Checkin from CSV Importer', $checkin_date));
|
||||
}
|
||||
|
||||
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
}
|
||||
|
||||
$asset->checkOut($target, $this->created_by, $checkout_date, null, 'Checkout from CSV Importer', $asset->name);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,13 +59,21 @@ class ComponentImporter extends ItemImporter
|
||||
|
||||
// If we have an asset tag, checkout to that asset.
|
||||
if (isset($this->item['asset_tag']) && ($asset = Asset::where('asset_tag', $this->item['asset_tag'])->first())) {
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_by' => auth()->id(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'assigned_qty' => 1, // Only assign the first one to the asset
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
if (! $component->canCheckoutTo($asset)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.component').' "'.$component->name.'"',
|
||||
'item_company' => $component->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
} else {
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_by' => auth()->id(),
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'assigned_qty' => 1, // Only assign the first one to the asset
|
||||
'asset_id' => $asset->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
@@ -82,6 +82,7 @@ class ItemImporter extends Importer
|
||||
$this->item['qty'] = $this->findCsvMatch($row, 'quantity');
|
||||
$this->item['requestable'] = $this->findCsvMatch($row, 'requestable');
|
||||
$this->item['created_by'] = auth()->id();
|
||||
$this->item['asset_tag'] = $this->findCsvMatch($row, 'asset_tag');
|
||||
$this->item['serial'] = $this->findCsvMatch($row, 'serial');
|
||||
$this->item['item_no'] = trim($this->findCsvMatch($row, 'item_no'));
|
||||
|
||||
|
||||
@@ -106,16 +106,32 @@ class LicenseImporter extends ItemImporter
|
||||
}
|
||||
|
||||
if ($checkout_target) {
|
||||
$targetLicense->assigned_to = $checkout_target->id;
|
||||
$targetLicense->created_by = auth()->id();
|
||||
if ($asset) {
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
if (! $license->canCheckoutTo($checkout_target)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => ($checkout_target->name ?? $checkout_target->username ?? $checkout_target->id),
|
||||
]));
|
||||
} else {
|
||||
$targetLicense->assigned_to = $checkout_target->id;
|
||||
$targetLicense->created_by = auth()->id();
|
||||
if ($asset) {
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
}
|
||||
$targetLicense->save();
|
||||
}
|
||||
$targetLicense->save();
|
||||
} elseif ($asset) {
|
||||
$targetLicense->created_by = auth()->id();
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
$targetLicense->save();
|
||||
if (! $license->canCheckoutTo($asset)) {
|
||||
$this->log(trans('general.error_checkout_company_mismatch', [
|
||||
'item' => trans('general.license').' "'.$license->name.'"',
|
||||
'item_company' => $license->company?->name ?? trans('general.unassigned'),
|
||||
'target' => trans('general.asset').' "'.$asset->display_name.'"',
|
||||
]));
|
||||
} else {
|
||||
$targetLicense->created_by = auth()->id();
|
||||
$targetLicense->asset_id = $asset->id;
|
||||
$targetLicense->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ class UserImporter extends ItemImporter
|
||||
// Pull the records from the CSV to determine their values
|
||||
$this->item['id'] = trim($this->findCsvMatch($row, 'id'));
|
||||
$this->item['username'] = trim($this->findCsvMatch($row, 'username'));
|
||||
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name'));
|
||||
$this->item['display_name'] = trim($this->findCsvMatch($row, 'display_name')) ?: null;
|
||||
$this->item['first_name'] = trim($this->findCsvMatch($row, 'first_name'));
|
||||
$this->item['last_name'] = trim($this->findCsvMatch($row, 'last_name'));
|
||||
$this->item['email'] = trim($this->findCsvMatch($row, 'email'));
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Mail\Mailables\Address;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class BulkDeleteReportMail extends BaseMailable
|
||||
{
|
||||
use Queueable, SerializesModels;
|
||||
|
||||
public function __construct(
|
||||
public readonly User $admin,
|
||||
public readonly bool $dryRun,
|
||||
public readonly array $companyNames,
|
||||
public readonly array $selectedTypes,
|
||||
public readonly string $deleteType,
|
||||
public readonly array $reportLines,
|
||||
public readonly Carbon $runAt,
|
||||
) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
$subject = $this->dryRun
|
||||
? '[Dry Run] Bulk Check-in/Delete Report'
|
||||
: 'Bulk Check-in/Delete Report';
|
||||
|
||||
return new Envelope(
|
||||
from: new Address(config('mail.from.address'), config('mail.from.name')),
|
||||
subject: $subject,
|
||||
);
|
||||
}
|
||||
|
||||
public function content(): Content
|
||||
{
|
||||
return new Content(
|
||||
markdown: 'notifications.markdown.report-bulk-delete',
|
||||
with: [
|
||||
'admin' => $this->admin,
|
||||
'dryRun' => $this->dryRun,
|
||||
'companyNames' => $this->companyNames,
|
||||
'selectedTypes' => $this->selectedTypes,
|
||||
'deleteType' => $this->deleteType,
|
||||
'reportLines' => $this->reportLines,
|
||||
'runAt' => $this->runAt,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
public function attachments(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class Accessory extends SnipeModel
|
||||
'name' => 'required|max:255',
|
||||
'qty' => 'nullable|integer|min:0',
|
||||
'category_id' => 'required|integer|exists:categories,id',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'location_id' => 'exists:locations,id|nullable|fmcs_location',
|
||||
'min_amt' => 'integer|min:0|nullable',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
|
||||
+38
-50
@@ -34,7 +34,7 @@ class Asset extends Depreciable
|
||||
{
|
||||
protected $presenter = AssetPresenter::class;
|
||||
|
||||
protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
// protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
|
||||
use CompanyableTrait;
|
||||
use HasFactory;
|
||||
@@ -487,16 +487,18 @@ class Asset extends Depreciable
|
||||
|
||||
public function availableForCheckIn()
|
||||
{
|
||||
|
||||
// This asset is currently assigned to anyone and is not deleted...
|
||||
if (($this->assigned_to != '') && ($this->status) && ($this->status->archived == '0')
|
||||
&& ($this->status->deployable == '1')
|
||||
) {
|
||||
return true;
|
||||
|
||||
if ($this->assigned_to == '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
// Deleted assets that are still checked out should always allow checkin
|
||||
if ($this->deleted_at != '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->status
|
||||
&& ($this->status->archived == '0')
|
||||
&& ($this->status->deployable == '1');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1480,13 +1482,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopePending($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 1)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 1)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1536,14 +1535,11 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeRTD($query)
|
||||
{
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 1)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereNull('assets.assigned_to')
|
||||
->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 1)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1554,13 +1550,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeUndeployable($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1571,11 +1564,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeNotArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('archived', 0)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1738,17 +1730,16 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeAssetsForShow($query)
|
||||
{
|
||||
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
if (Setting::getSettings()->show_archived_in_list != 1) {
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
return $query;
|
||||
$validStatusIds = Statuslabel::where('archived', 0)
|
||||
->whereNull('deleted_at')
|
||||
->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $validStatusIds->isEmpty() ? [0] : $validStatusIds);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1759,13 +1750,10 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function scopeArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('deployable', '=', 0)
|
||||
->where('pending', '=', 0)
|
||||
->where('archived', '=', 1);
|
||||
}
|
||||
);
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where('deployable', 0)->where('pending', 0)->where('archived', 1)->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $query->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+79
-5
@@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Watson\Validating\ValidatingTrait;
|
||||
|
||||
/**
|
||||
@@ -155,11 +156,27 @@ final class Company extends SnipeModel
|
||||
if ($current_user->isSuperUser()) {
|
||||
return self::getIdFromInput($unescaped_input);
|
||||
} else {
|
||||
if ($current_user->company_id != null) {
|
||||
return $current_user->company_id;
|
||||
} else {
|
||||
return null;
|
||||
$userCompanyIds = self::getCurrentUserCompanyIds();
|
||||
$submittedId = (int) self::getIdFromInput($unescaped_input);
|
||||
|
||||
// Company membership is now determined entirely by the pivot (company_user table).
|
||||
// If the submitted value is a company the user actually belongs to, honour it.
|
||||
if ($submittedId && in_array($submittedId, $userCompanyIds)) {
|
||||
return $submittedId;
|
||||
}
|
||||
|
||||
// A user with pivot memberships who submits a company they don't belong to is
|
||||
// attempting cross-tenant assignment — reject outright rather than silently
|
||||
// overriding or storing null.
|
||||
if ($submittedId && ! empty($userCompanyIds)) {
|
||||
throw ValidationException::withMessages([
|
||||
'company_id' => [trans('validation.in', ['attribute' => 'company_id'])],
|
||||
]);
|
||||
}
|
||||
|
||||
// No company submitted (or user has no pivot memberships) — fall back to the
|
||||
// user's single company if unambiguous, otherwise null.
|
||||
return count($userCompanyIds) === 1 ? $userCompanyIds[0] : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,7 +286,7 @@ final class Company extends SnipeModel
|
||||
{
|
||||
return ! self::isFullMultipleCompanySupportEnabled()
|
||||
|| auth()->user()->isSuperUser()
|
||||
|| empty(self::getCurrentUserCompanyIds());
|
||||
|| ! empty(self::getCurrentUserCompanyIds());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -381,10 +398,17 @@ final class Company extends SnipeModel
|
||||
return $query->whereIn('companies.id', $companyIds);
|
||||
}
|
||||
|
||||
$floater = Setting::getSettings()->null_company_is_floater;
|
||||
|
||||
// Users are scoped by pivot membership (company_user), not by company_id column,
|
||||
// since a user may belong to multiple companies and company_id alone is insufficient.
|
||||
if ($query->getModel()->getTable() == 'users') {
|
||||
if (empty($companyIds)) {
|
||||
// Floater: actor has no company and is unrestricted — see everyone.
|
||||
if ($floater) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
// No pivot memberships: mirror old null-company behavior — show only users
|
||||
// who are also not in any company via the pivot.
|
||||
return $query->whereNotIn('users.id', function ($sub) {
|
||||
@@ -392,6 +416,15 @@ final class Company extends SnipeModel
|
||||
});
|
||||
}
|
||||
|
||||
// Floater: also include users with no company associations (they float). They all float down here, Georgie.).
|
||||
if ($floater) {
|
||||
return $query->where(function ($q) use ($companyIds) {
|
||||
$q->whereIn('users.id', function ($sub) use ($companyIds) {
|
||||
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
|
||||
})->orWhereDoesntHave('companies');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereIn('users.id', function ($sub) use ($companyIds) {
|
||||
$sub->select('user_id')->from('company_user')->whereIn('company_id', $companyIds);
|
||||
});
|
||||
@@ -402,13 +435,54 @@ final class Company extends SnipeModel
|
||||
$table = ($table_name) ? $table_name.'.' : $query->getModel()->getTable().'.';
|
||||
|
||||
if (empty($companyIds)) {
|
||||
// Floater: actor has no company and is unrestricted — see everything.
|
||||
if ($floater) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereNull($table.$column);
|
||||
}
|
||||
|
||||
// action_logs: a NULL company_id means the logged object (AssetModel, Company, etc.)
|
||||
// has no company_id column of its own. Those are global objects, visible to all users,
|
||||
// so their log entries should not be hidden by the company filter.
|
||||
if ($query->getModel()->getTable() === 'action_logs') {
|
||||
return $query->where(function ($q) use ($table, $column, $companyIds) {
|
||||
$q->whereIn($table.$column, $companyIds)
|
||||
->orWhereNull($table.$column);
|
||||
});
|
||||
}
|
||||
|
||||
// Floater: null-company items are visible to users from any company.
|
||||
if ($floater) {
|
||||
return $query->where(function ($q) use ($table, $column, $companyIds) {
|
||||
$q->whereIn($table.$column, $companyIds)
|
||||
->orWhereNull($table.$column);
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereIn($table.$column, $companyIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a users query to those belonging to the given company IDs, respecting floater mode.
|
||||
*
|
||||
* Extracted from controller-level inline logic so the same rule is enforced consistently
|
||||
* everywhere users are filtered by a specific set of company IDs (e.g. select2 dropdowns).
|
||||
*/
|
||||
public static function scopeUsersByCompanyIds($query, array $companyIds): mixed
|
||||
{
|
||||
if (Setting::getSettings()->null_company_is_floater) {
|
||||
return $query->where(function ($q) use ($companyIds) {
|
||||
$q->whereHas('companies', fn ($q2) => $q2->whereIn('companies.id', $companyIds))
|
||||
->orWhereDoesntHave('companies');
|
||||
});
|
||||
}
|
||||
|
||||
return $query->whereHas('companies', fn ($q) => $q->whereIn('companies.id', $companyIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* I legit do not know what this method does, but we can't remove it (yet).
|
||||
*
|
||||
|
||||
@@ -48,7 +48,7 @@ class Consumable extends SnipeModel
|
||||
'name' => 'required|max:255',
|
||||
'qty' => 'required|integer|min:0|max:99999',
|
||||
'category_id' => 'required|integer',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'location_id' => 'exists:locations,id|nullable|fmcs_location',
|
||||
'min_amt' => 'integer|min:0|max:99999|nullable',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
|
||||
@@ -51,7 +51,7 @@ class CustomField extends Model
|
||||
*/
|
||||
protected $rules = [
|
||||
'name' => 'required|unique:custom_fields',
|
||||
'element' => 'required|in:text,listbox,textarea,checkbox,radio',
|
||||
'element' => 'required|in:text,listbox,textarea,markdown-textarea,checkbox,radio',
|
||||
'field_encrypted' => 'nullable|boolean',
|
||||
'auto_add_to_fieldsets' => 'boolean',
|
||||
'show_in_listview' => 'boolean',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Models\Labels;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\User;
|
||||
|
||||
class FieldOption
|
||||
{
|
||||
@@ -27,14 +28,19 @@ class FieldOption
|
||||
// assignedTo directly on the asset is a special case where
|
||||
// we want to avoid returning the property directly
|
||||
// and instead return the entity's presented name.
|
||||
if ($dataPath[0] === 'assignedTo') {
|
||||
if ($asset->relationLoaded('assignedTo')) {
|
||||
// If the "assignedTo" relationship was eager loaded then the way to get the
|
||||
// relationship changes from $asset->assignedTo to $asset->assigned.
|
||||
return $asset->assigned ? $asset->assigned->display_name : null;
|
||||
}
|
||||
if (in_array($dataPath[0], ['assignedTo', 'displayName'])) {
|
||||
$assigned = $asset->relationLoaded('assignedTo') ? $asset->assigned : $asset->assignedTo;
|
||||
|
||||
return $asset->assignedTo ? $asset->assignedTo->display_name : null;
|
||||
if (!$assigned) {
|
||||
return null;
|
||||
}
|
||||
if ($dataPath[0] === 'displayName') {
|
||||
return $assigned->getRawOriginal('display_name') ?? $assigned->display_name;
|
||||
}
|
||||
if ($assigned instanceof User) {
|
||||
return $assigned->full_name;
|
||||
}
|
||||
return $assigned->name ?? $assigned->display_name ?? null;
|
||||
}
|
||||
|
||||
// Handle Laravel's stupid Carbon datetime casting
|
||||
|
||||
@@ -59,7 +59,7 @@ class License extends Depreciable
|
||||
'license_name' => 'string|nullable|max:100',
|
||||
'notes' => 'string|nullable',
|
||||
'category_id' => 'required|exists:categories,id',
|
||||
'company_id' => 'integer|nullable',
|
||||
'company_id' => 'integer|nullable|exists:companies,id',
|
||||
'purchase_cost' => 'numeric|nullable|gte:0|max:99999999999999999.99',
|
||||
'purchase_date' => 'date_format:Y-m-d|nullable|max:10|required_with:depreciation_id',
|
||||
'expiration_date' => 'date_format:Y-m-d|nullable|max:10',
|
||||
|
||||
@@ -170,14 +170,13 @@ class Location extends SnipeModel
|
||||
*/
|
||||
public function assets()
|
||||
{
|
||||
// Pluck IDs then whereIn — do NOT replace with whereHas. whereHas generates a correlated EXISTS per row and causes severe slowdowns in withCount contexts.
|
||||
$ids = Statuslabel::where(function ($q) {
|
||||
$q->where('deployable', 1)->orWhere('pending', 1)->orWhere('archived', 0);
|
||||
})->whereNull('deleted_at')->pluck('id');
|
||||
|
||||
return $this->hasMany(Asset::class, 'location_id')
|
||||
->whereHas(
|
||||
'status', function ($query) {
|
||||
$query->where('status_labels.deployable', '=', 1)
|
||||
->orWhere('status_labels.pending', '=', 1)
|
||||
->orWhere('status_labels.archived', '=', 0);
|
||||
}
|
||||
);
|
||||
->whereIn('assets.status_id', $ids->isEmpty() ? [0] : $ids);
|
||||
}
|
||||
|
||||
public function countAllTheThings()
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
|
||||
class SCIMUser extends User
|
||||
{
|
||||
protected $table = 'users';
|
||||
@@ -21,4 +23,9 @@ class SCIMUser extends User
|
||||
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
|
||||
}
|
||||
|
||||
public function companies(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Company::class, 'company_user', 'user_id', 'company_id');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Rules\CssColor;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
@@ -173,6 +175,34 @@ class Setting extends Model
|
||||
*
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*/
|
||||
protected function headerColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#3c8dbc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkLightColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkDarkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function navLinkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
|
||||
);
|
||||
}
|
||||
|
||||
public function show_custom_css(): string
|
||||
{
|
||||
$custom_css = self::getSettings()->custom_css;
|
||||
@@ -186,6 +216,11 @@ class Setting extends Model
|
||||
return $custom_css;
|
||||
}
|
||||
|
||||
public function isQrEnabled(): bool
|
||||
{
|
||||
return $this->qr_code == '1' || $this->label2_2d_type !== 'none';
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts bytes into human readable file size.
|
||||
*
|
||||
|
||||
+185
-10
@@ -12,6 +12,7 @@ use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Parser;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -31,11 +32,137 @@ function eloquent($name, $attribute = null): Attribute
|
||||
return new Eloquent($name, $attribute);
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
// Extends Complex to handle schema-qualified attribute keys in PATCH add/replace operations.
|
||||
// Azure Entra ID sends PATCH without a "path" field, putting the full URN as the value dict key
|
||||
// e.g. {"op":"add","value":{"urn:...grokability...:location":"Head Office"}}.
|
||||
// The upstream library's add() only searches the default (core) schema, silently dropping grokability attrs.
|
||||
class SnipeRootComplex extends Complex
|
||||
{
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
private function findInSchema(string $schemaUrn, string $attrName): ?object
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
$schemaNode = $this->getSubNode($schemaUrn);
|
||||
|
||||
return ($schemaNode instanceof AttributeSchema) ? $schemaNode->getSubNode($attrName) : null;
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$match = false;
|
||||
$this->dirty = true;
|
||||
|
||||
if ($this->mutability == 'readOnly') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $v) {
|
||||
if (is_numeric($key)) {
|
||||
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
|
||||
}
|
||||
|
||||
$path = Parser::parse($key);
|
||||
|
||||
if ($path->isNotEmpty()) {
|
||||
$attributeNames = $path->getAttributePathAttributes();
|
||||
$schema = $path->getAttributePath()?->path?->schema;
|
||||
$path = $path->shiftAttributePathAttributes();
|
||||
|
||||
$subNode = ($schema !== null) ? $this->findInSchema($schema, $attributeNames[0]) : null;
|
||||
if ($subNode === null) {
|
||||
$subNode = $this->getSubNode($attributeNames[0]);
|
||||
}
|
||||
|
||||
$match = true;
|
||||
|
||||
$newValue = $v;
|
||||
if ($path->isNotEmpty()) {
|
||||
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
|
||||
}
|
||||
|
||||
if ($subNode !== null) {
|
||||
$subNode->add($newValue, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $match && $this->parent == null) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if ($attribute instanceof AttributeSchema) {
|
||||
$attribute->add($value, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->dirty = true;
|
||||
|
||||
if ($this->mutability == 'readOnly') {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($value as $key => $v) {
|
||||
if (is_numeric($key)) {
|
||||
throw new SCIMException('Invalid key: '.$key.' for complex object '.$this->getFullKey());
|
||||
}
|
||||
|
||||
$subNode = null;
|
||||
|
||||
if (strpos($key, ':') !== false) {
|
||||
$parsed = Parser::parse($key);
|
||||
$schemaUrn = $parsed->getAttributePath()?->path?->schema;
|
||||
$attrName = $parsed->getAttributePathAttributes()[0] ?? null;
|
||||
if ($schemaUrn !== null && $attrName !== null) {
|
||||
$subNode = $this->findInSchema($schemaUrn, $attrName);
|
||||
}
|
||||
if ($subNode === null) {
|
||||
$subNode = $this->getSubNode($key);
|
||||
}
|
||||
} else {
|
||||
$path = Parser::parse($key);
|
||||
if ($path->isNotEmpty()) {
|
||||
$attributeNames = $path->getAttributePathAttributes();
|
||||
$path = $path->shiftAttributePathAttributes();
|
||||
$subNode = $this->getSubNode($attributeNames[0] ?? $path->getAttributePath()?->path?->schema);
|
||||
}
|
||||
}
|
||||
|
||||
if ($subNode !== null) {
|
||||
$newValue = $v;
|
||||
if ($path !== null && $path->isNotEmpty()) {
|
||||
$newValue = [implode('.', $path->getAttributePathAttributes()) => $v];
|
||||
}
|
||||
$subNode->replace($newValue, $object, $path);
|
||||
}
|
||||
}
|
||||
|
||||
if ($subNode == null && $this->parent == null) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if ($attribute instanceof AttributeSchema) {
|
||||
$attribute->replace($value, $object, $path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($removeIfNotSet) {
|
||||
foreach ($this->subAttributes as $attribute) {
|
||||
if (! $attribute->isDirty()) {
|
||||
$attribute->remove(null, $object);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Azure Entra ID sends op=replace with path=members and only the single user being provisioned,
|
||||
// not the full member list. Using sync() would wipe all other members on every user update.
|
||||
// Override replace() to use syncWithoutDetaching() so it behaves like add(); op=remove with a
|
||||
// filter path still handles explicit removals correctly.
|
||||
class SnipeMutableCollection extends MutableCollection
|
||||
{
|
||||
public function replace($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$this->add($value, $object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +173,8 @@ class MappedTable extends Attribute
|
||||
private string $relationship_name,
|
||||
private string $relationship_class,
|
||||
private string $relationship_id_field,
|
||||
private string $relationship_field)
|
||||
{
|
||||
private string $relationship_field
|
||||
) {
|
||||
parent::__construct($this->scim_attribute_name);
|
||||
}
|
||||
|
||||
@@ -72,6 +199,50 @@ class MappedTable extends Attribute
|
||||
}
|
||||
}
|
||||
|
||||
// Company is stored only in the company_user pivot, not company_id. Read from the pivot
|
||||
// and sync it on write. For new users (not yet saved) defer the sync via a saved() callback.
|
||||
class SCIMCompanyAttribute extends MappedTable
|
||||
{
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return $object->companies->first()?->name;
|
||||
}
|
||||
|
||||
private function applyCompany(?int $companyId, Model &$object): void
|
||||
{
|
||||
$ids = $companyId ? [$companyId] : [];
|
||||
|
||||
if ($object->exists) {
|
||||
$object->companies()->sync($ids);
|
||||
} else {
|
||||
$object->saved(fn () => $object->companies()->sync($ids));
|
||||
}
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->applyCompany($value ? Company::firstOrCreate(['name' => $value])->id : null, $object);
|
||||
}
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
{
|
||||
public function remove($value, Model &$object, ?Path $path = null)
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
}
|
||||
}
|
||||
|
||||
class UpdatableComplex extends Complex
|
||||
{
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
@@ -132,7 +303,7 @@ class SnipeSCIMConfig
|
||||
'withRelations' => [],
|
||||
'description' => 'User Account',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
'map' => (new SnipeRootComplex)->withSubAttributes(
|
||||
new class('schemas', ['urn:ietf:params:scim:schemas:core:2.0:User', self::ENTERPRISE, self::GROKABILITY]) extends Constant
|
||||
{
|
||||
public function replace($value, &$object, $path = null)
|
||||
@@ -190,7 +361,11 @@ class SnipeSCIMConfig
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
if (is_string($value)) {
|
||||
$object->email = $value; // Weird MS-SCIM stuff :/
|
||||
} else {
|
||||
$object->email = $value[0]['value'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown email object: '".print_r($value, true)."'", 422);
|
||||
@@ -299,7 +474,7 @@ class SnipeSCIMConfig
|
||||
$address['primary'] = true;
|
||||
}
|
||||
|
||||
return $address;
|
||||
return [$address];
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, ?Path $path = null, $removeIfNotSet = false)
|
||||
@@ -422,7 +597,7 @@ class SnipeSCIMConfig
|
||||
),
|
||||
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
|
||||
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
|
||||
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
|
||||
new SCIMCompanyAttribute('company', 'company', Company::class, 'company_id', 'name'),
|
||||
)
|
||||
),
|
||||
];
|
||||
@@ -471,7 +646,7 @@ class SnipeSCIMConfig
|
||||
$fail('The name has already been taken.');
|
||||
}
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
(new SnipeMutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class('$ref') extends Eloquent
|
||||
{
|
||||
|
||||
@@ -30,7 +30,7 @@ class Supplier extends SnipeModel
|
||||
'fax' => 'min:7|max:35|nullable',
|
||||
'phone' => 'min:7|max:35|nullable',
|
||||
'contact' => 'max:100|nullable',
|
||||
'notes' => 'max:191|nullable', // Default string length is 191 characters..
|
||||
'notes' => 'max:16383|nullable', // text is 65535 but each character can take up to 4 bytes in utf8mb4
|
||||
'email' => 'email|max:150|nullable',
|
||||
'address' => 'max:250|nullable',
|
||||
'address2' => 'max:250|nullable',
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Company\Company;
|
||||
use App\Models\CompanyableScope;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
trait CompanyableTrait
|
||||
{
|
||||
@@ -18,13 +19,43 @@ trait CompanyableTrait
|
||||
*/
|
||||
public static function bootCompanyableTrait()
|
||||
{
|
||||
// In Version 7.0 and before locations weren't scoped by companies, so add a check for the backward compatibility setting
|
||||
if (__CLASS__ != 'App\Models\Location') {
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
} else {
|
||||
if (Setting::getSettings()?->scope_locations_fmcs == 1) {
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
}
|
||||
static::addGlobalScope(new CompanyableScope);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this item may be checked out to the given target under FMCS rules.
|
||||
*
|
||||
* Returns true when:
|
||||
* - FMCS is disabled, OR
|
||||
* - this item has no company (uncompanied items are unrestricted), OR
|
||||
* - target is a User whose company pivot includes this item's company, OR
|
||||
* - target has no company and null_company_is_floater is enabled, OR
|
||||
* - target's company_id exactly matches this item's company_id.
|
||||
*/
|
||||
public function canCheckoutTo(Model $target): bool
|
||||
{
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if (! $settings->full_multiple_companies_support) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! $this->company_id) {
|
||||
if (is_null($target->company_id)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (bool) $settings->null_company_is_floater;
|
||||
}
|
||||
|
||||
if ($target instanceof User) {
|
||||
return $target->canReceiveFromCompany((int) $this->company_id);
|
||||
}
|
||||
|
||||
if (is_null($target->company_id)) {
|
||||
return (bool) $settings->null_company_is_floater;
|
||||
}
|
||||
|
||||
return (int) $target->company_id === (int) $this->company_id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\CompanyableScope;
|
||||
|
||||
trait HasUploads
|
||||
{
|
||||
public function uploads()
|
||||
{
|
||||
// Bypass FMCS company scoping: access is already gated by the policy on the
|
||||
// parent object. Objects like AssetModel and Company have no company_id, so
|
||||
// their upload logs always have company_id = null, which the scope would hide.
|
||||
return $this->hasMany(Actionlog::class, 'item_id')
|
||||
->withoutGlobalScope(CompanyableScope::class)
|
||||
->where('item_type', self::class)
|
||||
->where('action_type', '=', 'uploaded')
|
||||
->whereNotNull('filename')
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CompanyableScope;
|
||||
use App\Models\ICompanyableChild;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
@@ -41,13 +42,15 @@ trait Loggable
|
||||
|
||||
public function history()
|
||||
{
|
||||
|
||||
// Bypass FMCS company scoping: access is already gated by the policy on the
|
||||
// parent object. Objects like AssetModel and Company have no company_id, so
|
||||
// their history logs always have company_id = null, which the scope would hide.
|
||||
return $this->morphMany(Actionlog::class, 'item')
|
||||
->withoutGlobalScope(CompanyableScope::class)
|
||||
->orWhere(function ($query) {
|
||||
$query->where('target_type', '=', static::class)
|
||||
->where('target_id', '=', $this->getKey());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public function getHistory(Request $request)
|
||||
@@ -446,7 +449,7 @@ trait Loggable
|
||||
|
||||
} catch (ServerException $e) {
|
||||
|
||||
Log::error('Teams webhook server error', [
|
||||
Log::warning('Teams webhook server error', [
|
||||
'endpoint' => $endpoint,
|
||||
'status' => $e->getResponse()?->getStatusCode(),
|
||||
'error' => $e->getMessage(),
|
||||
@@ -461,19 +464,28 @@ trait Loggable
|
||||
]);
|
||||
} catch (RequestException $e) {
|
||||
|
||||
Log::error('Teams webhook request failure', [
|
||||
Log::warning('Teams webhook request failure', [
|
||||
'endpoint' => $endpoint,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Teams webhook failed unexpectedly', [
|
||||
Log::warning('Teams webhook failed unexpectedly', [
|
||||
'endpoint' => $endpoint,
|
||||
'exception' => get_class($e),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
Setting::getSettings()->notify(new AuditNotification($params));
|
||||
try {
|
||||
Setting::getSettings()->notify(new AuditNotification($params));
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('Audit webhook notification failed', [
|
||||
'endpoint' => Setting::getSettings()->webhook_endpoint,
|
||||
'channel' => Setting::getSettings()->webhook_selected,
|
||||
'exception' => get_class($e),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $log;
|
||||
|
||||
+98
-25
@@ -9,6 +9,7 @@ use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\Presentable;
|
||||
use App\Presenters\UserPresenter;
|
||||
use App\Rules\CssColor;
|
||||
use Illuminate\Auth\Authenticatable;
|
||||
use Illuminate\Auth\Passwords\CanResetPassword;
|
||||
use Illuminate\Contracts\Auth\Access\Authorizable as AuthorizableContract;
|
||||
@@ -322,7 +323,7 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
protected function displayName(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (mixed $value) => $value ?? $this->getFullNameAttribute(),
|
||||
get: fn (mixed $value) => ($value !== null && $value !== '') ? $value : $this->getFullNameAttribute(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -600,7 +601,6 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
|
||||
&& (($this->licenses_count ?? $this->licenses()->count()) === 0)
|
||||
&& (($this->consumables_count ?? $this->consumables()->count()) === 0)
|
||||
&& (($this->accessories_count ?? $this->accessories()->count()) === 0)
|
||||
&& (($this->manages_users_count ?? $this->managesUsers()->count()) === 0)
|
||||
&& (($this->manages_locations_count ?? $this->managedLocations()->count()) === 0)
|
||||
&& ($this->deleted_at == '');
|
||||
@@ -625,6 +625,43 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return $this->belongsToMany(Company::class, 'company_user');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether an FMCS company check should allow this user to receive
|
||||
* an asset that belongs to the given company.
|
||||
*
|
||||
* - If the user has no company associations at all: returns true (no restriction).
|
||||
* - If the user has associations: returns true only when $companyId is among them.
|
||||
*/
|
||||
public function canReceiveFromCompany(int $companyId): bool
|
||||
{
|
||||
// Items with no company association are unrestricted — anyone can receive them.
|
||||
if (! $companyId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Query the pivot directly to avoid the Company model's FMCS global scope,
|
||||
// which would restrict results to the current actor's visible companies.
|
||||
$userCompanyIds = DB::table('company_user')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('company_id');
|
||||
|
||||
if ($userCompanyIds->isEmpty()) {
|
||||
return (bool) Setting::getSettings()->null_company_is_floater;
|
||||
}
|
||||
|
||||
return $userCompanyIds->contains($companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all companies this user belongs to — union of the primary company_id
|
||||
* column and the many-to-many pivot — as a deduplicated Collection.
|
||||
* Used to scope FMCS dropdowns to companies the user is allowed to work with.
|
||||
*/
|
||||
public function allCompanies(): Collection
|
||||
{
|
||||
return $this->companies->unique('id')->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync company pivot membership and log the change if the set of companies changed.
|
||||
*
|
||||
@@ -713,6 +750,27 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return $this->last_name ? $this->first_name.' '.$this->last_name : $this->first_name;
|
||||
}
|
||||
|
||||
protected function linkLightColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#296282'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function linkDarkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#5fa4cc'),
|
||||
);
|
||||
}
|
||||
|
||||
protected function navLinkColor(): Attribute
|
||||
{
|
||||
return Attribute::make(
|
||||
get: fn (?string $value) => CssColor::sanitize($value, '#ffffff'),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> assets relationship
|
||||
*
|
||||
@@ -1464,28 +1522,38 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
|
||||
}
|
||||
|
||||
public function scopeWithInventoryRelations($query, int $id)
|
||||
public function scopeWithInventoryRelations($query, int $id, bool $withLicenses = true, bool $withAccessories = true, bool $withConsumables = true)
|
||||
{
|
||||
return $query->where('id', $id)
|
||||
->with([
|
||||
'assets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.defaultLoc',
|
||||
'assets.location',
|
||||
'assets.model.category',
|
||||
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.assignedTo',
|
||||
'assets.assignedAssets.defaultLoc',
|
||||
'assets.assignedAssets.location',
|
||||
'assets.assignedAssets.model.category',
|
||||
'assets.components.category',
|
||||
$with = [
|
||||
'assets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.defaultLoc',
|
||||
'assets.location',
|
||||
'assets.model.category',
|
||||
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.assignedTo',
|
||||
'assets.assignedAssets.defaultLoc',
|
||||
'assets.assignedAssets.location',
|
||||
'assets.assignedAssets.model.category',
|
||||
'assets.components.category',
|
||||
];
|
||||
|
||||
if ($withLicenses) {
|
||||
$with = array_merge($with, [
|
||||
'assets.licenses',
|
||||
'assets.licenses.category',
|
||||
'directLicenses.category',
|
||||
'licenses.category',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($withAccessories) {
|
||||
$with = array_merge($with, [
|
||||
'assets.assignedAccessories',
|
||||
'assets.assignedAccessories.accessory.category',
|
||||
'accessories.log' => fn ($query) => $query->withTrashed()
|
||||
@@ -1494,16 +1562,21 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->where('action_type', 'accepted'),
|
||||
'accessories.category',
|
||||
'accessories.manufacturer',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($withConsumables) {
|
||||
$with = array_merge($with, [
|
||||
'consumables.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'consumables.category',
|
||||
'consumables.manufacturer',
|
||||
'directLicenses.category',
|
||||
'licenses.category',
|
||||
])
|
||||
->withTrashed();
|
||||
]);
|
||||
}
|
||||
|
||||
return $query->where('id', $id)->with($with)->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -139,23 +139,22 @@ class CheckinAssetNotification extends Notification
|
||||
$target = $this->target;
|
||||
$item = $this->item;
|
||||
$note = $this->note;
|
||||
|
||||
//
|
||||
return GoogleChatMessage::create()
|
||||
->to($this->settings->webhook_endpoint)
|
||||
->card(
|
||||
Card::create()
|
||||
->header(
|
||||
'<strong>'.trans('mail.Asset_Checkin_Notification', ['tag' => '']).'</strong>' ?: '',
|
||||
'<strong>' . trans('mail.Asset_Checkin_Notification', ['tag' => '']) . '</strong>' ?: '',
|
||||
htmlspecialchars_decode($item->display_name) ?: '',
|
||||
)
|
||||
->section(
|
||||
Section::create(
|
||||
KeyValue::create(
|
||||
trans('mail.checked_into') ?: '',
|
||||
($item->location) ? $item->location->name : '',
|
||||
trans('admin/hardware/form.status').': '.$item->status?->name,
|
||||
)
|
||||
->onClick(route('hardware.show', $item->id))
|
||||
($item->location) ? $item->location?->name : '',
|
||||
trans('admin/hardware/form.status') . ': ' . $item->status?->name
|
||||
)->onClick(route('hardware.show', $item->id))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -14,12 +14,27 @@ class AccessoryObserver
|
||||
*/
|
||||
public function updated(Accessory $accessory)
|
||||
{
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Accessory::class;
|
||||
$logAction->item_id = $accessory->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->logaction('update');
|
||||
$changed = [];
|
||||
|
||||
foreach ($accessory->getRawOriginal() as $key => $value) {
|
||||
if ($key === 'updated_at') {
|
||||
continue;
|
||||
}
|
||||
if ($accessory->getRawOriginal()[$key] != $accessory->getAttributes()[$key]) {
|
||||
$changed[$key]['old'] = $accessory->getRawOriginal()[$key];
|
||||
$changed[$key]['new'] = $accessory->getAttributes()[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($changed) > 0) {
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Accessory::class;
|
||||
$logAction->item_id = $accessory->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
$logAction->logaction('update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,8 @@ class ComponentObserver
|
||||
public function updated(Component $component)
|
||||
{
|
||||
|
||||
$changed = [];
|
||||
|
||||
foreach ($component->getRawOriginal() as $key => $value) {
|
||||
// Check and see if the value changed
|
||||
if ($component->getRawOriginal()[$key] != $component->getAttributes()[$key]) {
|
||||
|
||||
@@ -14,12 +14,27 @@ class LicenseObserver
|
||||
*/
|
||||
public function updated(License $license)
|
||||
{
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = License::class;
|
||||
$logAction->item_id = $license->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->logaction('update');
|
||||
$changed = [];
|
||||
|
||||
foreach ($license->getRawOriginal() as $key => $value) {
|
||||
if ($key === 'updated_at') {
|
||||
continue;
|
||||
}
|
||||
if ($license->getRawOriginal()[$key] != $license->getAttributes()[$key]) {
|
||||
$changed[$key]['old'] = $license->getRawOriginal()[$key];
|
||||
$changed[$key]['new'] = $license->getAttributes()[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($changed) > 0) {
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = License::class;
|
||||
$logAction->item_id = $license->id;
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
$logAction->logaction('update');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -75,13 +75,18 @@ class UserObserver
|
||||
foreach (array_unique(array_merge(array_keys($oldDecoded), array_keys($newDecoded))) as $permKey) {
|
||||
$oldPerm = $oldDecoded[$permKey] ?? null;
|
||||
$newPerm = $newDecoded[$permKey] ?? null;
|
||||
if ($oldPerm != $newPerm) {
|
||||
// null and "0" are both "inherit" — treat them as equivalent
|
||||
$normalizedOld = ($oldPerm === null || $oldPerm === '0' || $oldPerm === 0) ? null : $oldPerm;
|
||||
$normalizedNew = ($newPerm === null || $newPerm === '0' || $newPerm === 0) ? null : $newPerm;
|
||||
if ($normalizedOld !== $normalizedNew) {
|
||||
$diffOld[$permKey] = $oldPerm;
|
||||
$diffNew[$permKey] = $newPerm;
|
||||
}
|
||||
}
|
||||
$changed['permissions']['old'] = json_encode($diffOld);
|
||||
$changed['permissions']['new'] = json_encode($diffNew);
|
||||
if (! empty($diffOld) || ! empty($diffNew)) {
|
||||
$changed['permissions']['old'] = json_encode($diffOld);
|
||||
$changed['permissions']['new'] = json_encode($diffNew);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ class AssetPresenter extends Presenter
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function dataTableLayout()
|
||||
public static function dataTableLayout($hide_fields = [])
|
||||
{
|
||||
$layout = [
|
||||
[
|
||||
@@ -278,7 +278,23 @@ class AssetPresenter extends Presenter
|
||||
'title' => trans('general.updated_at'),
|
||||
'visible' => false,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
], [
|
||||
],
|
||||
];
|
||||
|
||||
if (! in_array('deleted_at', $hide_fields)) {
|
||||
$layout[] = [
|
||||
'field' => 'deleted_at',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.deleted_at'),
|
||||
'visible' => true,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
];
|
||||
}
|
||||
|
||||
$layout = array_merge($layout, [
|
||||
[
|
||||
'field' => 'last_checkout',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
@@ -323,7 +339,7 @@ class AssetPresenter extends Presenter
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
|
||||
],
|
||||
];
|
||||
]);
|
||||
|
||||
// This looks complicated, but we have to confirm that the custom fields exist in custom fieldsets
|
||||
// *and* those fieldsets are associated with models, otherwise we'll trigger
|
||||
|
||||
@@ -77,7 +77,7 @@ class CategoryPresenter extends Presenter
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'class' => 'css-envelope',
|
||||
'title' => 'Send Email',
|
||||
'title' => trans('general.send_email'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
|
||||
@@ -23,11 +23,13 @@ use App\Observers\LocationObserver;
|
||||
use App\Observers\MaintenanceObserver;
|
||||
use App\Observers\SettingObserver;
|
||||
use App\Observers\UserObserver;
|
||||
use App\View\Composers\SidebarComposer;
|
||||
use Illuminate\Pagination\Paginator;
|
||||
use Illuminate\Routing\UrlGenerator;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Rollbar\Laravel\RollbarServiceProvider;
|
||||
|
||||
@@ -75,6 +77,8 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
Paginator::useBootstrap();
|
||||
|
||||
View::composer('layouts.default', SidebarComposer::class);
|
||||
|
||||
Schema::defaultStringLength(191);
|
||||
Accessory::observe(AccessoryObserver::class);
|
||||
Asset::observe(AssetObserver::class);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Category;
|
||||
@@ -22,6 +23,7 @@ use App\Models\PredefinedKit;
|
||||
use App\Models\Statuslabel;
|
||||
use App\Models\Supplier;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Tabuna\Breadcrumbs\Breadcrumbs;
|
||||
use Tabuna\Breadcrumbs\Trail;
|
||||
@@ -119,6 +121,24 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('accessories.checkout.show', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
|
||||
->push(trans('general.checkout'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('accessories.checkin.show', function (Trail $trail, int $accessoryID) {
|
||||
$checkout = AccessoryCheckout::find($accessoryID);
|
||||
$accessory = $checkout ? Accessory::find($checkout->accessory_id) : null;
|
||||
$trail->parent('accessories.index');
|
||||
if ($accessory) {
|
||||
$trail->push($accessory->name, route('accessories.show', $accessory));
|
||||
}
|
||||
$trail->push(trans('general.checkin'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('clone/accessories', fn (Trail $trail, Accessory $accessory) => $trail->parent('accessories.show', $accessory)
|
||||
->push(trans('general.clone'))
|
||||
);
|
||||
|
||||
/**
|
||||
* Categories Breadcrumbs
|
||||
*/
|
||||
@@ -184,6 +204,25 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.clone'), route('components.create'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('components.checkout.show', function (Trail $trail, int $componentID) {
|
||||
$component = Component::find($componentID);
|
||||
$trail->parent('components.index');
|
||||
if ($component) {
|
||||
$trail->push($component->name, route('components.show', $component));
|
||||
}
|
||||
$trail->push(trans('general.checkout'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('components.checkin.show', function (Trail $trail, int $componentAssetId) {
|
||||
$componentAsset = DB::table('components_assets')->find($componentAssetId);
|
||||
$component = $componentAsset ? Component::find($componentAsset->component_id) : null;
|
||||
$trail->parent('components.index');
|
||||
if ($component) {
|
||||
$trail->push($component->name, route('components.show', $component));
|
||||
}
|
||||
$trail->push(trans('general.checkin'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Consumables Breadcrumbs
|
||||
*/
|
||||
@@ -204,6 +243,19 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('consumables.checkout.show', function (Trail $trail, $consumablesID) {
|
||||
$consumable = Consumable::find($consumablesID);
|
||||
$trail->parent('consumables.index');
|
||||
if ($consumable) {
|
||||
$trail->push($consumable->name, route('consumables.show', $consumable));
|
||||
}
|
||||
$trail->push(trans('general.checkout'));
|
||||
});
|
||||
|
||||
Breadcrumbs::for('consumables.clone.create', fn (Trail $trail, Consumable $consumable) => $trail->parent('consumables.show', $consumable)
|
||||
->push(trans('general.clone'))
|
||||
);
|
||||
|
||||
/**
|
||||
* Custom fields Breadcrumbs
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,7 @@ class SettingsServiceProvider extends ServiceProvider
|
||||
// Share common setting variables with all views.
|
||||
view()->composer('*', function ($view) {
|
||||
$view->with('snipeSettings', Setting::getSettings());
|
||||
$view->with('settings', Setting::getSettings());
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -349,6 +349,20 @@ class ValidationServiceProvider extends ServiceProvider
|
||||
return in_array($value, $options);
|
||||
});
|
||||
|
||||
Validator::replacer('fmcs_location', function ($message, $attribute, $rule, $parameters, $validator) {
|
||||
$locationId = $validator->getData()[$attribute] ?? null;
|
||||
$location = $locationId ? Location::find($locationId) : null;
|
||||
|
||||
return str_replace(
|
||||
[':location', ':location_company'],
|
||||
[
|
||||
$location?->name ?? '?',
|
||||
$location?->company?->name ?? trans('general.unassigned'),
|
||||
],
|
||||
$message
|
||||
);
|
||||
});
|
||||
|
||||
// Validates that the company of the validated object matches the company of the location in case of scoped locations
|
||||
Validator::extend('fmcs_location', function ($attribute, $value, $parameters, $validator) {
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class CssColor implements ValidationRule
|
||||
{
|
||||
private static function pattern(): string
|
||||
{
|
||||
$num = '\s*[\d.]+\s*';
|
||||
$pct = '\s*[\d.]+%\s*';
|
||||
$alpha = '(?:,\s*[\d.]+\s*)?';
|
||||
$hex = '#[0-9a-fA-F]{3,8}';
|
||||
$rgb = "rgba?\({$num},{$num},{$num}{$alpha}\)";
|
||||
$hsl = "hsla?\({$num},{$pct},{$pct}{$alpha}\)";
|
||||
|
||||
return "/^(?:{$hex}|{$rgb}|{$hsl})$/i";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return $value if it is a safe CSS color, otherwise return $default.
|
||||
* Use this for defense-in-depth when rendering color values already in the database.
|
||||
*/
|
||||
public static function sanitize(?string $value, string $default): string
|
||||
{
|
||||
if ($value && preg_match(self::pattern(), trim($value))) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return $default;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the validation rule.
|
||||
*
|
||||
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if (! preg_match(self::pattern(), $value)) {
|
||||
$fail(trans('validation.valid_css_color'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ use App\Models\PredefinedKit;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Class incapsulates checkout logic for reuse in different controllers
|
||||
*
|
||||
* @author [D. Minaev.] [<dmitriy.minaev.v@gmail.com>]
|
||||
*/
|
||||
class PredefinedKitCheckoutService
|
||||
@@ -19,9 +19,9 @@ class PredefinedKitCheckoutService
|
||||
use AuthorizesRequests;
|
||||
|
||||
/**
|
||||
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
|
||||
* @param PredefinedKit $kit kit for checkout
|
||||
* @param User $user checkout target
|
||||
* @param Request $request, this function works with fields: checkout_at, expected_checkin, note
|
||||
* @param PredefinedKit $kit kit for checkout
|
||||
* @param User $user checkout target
|
||||
* @return array Empty array if all ok, else [string_error1, string_error2...]
|
||||
*/
|
||||
public function checkout(Request $request, PredefinedKit $kit, User $user)
|
||||
@@ -93,7 +93,7 @@ class PredefinedKitCheckoutService
|
||||
}
|
||||
}
|
||||
if ($quantity > 0) {
|
||||
$errors[] = trans('admin/kits/general.none_models', ['model'=> $model->name, 'qty' => $model->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_models', ['model' => $model->name, 'qty' => $model->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,9 +107,10 @@ class PredefinedKitCheckoutService
|
||||
->with('freeSeats')
|
||||
->get();
|
||||
foreach ($licenses as $license) {
|
||||
$this->authorize('checkout', $license);
|
||||
$quantity = $license->pivot->quantity;
|
||||
if ($quantity > count($license->freeSeats)) {
|
||||
$errors[] = trans('admin/kits/general.none_licenses', ['license'=> $license->name, 'qty' => $license->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_licenses', ['license' => $license->name, 'qty' => $license->pivot->quantity]);
|
||||
}
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
$seats_to_add[] = $license->freeSeats[$i];
|
||||
@@ -123,8 +124,9 @@ class PredefinedKitCheckoutService
|
||||
{
|
||||
$consumables = $kit->consumables()->with('users')->get();
|
||||
foreach ($consumables as $consumable) {
|
||||
$this->authorize('checkout', $consumable);
|
||||
if ($consumable->numRemaining() < $consumable->pivot->quantity) {
|
||||
$errors[] = trans('admin/kits/general.none_consumables', ['consumable'=> $consumable->name, 'qty' => $consumable->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_consumables', ['consumable' => $consumable->name, 'qty' => $consumable->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +137,9 @@ class PredefinedKitCheckoutService
|
||||
{
|
||||
$accessories = $kit->accessories()->with('users')->get();
|
||||
foreach ($accessories as $accessory) {
|
||||
$this->authorize('checkout', $accessory);
|
||||
if ($accessory->numRemaining() < $accessory->pivot->quantity) {
|
||||
$errors[] = trans('admin/kits/general.none_accessory', ['accessory'=> $accessory->name, 'qty' => $accessory->pivot->quantity]);
|
||||
$errors[] = trans('admin/kits/general.none_accessory', ['accessory' => $accessory->name, 'qty' => $accessory->pivot->quantity]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +178,7 @@ class PredefinedKitCheckoutService
|
||||
]);
|
||||
event(new CheckoutableCheckedOut($consumable, $user, $admin, $note));
|
||||
}
|
||||
//accessories
|
||||
// accessories
|
||||
foreach ($accessories_to_add as $accessory) {
|
||||
$accessory->assigned_to = $user->id;
|
||||
$accessory->users()->attach($accessory->id, [
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
// A View Composer is a callback Laravel runs right before a specific view renders.
|
||||
// It's registered in AppServiceProvider bound to 'layouts.default', so it only fires
|
||||
// when a full page is rendered — not on modal AJAX responses, select2 searches, or
|
||||
// API requests. This replaces the old AssetCountForSidebar middleware, which ran on
|
||||
// every web request regardless of what was returned.
|
||||
|
||||
namespace App\View\Composers;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class SidebarComposer
|
||||
{
|
||||
public function compose(View $view): void
|
||||
{
|
||||
// Guard against the setup wizard, where DB tables may not exist yet
|
||||
try {
|
||||
$settings = Setting::getSettings();
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$due_for_checkin = Asset::DueForCheckin($settings)->count();
|
||||
$overdue_for_checkin = Asset::OverdueForCheckin()->count();
|
||||
$due_for_audit = Asset::DueForAudit($settings)->count();
|
||||
$overdue_for_audit = Asset::OverdueForAudit()->count();
|
||||
|
||||
$view->with([
|
||||
'total_assets' => Asset::AssetsForShow()->count(),
|
||||
'total_rtd_sidebar' => Asset::RTD()->count(),
|
||||
'total_deployed_sidebar' => Asset::Deployed()->count(),
|
||||
'total_archived_sidebar' => Asset::Archived()->count(),
|
||||
'total_pending_sidebar' => Asset::Pending()->count(),
|
||||
'total_undeployable_sidebar' => Asset::Undeployable()->count(),
|
||||
'total_byod_sidebar' => Asset::where('byod', 1)->count(),
|
||||
'total_due_for_audit' => $due_for_audit,
|
||||
'total_overdue_for_audit' => $overdue_for_audit,
|
||||
'total_due_for_checkin' => $due_for_checkin,
|
||||
'total_overdue_for_checkin' => $overdue_for_checkin,
|
||||
'total_due_and_overdue_for_checkin' => $due_for_checkin + $overdue_for_checkin,
|
||||
'total_due_and_overdue_for_audit' => $due_for_audit + $overdue_for_audit,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::debug($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+2
-1
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "ed0655f6c3c75cda1939dfc27b492029",
|
||||
"content-hash": "09f6cf88befc67d5f5ead4d38c37e857",
|
||||
"packages": [
|
||||
{
|
||||
"name": "alek13/slack",
|
||||
@@ -16835,6 +16835,7 @@
|
||||
"platform": {
|
||||
"php": "^8.2",
|
||||
"ext-curl": "*",
|
||||
"ext-exif": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
|
||||
+5
-1
@@ -11,12 +11,16 @@
|
||||
// This is used by the mysql dump options in spatie backup
|
||||
$dump_options = [
|
||||
'dump_binary_path' => env('DB_DUMP_PATH', '/usr/local/bin'), // only the path, so without 'mysqldump'
|
||||
'use_single_transaction' => false,
|
||||
'timeout' => 60 * 5, // 5 minute timeout
|
||||
// 'exclude_tables' => ['table1', 'table2'],
|
||||
// 'add_extra_option' => '--optionname=optionvalue',
|
||||
];
|
||||
|
||||
// Default to false (preserves original behavior for non-RDS installs).
|
||||
// Set DB_DUMP_SINGLE_TRANSACTION=true in .env for RDS or other environments
|
||||
// that require --single-transaction (e.g. no LOCK TABLES privilege).
|
||||
$dump_options['use_single_transaction'] = (env('DB_DUMP_SINGLE_TRANSACTION', 'false') === 'true');
|
||||
|
||||
// For modern versions of mysqldump, use --ssl-mode=DISABLED
|
||||
if (env('DB_DUMP_SKIP_SSL') == 'true') {
|
||||
// Correctly add the option as a string to the 'add_extra_option' key.
|
||||
|
||||
@@ -131,6 +131,7 @@ $config['allowed_upload_extensions_array'] = [
|
||||
'docx',
|
||||
'gif',
|
||||
'ico',
|
||||
'jfif',
|
||||
'jpeg',
|
||||
'jpg',
|
||||
'json',
|
||||
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app_version' => 'v8.5.1-pre',
|
||||
'full_app_version' => 'v8.5.1-pre - build 22809-g86245ad4ae',
|
||||
'build_version' => '22809',
|
||||
'app_version' => 'v8.6.2',
|
||||
'full_app_version' => 'v8.6.2 - build 23218-gc98eea1ce5',
|
||||
'build_version' => '23218',
|
||||
'prerelease_version' => '',
|
||||
'hash_version' => 'g86245ad4ae',
|
||||
'full_hash' => 'v8.5.1-pre-185-g86245ad4ae',
|
||||
'branch' => 'develop',
|
||||
'hash_version' => 'gc98eea1ce5',
|
||||
'full_hash' => 'v8.6.2-212-gc98eea1ce5',
|
||||
'branch' => 'master',
|
||||
];
|
||||
|
||||
@@ -154,4 +154,15 @@ class CustomFieldFactory extends Factory
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function testMarkdownTextarea()
|
||||
{
|
||||
return $this->state(function () {
|
||||
return [
|
||||
'name' => 'Notes',
|
||||
'help_text' => 'Additional notes about this asset. Markdown is supported.',
|
||||
'element' => 'markdown-textarea',
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +430,11 @@ class UserFactory extends Factory
|
||||
return $this->appendPermission(['kits.delete' => '1']);
|
||||
}
|
||||
|
||||
public function editPredefinedKits()
|
||||
{
|
||||
return $this->appendPermission(['kits.edit' => '1']);
|
||||
}
|
||||
|
||||
public function viewPredefinedKits()
|
||||
{
|
||||
return $this->appendPermission(['kits.view' => '1']);
|
||||
@@ -450,6 +455,26 @@ class UserFactory extends Factory
|
||||
return $this->appendPermission(['assets.audit' => '1']);
|
||||
}
|
||||
|
||||
public function manageModelFiles()
|
||||
{
|
||||
return $this->appendPermission(['models.files' => '1']);
|
||||
}
|
||||
|
||||
public function manageLocationFiles()
|
||||
{
|
||||
return $this->appendPermission(['locations.files' => '1']);
|
||||
}
|
||||
|
||||
public function manageCompanyFiles()
|
||||
{
|
||||
return $this->appendPermission(['companies.files' => '1']);
|
||||
}
|
||||
|
||||
public function manageSupplierFiles()
|
||||
{
|
||||
return $this->appendPermission(['suppliers.files' => '1']);
|
||||
}
|
||||
|
||||
private function appendPermission(array $permission)
|
||||
{
|
||||
return $this->state(function ($currentState) use ($permission) {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->boolean('null_company_is_floater')->default(false)->after('scope_locations_fmcs');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('settings', function (Blueprint $table) {
|
||||
$table->dropColumn('null_company_is_floater');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -36,6 +36,7 @@ class CustomFieldSeeder extends Seeder
|
||||
CustomField::factory()->count(1)->testEncrypted()->create();
|
||||
CustomField::factory()->count(1)->testCheckbox()->create();
|
||||
CustomField::factory()->count(1)->testRadio()->create();
|
||||
CustomField::factory()->count(1)->testMarkdownTextarea()->create();
|
||||
|
||||
DB::table('custom_field_custom_fieldset')->insert([
|
||||
[
|
||||
@@ -109,6 +110,20 @@ class CustomFieldSeeder extends Seeder
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
[
|
||||
'custom_field_id' => '9',
|
||||
'custom_fieldset_id' => '1',
|
||||
'order' => 0,
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
[
|
||||
'custom_field_id' => '9',
|
||||
'custom_fieldset_id' => '2',
|
||||
'order' => 0,
|
||||
'required' => 0,
|
||||
],
|
||||
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ class DatabaseSeeder extends Seeder
|
||||
public function run()
|
||||
{
|
||||
Model::unguard();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=0');
|
||||
|
||||
// Only create default settings if they do not exist in the db.
|
||||
if (! Setting::first()) {
|
||||
@@ -51,6 +52,7 @@ class DatabaseSeeder extends Seeder
|
||||
Log::info($output);
|
||||
|
||||
Model::reguard();
|
||||
DB::statement('SET FOREIGN_KEY_CHECKS=1');
|
||||
|
||||
DB::table('imports')->truncate();
|
||||
DB::table('requested_assets')->truncate();
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user