Compare commits
283 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 90c8689596 | |||
| 161d7e1c2b | |||
| 8627032c4f | |||
| 5bead4fbcc | |||
| ef44ba5f97 | |||
| 2dc0ec9e7e | |||
| afd435e895 | |||
| 80d1bf6a7a | |||
| 737f3ef3db | |||
| d179f47274 | |||
| 1832d95371 | |||
| a614f986f0 | |||
| f398a59d26 | |||
| c8bd104268 | |||
| 7f01bd4c56 | |||
| 6c3c7fdf49 | |||
| bdc8fc8d4a | |||
| 41be127489 | |||
| fdfae9593d | |||
| 6a21eb53c9 | |||
| efde2b4672 | |||
| daaa26cbf4 | |||
| 3e7441562c | |||
| 7b53fa5245 | |||
| d35d46f5b4 | |||
| f4772a9cad | |||
| 4c1bb7e0ac | |||
| 762ea9b4db | |||
| b16970a61e | |||
| dadb9bd81e | |||
| 13dc7de660 | |||
| 003ea36e18 | |||
| f4bd2a68c9 | |||
| be4e75d4f7 | |||
| 538c21ce1e | |||
| 626cd6cb2e | |||
| 2a56f6573d | |||
| 6ee2dc1cd6 | |||
| 3fcde8bd16 | |||
| e2ff7a7bc7 | |||
| c7efd16517 | |||
| f2907f04d9 | |||
| 7d98c267d5 | |||
| 5bc6330c13 | |||
| 1706ed597d | |||
| 6e9ba28ef7 | |||
| 554d1a44de | |||
| c0a8f4c1a4 | |||
| 08be9aac6d | |||
| a51b17fb53 | |||
| 66d5618d60 | |||
| e16c2384fd | |||
| b3323f08a0 | |||
| 7e63c2ef92 | |||
| 7f65b6d598 | |||
| 8fb8f0a4d2 | |||
| 637dbc8d2a | |||
| 978990fdff | |||
| 52a058e511 | |||
| 64bea202c5 | |||
| 37f60993ca | |||
| 32717c67c7 | |||
| 3681e3f025 | |||
| 1d0f055349 | |||
| fb3024ca9c | |||
| 005c0ea9f6 | |||
| 7c3f1f3a84 | |||
| 900e5209d9 | |||
| 4fbf416d16 | |||
| 7b7d2c87fb | |||
| 6debb3a65d | |||
| 315ba49a1d | |||
| ff57855038 | |||
| da6e837578 | |||
| a2d8f89162 | |||
| e36d65e695 | |||
| 34abf14cbe | |||
| dda7a4f22f | |||
| 283a885196 | |||
| d44aa3f16e | |||
| 575e825579 | |||
| dc8cbf4786 | |||
| 5f81a48d8b | |||
| c22e4c00a5 | |||
| afb37981bf | |||
| 9b5ead39d3 | |||
| 158e66f9c6 | |||
| bd8e944e2f | |||
| 2b6518427a | |||
| 06d95b679b | |||
| ff75b9eed8 | |||
| 17a88fcb80 | |||
| 185e0073b3 | |||
| eca34de593 | |||
| d0794ba71c | |||
| 40e89756bf | |||
| 55e46b2d15 | |||
| 02383aad7b | |||
| e75f54cc1c | |||
| 1b42e2e138 | |||
| 3668c24d02 | |||
| a84533b4f4 | |||
| b4efabe82e | |||
| cbe750cc9e | |||
| a77dedf3d7 | |||
| b6ce823cc2 | |||
| f7e8ce2ade | |||
| 62e5b71dc1 | |||
| 3d04324595 | |||
| 468cf73b97 | |||
| 5b90f9fb87 | |||
| 9131dbf09b | |||
| 9b37e95b58 | |||
| a425234365 | |||
| a92d8eeaab | |||
| cd4e268c72 | |||
| b94945a461 | |||
| 5b0a779c07 | |||
| e8dbb12ccc | |||
| d099bf2983 | |||
| f7add0e4dd | |||
| 1e1cc897ad | |||
| 04e2c59aa9 | |||
| 03bd3517be | |||
| eeba5bc8fd | |||
| 1f54180c9c | |||
| 8497a27c81 | |||
| 80afa470ee | |||
| 10c750e1a2 | |||
| 3aa175b36d | |||
| 8a2cd19ea6 | |||
| e76036965b | |||
| 2bb86a2ec1 | |||
| a89c8c6e5b | |||
| 1bdf205ca6 | |||
| afdf86ad0d | |||
| ccf801137a | |||
| ef746a173e | |||
| a5dae3f222 | |||
| e3552f4e36 | |||
| 75d9357488 | |||
| 26c028cf37 | |||
| 10c483967f | |||
| 07b33e8189 | |||
| 97765c08b1 | |||
| fc3ea78005 | |||
| 6ad92556a1 | |||
| bd4150af5a | |||
| e2465ca2a7 | |||
| 1c6c93da35 | |||
| 0daec32ddd | |||
| e466ed9e06 | |||
| 4445b0317f | |||
| beaea6c3bf | |||
| f5644928a8 | |||
| a279c44aa5 | |||
| f1f96e574c | |||
| 1879001ef3 | |||
| 5014b1c459 | |||
| 903459cf7e | |||
| 7c04661cfa | |||
| 76d3194c96 | |||
| b63aee2851 | |||
| f57d2608c5 | |||
| 34331525b1 | |||
| 8d1f4427ae | |||
| 7f89f8284f | |||
| 3b2ac2bc3c | |||
| 73e88be8f3 | |||
| f5d092f497 | |||
| 8edbad92cb | |||
| b0e13a1352 | |||
| 5c75648cd7 | |||
| 0b1b99697e | |||
| 07202a8061 | |||
| 189454096b | |||
| 55ee5df852 | |||
| f6466b9154 | |||
| 8e5a64dca9 | |||
| b894147514 | |||
| d55c2c269f | |||
| c7afcf0bef | |||
| c79f5b8b74 | |||
| dc6b45cbcb | |||
| 73bbe5062d | |||
| 11eaf7ce7b | |||
| 590e97a99f | |||
| 4c09f3a229 | |||
| 260ca085bb | |||
| 7b00074b9e | |||
| 21d030db26 | |||
| 444b58504c | |||
| c1e2f4ad75 | |||
| ec6778e770 | |||
| 10e6c93a95 | |||
| 0060207816 | |||
| 2f6420e05f | |||
| c01699b6e4 | |||
| 6c6199add8 | |||
| 42cd5e0017 | |||
| baee6a37ea | |||
| 90b3685808 | |||
| e9a628066f | |||
| 8f46b5254e | |||
| a15e9d737c | |||
| 08f6f5cf71 | |||
| 4f9ce07304 | |||
| 743c598b83 | |||
| f7717571ea | |||
| fe84d35ce4 | |||
| 5c5414c960 | |||
| 2eeb1f588a | |||
| 9f69eacf71 | |||
| b2fda13ac3 | |||
| 88d34a5b92 | |||
| b91dd15f96 | |||
| 6e8e72f281 | |||
| 1311ce48d3 | |||
| 4a0dbba3ec | |||
| fcd0360135 | |||
| a94ba474f3 | |||
| a81ab0ea0f | |||
| 5417bf3445 | |||
| 8113ddb2d5 | |||
| 95c7d5eeff | |||
| fff89ee94c | |||
| 2745552915 | |||
| 2f400a2b17 | |||
| de5256b8f5 | |||
| 344ae053cf | |||
| d6b48a2818 | |||
| f8c7eee17b | |||
| c8d2118c74 | |||
| ddaa75a6dd | |||
| 182e06173d | |||
| f6b4600f8a | |||
| 6eaea0b73f | |||
| ee7dddf836 | |||
| e2e4743994 | |||
| 7b1a5aea19 | |||
| 602e13dab7 | |||
| 64117b92b0 | |||
| 17c89a3f2b | |||
| 9d33a2c524 | |||
| a470ba76df | |||
| 3ce017fa68 | |||
| d446da2243 | |||
| cdb4416421 | |||
| a1de8aa20c | |||
| adfad90f7c | |||
| 22703806cd | |||
| 22a63fc2ee | |||
| 8747ff32dd | |||
| 4ddd2f1cf8 | |||
| 11c8fd4d4c | |||
| ab04f3de93 | |||
| 4c16796256 | |||
| 516771d948 | |||
| e25ea465c5 | |||
| 30ac3d1a26 | |||
| e47c772230 | |||
| 706b623d95 | |||
| a908a76f53 | |||
| a2ec707f79 | |||
| 7cbc0fa671 | |||
| 15346eec22 | |||
| c48e0c7377 | |||
| 95fdfa6396 | |||
| f8ecbf8f0b | |||
| c5ffbf6ed9 | |||
| 2115de9926 | |||
| 53149666ad | |||
| 5d55c5021b | |||
| 778da511a5 | |||
| 84940f12c5 | |||
| 0f45ecc00f | |||
| fc4ac029b1 | |||
| 73f4afa05e | |||
| ef1a42fff2 | |||
| 760d089073 | |||
| 92fbf83bdb | |||
| 9525bbf502 | |||
| 61df3bc462 |
@@ -0,0 +1,69 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# EthicalCheck addresses the critical need to continuously security test APIs in development and in production.
|
||||
|
||||
# EthicalCheck provides the industry’s only free & automated API security testing service that uncovers security vulnerabilities using OWASP API list.
|
||||
# Developers relies on EthicalCheck to evaluate every update and release, ensuring that no APIs go to production with exploitable vulnerabilities.
|
||||
|
||||
# You develop the application and API, we bring complete and continuous security testing to you, accelerating development.
|
||||
|
||||
# Know your API and Applications are secure with EthicalCheck – our free & automated API security testing service.
|
||||
|
||||
# How EthicalCheck works?
|
||||
# EthicalCheck functions in the following simple steps.
|
||||
# 1. Security Testing.
|
||||
# Provide your OpenAPI specification or start with a public Postman collection URL.
|
||||
# EthicalCheck instantly instrospects your API and creates a map of API endpoints for security testing.
|
||||
# It then automatically creates hundreds of security tests that are non-intrusive to comprehensively and completely test for authentication, authorizations, and OWASP bugs your API. The tests addresses the OWASP API Security categories including OAuth 2.0, JWT, Rate Limit etc.
|
||||
|
||||
# 2. Reporting.
|
||||
# EthicalCheck generates security test report that includes all the tested endpoints, coverage graph, exceptions, and vulnerabilities.
|
||||
# Vulnerabilities are fully triaged, it contains CVSS score, severity, endpoint information, and OWASP tagging.
|
||||
|
||||
|
||||
# This is a starter workflow to help you get started with EthicalCheck Actions
|
||||
|
||||
name: EthicalCheck-Workflow
|
||||
|
||||
# Controls when the workflow will run
|
||||
on:
|
||||
# Triggers the workflow on push or pull request events but only for the "master" branch
|
||||
# Customize trigger events based on your DevSecOps processes.
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
branches: [ "master" ]
|
||||
schedule:
|
||||
- cron: '35 17 * * 6'
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
Trigger_EthicalCheck:
|
||||
permissions:
|
||||
security-events: write # for github/codeql-action/upload-sarif to upload SARIF results
|
||||
actions: read # only required for a private repository by github/codeql-action/upload-sarif to get the Action run status
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: EthicalCheck Free & Automated API Security Testing Service
|
||||
uses: apisec-inc/ethicalcheck-action@005fac321dd843682b1af6b72f30caaf9952c641
|
||||
with:
|
||||
# The OpenAPI Specification URL or Swagger Path or Public Postman collection URL.
|
||||
oas-url: "http://netbanking.apisec.ai:8080/v2/api-docs"
|
||||
# The email address to which the penetration test report will be sent.
|
||||
email: "snipe@snipe.net"
|
||||
sarif-result-file: "ethicalcheck-results.sarif"
|
||||
|
||||
- name: Upload sarif file to repository
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
with:
|
||||
sarif_file: ./ethicalcheck-results.sarif
|
||||
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
- "8.2"
|
||||
- "8.3"
|
||||
- "8.4"
|
||||
- "8.5"
|
||||
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php-version:
|
||||
- "8.3"
|
||||
- "8.5"
|
||||
|
||||
name: PHP ${{ matrix.php-version }}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.4",
|
||||
"php_max_wontwork": "8.5.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
"DOC1": "This file is meant to be pulled from the current HEAD of the desired branch, NOT referenced locally",
|
||||
"DOC2": "In other words, what you see locally are the requirements for your _current_ install",
|
||||
"DOC3": "Please don't rely on these versions for planning upgrades unless you've fetched the most recent version",
|
||||
"DOC4": "You should really just ignore it and run upgrade.php. Really",
|
||||
"php_min_version": "8.2.0",
|
||||
"php_max_major_minor": "8.5",
|
||||
"php_max_wontwork": "8.6.0",
|
||||
"current_snipeit_version": "8.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Stack
|
||||
|
||||
- **PHP 8.2+** / **Laravel 12** (framework), **Laravel Mix** (webpack) for frontend assets
|
||||
- **AdminLTE 2** / **Bootstrap 3** UI — Blade views, no Livewire/Inertia
|
||||
- **Chart.js v2.9.4** — bundled at `public/js/dist/Chart.min.js`; use `horizontalBar` type (v2 API, not v3)
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
php artisan test
|
||||
# or
|
||||
vendor/bin/phpunit
|
||||
|
||||
# Run a single test file
|
||||
php artisan test tests/Feature/Assets/AssetsTest.php
|
||||
|
||||
# Run a specific test method
|
||||
php artisan test --filter testSomeMethod
|
||||
|
||||
# Build frontend assets (dev)
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run prod
|
||||
|
||||
# Laravel Mix watch
|
||||
npm run watch
|
||||
|
||||
# Tinker / REPL
|
||||
php artisan tinker
|
||||
|
||||
# Clear caches after config/route changes
|
||||
php artisan optimize:clear
|
||||
```
|
||||
|
||||
Dev server is served via **Laravel Herd** (`herd coverage` for coverage reports).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Controllers
|
||||
|
||||
Two parallel controller trees:
|
||||
- `app/Http/Controllers/` — web/UI controllers (Blade views)
|
||||
- `app/Http/Controllers/Api/` — REST API controllers (JSON, used by datatables + select2)
|
||||
|
||||
Subdirectory groupings: `Assets/`, `Licenses/`, `Users/`, `Accessories/`, `Consumables/`, `Components/`, `Kits/`, `Account/`, `Auth/`
|
||||
|
||||
### API Pattern
|
||||
|
||||
Every API controller returns data via a **Transformer** (`app/Http/Transformers/`). Never return raw model attributes from API controllers — always pass through the transformer. `DatatablesTransformer` wraps paginated results.
|
||||
|
||||
```php
|
||||
return (new AssetsTransformer)->transformAssets($assets, $assets->count());
|
||||
```
|
||||
|
||||
### Authorization
|
||||
|
||||
All authorization goes through **Policies** (`app/Policies/`). `CheckoutablePermissionsPolicy` is the base for assets/licenses/accessories/consumables — its `checkout()` / `checkin()` methods accept `$item = null` so you can use `@can('checkout', \App\Models\Asset::class)` without an instance.
|
||||
|
||||
### FMCS (Full Multiple Company Support)
|
||||
|
||||
`Setting::getSettings()->full_multiple_companies_support == '1'` gates company-scoped filtering. The select2 API endpoints (`selectlist()` methods) accept a `companyId` query param — apply it like this:
|
||||
|
||||
```php
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
|
||||
$query->where('table.company_id', $request->input('companyId'));
|
||||
}
|
||||
```
|
||||
|
||||
Pass `data-company-id="{{ $user->company_id }}"` in Blade to wire it to select2.
|
||||
|
||||
### Select2 AJAX Dropdowns
|
||||
|
||||
Use `class="js-data-ajax"` with `data-endpoint="hardware|licenses|consumables|..."`. `snipeit.js` auto-initializes these, forwarding `data-company-id` as `companyId` and `data-asset-status-type` as `statusType` to the API.
|
||||
|
||||
### Routes
|
||||
|
||||
All routes are in `routes/web.php` (UI) and `routes/api.php` (API). Breadcrumbs are defined inline using `->breadcrumbs(fn (Trail $trail) => ...)` from `tabuna/breadcrumbs`. Every UI route should have a breadcrumb.
|
||||
|
||||
Note: the `reports/unaccepted_assets` route is named with slashes, not dots — use `route('reports/unaccepted_assets')`.
|
||||
|
||||
### Translations
|
||||
|
||||
String keys live in `resources/lang/en-US/general.php` (and other files in that directory). Always add new UI strings as translation keys rather than hard-coding English.
|
||||
|
||||
### Checkout Redirect Flow
|
||||
|
||||
After checkout, `Helper::getRedirectOption()` reads `$request->redirect_option`. For redirecting back to the assigned user after checkout:
|
||||
- Set `redirect_option=target` in the form
|
||||
- Set `checkout_to_type=user` in the form
|
||||
- Set `assigned_user={{ $user->id }}` in the form
|
||||
|
||||
### Key Helper Methods (`app/Helpers/Helper.php`)
|
||||
|
||||
- `Helper::deployableStatusLabelList()` — status labels for checkout forms
|
||||
- `Helper::defaultChartColors()` — 10-color palette used in charts
|
||||
- `Helper::getRedirectOption($request, $id, $table)` — post-checkout redirect logic
|
||||
|
||||
### Global View Variables
|
||||
|
||||
`$snipeSettings` is injected into all views via a service provider — no need to pass `Setting::getSettings()` from every controller. Use it directly in Blade.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests live in `tests/Feature/` (organized by entity) and `tests/Unit/`. Feature tests hit the database; the test environment uses `array` cache/session/mail drivers. Tests use factories for data setup.
|
||||
+15
-4
@@ -10,9 +10,9 @@ however there are times when library dependencies and/or PHP/MySQL dependencies
|
||||
make it impossible to backport security fixes on older versions.
|
||||
|
||||
| Version | Supported |
|
||||
|---------| ------------------ |
|
||||
|---------|--------------------|
|
||||
| 8.x | :white_check_mark: |
|
||||
| 7.x | :white_check_mark: |
|
||||
| 7.x | :x: |
|
||||
| 6.x | :x: |
|
||||
| 5.1.x | :x: |
|
||||
| 5.0.x | :x: |
|
||||
@@ -24,7 +24,18 @@ make it impossible to backport security fixes on older versions.
|
||||
Security vulnerabilities should be sent to security@snipeitapp.com. You can typically expect a
|
||||
response within two business days, and we typically have fixes out in under a week from the initial disclosure.
|
||||
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation,
|
||||
but those have historically been the timelines we worm around.
|
||||
This obviously varies based on the severity of the security issue and the difficulty in remediation, but those have
|
||||
historically been the timelines we work around.
|
||||
|
||||
We do ask that you do not disclose the vulnerability publicly until we have had a chance to address it and tag a release
|
||||
so that we can protect our users, and we will work
|
||||
with you to coordinate a public disclosure once we have a fix out. We will also work with you to ensure that you receive
|
||||
appropriate credit for the discovery of the vulnerability, if you would like to be credited. (Please provide a GitHub
|
||||
username or other information if you would like to be credited, and please let us know if you would like to remain
|
||||
anonymous.)
|
||||
|
||||
For responsible disclosure, we ask that you give us at least __90 days__ to address the issue before disclosing it
|
||||
publicly,
|
||||
but we will work with you if you need to disclose it sooner than that.
|
||||
|
||||
For a full breakdown of our security policies, please see https://snipeitapp.com/security.
|
||||
|
||||
@@ -19,7 +19,7 @@ class LdapSync extends Command
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--summary} {--json_summary}';
|
||||
protected $signature = 'snipeit:ldap-sync {--location=} {--location_id=*} {--base_dn=} {--filter=} {--delete} {--summary} {--json_summary}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
@@ -94,6 +94,7 @@ class LdapSync extends Command
|
||||
}
|
||||
|
||||
$summary = [];
|
||||
$seen_ldap_usernames = [];
|
||||
|
||||
try {
|
||||
|
||||
@@ -274,8 +275,14 @@ class LdapSync extends Command
|
||||
'name' => $item['department'],
|
||||
]);
|
||||
|
||||
$user = User::where('username', $item['username'])->first();
|
||||
$user = User::withTrashed()->where('username', $item['username'])->first();
|
||||
if (! empty($item['username'])) {
|
||||
$seen_ldap_usernames[] = $item['username'];
|
||||
}
|
||||
if ($user) {
|
||||
if ($user->trashed()) {
|
||||
$user->restore();
|
||||
}
|
||||
// Updating an existing user.
|
||||
$item['createorupdate'] = 'updated';
|
||||
} else {
|
||||
@@ -490,6 +497,41 @@ class LdapSync extends Command
|
||||
array_push($summary, $item);
|
||||
}
|
||||
|
||||
// Optionally soft-delete LDAP-imported users that are no longer present in LDAP.
|
||||
// users with assests etc. are not deletable and skipped
|
||||
if ($this->option('delete')) {
|
||||
$missing_ldap_users = User::where('ldap_import', 1);
|
||||
$missing_ldap_users = $missing_ldap_users->whereNotIn('username', $seen_ldap_usernames);
|
||||
$missing_ldap_users = $missing_ldap_users->get();
|
||||
|
||||
foreach ($missing_ldap_users as $missing_user) {
|
||||
$is_deletable = $this->isUserDeletable($missing_user);
|
||||
|
||||
$missing_item = [
|
||||
'id' => $missing_user->id,
|
||||
'username' => $missing_user->username,
|
||||
'firstname' => $missing_user->first_name,
|
||||
'lastname' => $missing_user->last_name,
|
||||
'email' => $missing_user->email,
|
||||
'createorupdate' => 'skipped',
|
||||
'status' => 'info',
|
||||
'deletable' => $is_deletable,
|
||||
'note' => $is_deletable ? 'missing from LDAP' : 'missing from LDAP, but not deletable',
|
||||
];
|
||||
|
||||
if ($is_deletable) {
|
||||
$missing_user->delete();
|
||||
$missing_item['createorupdate'] = 'deleted';
|
||||
$missing_item['status'] = 'success';
|
||||
$missing_item['note'] = 'deleted_missing_from_ldap';
|
||||
}
|
||||
|
||||
$summary[] = $missing_item;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
if ($this->option('summary')) {
|
||||
for ($x = 0; $x < count($summary); $x++) {
|
||||
if ($summary[$x]['status'] == 'error') {
|
||||
@@ -505,4 +547,23 @@ class LdapSync extends Command
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the user is deletable without gate check
|
||||
*
|
||||
* A user is considered deletable if they have no associated assets, accessories, licenses, consumables, managed users, or managed locations.
|
||||
*
|
||||
* @param User $user The user to check
|
||||
*
|
||||
* @return bool True if the user is deletable, false otherwise
|
||||
*/
|
||||
private function isUserDeletable(User $user): bool
|
||||
{
|
||||
return (($user->assets_count ?? $user->assets()->count()) === 0)
|
||||
&& (($user->accessories_count ?? $user->accessories()->count()) === 0)
|
||||
&& (($user->licenses_count ?? $user->licenses()->count()) === 0)
|
||||
&& (($user->consumables_count ?? $user->consumables()->count()) === 0)
|
||||
&& (($user->manages_users_count ?? $user->managesUsers()->count()) === 0)
|
||||
&& (($user->manages_locations_count ?? $user->managedLocations()->count()) === 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,9 @@ class PurgeEulaPDFs extends Command
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'snipeit:purge-eula-pdfs
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--older-than-days= : The number of days we should delete before }
|
||||
{--company-id= : Only purge acceptances for users in this company}
|
||||
{--only-deleted-users : Only purge acceptances for deleted users, including soft-deleted or missing users}
|
||||
{--force : Skip the interactive yes/no prompt for confirmation}
|
||||
{--dryrun : Show the records that would be deleted but don\'t update the database or delete files from disk}
|
||||
{--with-output : Display the results in a table in your console}';
|
||||
@@ -55,7 +57,34 @@ class PurgeEulaPDFs extends Command
|
||||
$this->info('This script is being run with the --dryrun option. No files or records will be deleted.');
|
||||
|
||||
}
|
||||
$acceptances = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)->with('assignedTo')->get();
|
||||
$companyId = $this->option('company-id');
|
||||
$query = CheckoutAcceptance::HasFiles()->where('updated_at', '<', $interval_date)
|
||||
->with([
|
||||
'assignedTo' => function ($query) {
|
||||
$query->withTrashed();
|
||||
},
|
||||
]);
|
||||
|
||||
if ($this->option('only-deleted-users')) {
|
||||
$query->where(function ($query) use ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($q) use ($companyId) {
|
||||
$q->withTrashed()->whereNotNull('deleted_at');
|
||||
|
||||
if ($companyId) {
|
||||
$q->where('company_id', $companyId);
|
||||
}
|
||||
});
|
||||
|
||||
$query->orWhereDoesntHave('assignedTo');
|
||||
});
|
||||
} else {
|
||||
if ($companyId) {
|
||||
$query->whereHas('assignedTo', function ($query) use ($companyId) {
|
||||
$query->withTrashed()->where('company_id', $companyId);
|
||||
});
|
||||
}
|
||||
}
|
||||
$acceptances = $query->get();
|
||||
|
||||
if (! $this->option('force')) {
|
||||
if ($this->confirm("\n****************************************************\nTHIS WILL DELETE ALL OF THE SIGNATURES AND EULA PDF FILES SINCE $interval_date. \nThere is NO undo! \n****************************************************\n\nDo you wish to continue? No backsies! [y|N]")) {
|
||||
|
||||
@@ -456,7 +456,11 @@ class RestoreFromBackup extends Command
|
||||
if (! file_exists($mysql_binary)) {
|
||||
return $this->error("mysql tool at: '$mysql_binary' does not exist, cannot restore. Please edit DB_DUMP_PATH in your .env to point to a directory that contains the mysqldump and mysql binary");
|
||||
}
|
||||
$proc_results = proc_open("$mysql_binary -h ".escapeshellarg(config('database.connections.mysql.host')).' -u '.escapeshellarg(config('database.connections.mysql.username')).' '.escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
$proc_results = proc_open("$mysql_binary -h " .
|
||||
escapeshellarg(config('database.connections.mysql.host')) .
|
||||
' -u ' . escapeshellarg(config('database.connections.mysql.username')) . ' ' .
|
||||
' -P ' . escapeshellarg(config('database.connections.mysql.port')) . ' ' .
|
||||
escapeshellarg(config('database.connections.mysql.database')), // yanked -p since we pass via ENV
|
||||
[0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']],
|
||||
$pipes,
|
||||
null,
|
||||
|
||||
@@ -208,6 +208,30 @@ class AcceptanceController extends Controller
|
||||
'qty' => $acceptance->qty ?? 1,
|
||||
];
|
||||
|
||||
// Include asset custom fields that are explicitly allowed in outbound emails/PDFs.
|
||||
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
|
||||
$customFields = [];
|
||||
$fields = $item->model->fieldset->fields
|
||||
->where('show_in_email', true)
|
||||
->where('field_encrypted', false);
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$dbColumn = $field->db_column;
|
||||
$value = $item->{$dbColumn};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($customFields)) {
|
||||
$data['custom_fields'] = $customFields;
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->input('asset_acceptance') === 'accepted') {
|
||||
|
||||
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
|
||||
@@ -14,11 +14,14 @@ use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Company;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class AccessoriesController extends Controller
|
||||
{
|
||||
@@ -84,23 +87,23 @@ class AccessoriesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$accessories->where('category_id', '=', $request->input('category_id'));
|
||||
$accessories->where('accessories.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$accessories->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$accessories->where('accessories.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$accessories->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$accessories->where('accessories.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$accessories->where('location_id', '=', $request->input('location_id'));
|
||||
$accessories->where('accessories.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$accessories->where('notes', '=', $request->input('notes'));
|
||||
$accessories->where('accessories.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -155,6 +158,7 @@ class AccessoriesController extends Controller
|
||||
{
|
||||
$accessory = new Accessory;
|
||||
$accessory->fill($request->all());
|
||||
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$accessory = $request->handleImages($accessory);
|
||||
|
||||
if ($accessory->save()) {
|
||||
@@ -248,6 +252,7 @@ class AccessoriesController extends Controller
|
||||
$this->authorize('update', Accessory::class);
|
||||
$accessory = Accessory::findOrFail($id);
|
||||
$accessory->fill($request->all());
|
||||
$accessory->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$accessory = $request->handleImages($accessory);
|
||||
|
||||
if ($accessory->save()) {
|
||||
@@ -297,40 +302,49 @@ class AccessoriesController extends Controller
|
||||
{
|
||||
$this->authorize('checkout', $accessory);
|
||||
$target = $this->determineCheckoutTarget();
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
$accessory_checkout = new AccessoryCheckout([
|
||||
'accessory_id' => $accessory->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$accessory_checkout->created_by = auth()->id();
|
||||
$accessory_checkout->save();
|
||||
|
||||
$payload = [
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
'created_by' => auth()->id(),
|
||||
'pivot' => $accessory_checkout->id,
|
||||
];
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($accessory->company_id !== $target->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
// Set this value to be able to pass the qty through to the event
|
||||
event(new CheckoutableCheckedOut(
|
||||
$accessory,
|
||||
$target,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
));
|
||||
$accessory->checkout_qty = $request->input('checkout_qty', 1);
|
||||
$payload = null;
|
||||
|
||||
// Keep checkout rows and checkout log/event atomic to avoid ghost assignments.
|
||||
DB::transaction(function () use ($accessory, $request, $target, &$payload): void {
|
||||
for ($i = 0; $i < $accessory->checkout_qty; $i++) {
|
||||
|
||||
$accessory_checkout = new AccessoryCheckout([
|
||||
'accessory_id' => $accessory->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$accessory_checkout->created_by = auth()->id();
|
||||
$accessory_checkout->save();
|
||||
|
||||
$payload = [
|
||||
'accessory_id' => $accessory->id,
|
||||
'assigned_to' => $target->id,
|
||||
'assigned_type' => $target::class,
|
||||
'note' => $request->input('note'),
|
||||
'created_by' => auth()->id(),
|
||||
'pivot' => $accessory_checkout->id,
|
||||
];
|
||||
}
|
||||
|
||||
// Set this value to be able to pass the qty through to the event.
|
||||
event(new CheckoutableCheckedOut(
|
||||
$accessory,
|
||||
$target,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $payload, trans('admin/accessories/message.checkout.success')));
|
||||
|
||||
|
||||
@@ -706,18 +706,35 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($asset->save()) {
|
||||
if ($request->input('assigned_user')) {
|
||||
$target = User::find(request('assigned_user'));
|
||||
} elseif ($request->input('assigned_asset')) {
|
||||
$target = Asset::find(request('assigned_asset'));
|
||||
} elseif ($request->input('assigned_location')) {
|
||||
$target = Location::find(request('assigned_location'));
|
||||
$target = $this->resolveCheckoutTargetForAssetMutation($request);
|
||||
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
|
||||
|
||||
if ($requestedCheckout && (! $target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
|
||||
if ($companyMismatchResponse) {
|
||||
return $companyMismatchResponse;
|
||||
}
|
||||
if (isset($target)) {
|
||||
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
|
||||
}
|
||||
|
||||
$stored = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
|
||||
if (! $asset->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
// Keep create + optional checkout side effects atomic.
|
||||
return $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset creation', e($request->input('name')));
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($stored) {
|
||||
|
||||
if ($asset->image) {
|
||||
$asset->image = $asset->getImageUrl();
|
||||
}
|
||||
@@ -792,25 +809,54 @@ class AssetsController extends Controller
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($asset->save()) {
|
||||
if (($request->filled('assigned_user')) && ($target = User::find($request->input('assigned_user')))) {
|
||||
$location = $target->location_id;
|
||||
} elseif (($request->filled('assigned_asset')) && ($target = Asset::find($request->input('assigned_asset')))) {
|
||||
$location = $target->location_id;
|
||||
$target = $this->resolveCheckoutTargetForAssetMutation($request, $asset->id);
|
||||
$requestedCheckout = $request->filled('assigned_user') || $request->filled('assigned_asset') || $request->filled('assigned_location');
|
||||
|
||||
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $target->location_id]);
|
||||
} elseif (($request->filled('assigned_location')) && ($target = Location::find($request->input('assigned_location')))) {
|
||||
$location = $target->id;
|
||||
if ($requestedCheckout && (! $target)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
if ($requestedCheckout) {
|
||||
$companyMismatchResponse = $this->checkoutCompanyMismatchResponse($asset, $target);
|
||||
if ($companyMismatchResponse) {
|
||||
return $companyMismatchResponse;
|
||||
}
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($asset, $request, $target, $requestedCheckout): bool {
|
||||
if (! $asset->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($target)) {
|
||||
if ($requestedCheckout) {
|
||||
// Using `->has` preserves the asset name if the name parameter was not included in request.
|
||||
$asset_name = request()->has('name') ? request('name') : $asset->name;
|
||||
|
||||
$asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location);
|
||||
$location = null;
|
||||
if ($request->filled('assigned_user')) {
|
||||
$location = $target->location_id;
|
||||
} elseif ($request->filled('assigned_asset')) {
|
||||
$location = $target->location_id;
|
||||
} elseif ($request->filled('assigned_location')) {
|
||||
$location = $target->id;
|
||||
}
|
||||
|
||||
// Keep update + optional checkout side effects atomic.
|
||||
if (! $asset->checkOut($target, auth()->user(), date('Y-m-d H:i:s'), '', 'Checked out on asset update', $asset_name, $location)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($request->filled('assigned_asset')) {
|
||||
Asset::where('assigned_type', Asset::class)->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $target->location_id]);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($updated) {
|
||||
|
||||
if ($asset->image) {
|
||||
$asset->image = $asset->getImageUrl();
|
||||
}
|
||||
@@ -829,6 +875,36 @@ class AssetsController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, $asset->getErrors()), 200);
|
||||
}
|
||||
|
||||
private function resolveCheckoutTargetForAssetMutation(Request $request, ?int $assetId = null): User|Asset|Location|null
|
||||
{
|
||||
if ($request->filled('assigned_user')) {
|
||||
return User::withoutGlobalScopes()->find($request->input('assigned_user'));
|
||||
}
|
||||
|
||||
if ($request->filled('assigned_asset')) {
|
||||
return Asset::withoutGlobalScopes()->where('id', '!=', $assetId)->find($request->input('assigned_asset'));
|
||||
}
|
||||
|
||||
if ($request->filled('assigned_location')) {
|
||||
return Location::withoutGlobalScopes()->find($request->input('assigned_location'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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)
|
||||
) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a given asset (mark as deleted).
|
||||
*
|
||||
@@ -905,6 +981,7 @@ class AssetsController extends Controller
|
||||
*/
|
||||
public function checkoutByTag(AssetCheckoutRequest $request, $tag): JsonResponse
|
||||
{
|
||||
// Use the same hardened checkout path as ID-based checkout.
|
||||
if ($asset = Asset::where('asset_tag', $tag)->first()) {
|
||||
return $this->checkout($request, $asset->id);
|
||||
}
|
||||
@@ -940,19 +1017,22 @@ class AssetsController extends Controller
|
||||
|
||||
// This item is checked out to a location
|
||||
if (request('checkout_to_type') == 'location') {
|
||||
$target = Location::find(request('assigned_location'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = Location::withoutGlobalScopes()->find(request('assigned_location'));
|
||||
$asset->location_id = ($target) ? $target->id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_location');
|
||||
$error_payload['target_type'] = 'location';
|
||||
} elseif (request('checkout_to_type') == 'asset') {
|
||||
$target = Asset::where('id', '!=', $asset_id)->find(request('assigned_asset'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = Asset::withoutGlobalScopes()->where('id', '!=', $asset_id)->find(request('assigned_asset'));
|
||||
// Override with the asset's location_id if it has one
|
||||
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_asset');
|
||||
$error_payload['target_type'] = 'asset';
|
||||
} elseif (request('checkout_to_type') == 'user') {
|
||||
// Fetch the target and set the asset's new location_id
|
||||
$target = User::find(request('assigned_user'));
|
||||
// Resolve unscoped target first so FMCS mismatch can be handled explicitly.
|
||||
$target = User::withoutGlobalScopes()->find(request('assigned_user'));
|
||||
$asset->location_id = (($target) && (isset($target->location_id))) ? $target->location_id : '';
|
||||
$error_payload['target_id'] = $request->input('assigned_user');
|
||||
$error_payload['target_type'] = 'user';
|
||||
@@ -971,6 +1051,16 @@ class AssetsController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', $error_payload, 'Checkout target for asset '.e($asset->asset_tag).' is invalid - '.$error_payload['target_type'].' does not exist.'));
|
||||
}
|
||||
|
||||
// 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')));
|
||||
}
|
||||
|
||||
$checkout_at = request('checkout_at', date('Y-m-d H:i:s'));
|
||||
$expected_checkin = request('expected_checkin', null);
|
||||
$note = request('note', null);
|
||||
@@ -985,7 +1075,12 @@ class AssetsController extends Controller
|
||||
// $asset->location_id = $target->rtd_location_id;
|
||||
// }
|
||||
|
||||
if ($asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id)) {
|
||||
// Keep checkout mutation + checkout logging/event side effects atomic.
|
||||
$wasCheckedOut = DB::transaction(function () use ($asset, $target, $checkout_at, $expected_checkin, $note, $asset_name): bool {
|
||||
return $asset->checkOut($target, auth()->user(), $checkout_at, $expected_checkin, $note, $asset_name, $asset->location_id);
|
||||
});
|
||||
|
||||
if ($wasCheckedOut) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', ['asset' => e($asset->asset_tag)], trans('admin/hardware/message.checkout.success')));
|
||||
}
|
||||
|
||||
@@ -1067,6 +1162,12 @@ class AssetsController extends Controller
|
||||
});
|
||||
|
||||
if ($asset->save()) {
|
||||
|
||||
// Update the location of any child assets
|
||||
Asset::where('assigned_type', Asset::class)
|
||||
->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $asset->location_id]);
|
||||
|
||||
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', [
|
||||
@@ -1444,7 +1545,7 @@ class AssetsController extends Controller
|
||||
$label = new Label;
|
||||
|
||||
if (! $label) {
|
||||
throw new \Exception('Label object could not be created');
|
||||
throw new \Exception(trans('admin/labels/message.label_not_created'));
|
||||
}
|
||||
|
||||
// Configure label with assets and settings
|
||||
@@ -1465,7 +1566,7 @@ class AssetsController extends Controller
|
||||
|
||||
// Verify PDF was generated successfully
|
||||
if (empty($pdf_content)) {
|
||||
throw new \Exception('PDF content is empty');
|
||||
throw new \Exception(trans('admin/labels/message.use_new_label_engine_for_api'));
|
||||
}
|
||||
|
||||
$encoded_content = base64_encode($pdf_content);
|
||||
|
||||
@@ -9,8 +9,9 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\ComponentsTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\ComponentAssignment;
|
||||
use App\Models\Setting;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -80,7 +81,7 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$components->where('name', '=', $request->input('name'));
|
||||
$components->where('components.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
@@ -92,27 +93,27 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$components->where('category_id', '=', $request->input('category_id'));
|
||||
$components->where('components.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$components->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$components->where('components.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$components->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$components->where('components.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('model_number')) {
|
||||
$components->where('model_number', '=', $request->input('model_number'));
|
||||
$components->where('components.model_number', '=', $request->input('model_number'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$components->where('location_id', '=', $request->input('location_id'));
|
||||
$components->where('components.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$components->where('notes', '=', $request->input('notes'));
|
||||
$components->where('components.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -166,6 +167,7 @@ class ComponentsController extends Controller
|
||||
$this->authorize('create', Component::class);
|
||||
$component = new Component;
|
||||
$component->fill($request->all());
|
||||
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$component = $request->handleImages($component);
|
||||
|
||||
if ($component->save()) {
|
||||
@@ -206,6 +208,7 @@ class ComponentsController extends Controller
|
||||
$this->authorize('update', Component::class);
|
||||
$component = Component::findOrFail($id);
|
||||
$component->fill($request->all());
|
||||
$component->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$component = $request->handleImages($component);
|
||||
|
||||
if ($component->save()) {
|
||||
@@ -252,13 +255,11 @@ class ComponentsController extends Controller
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
|
||||
$component_checkouts = ComponentAssignment::where('component_id', $component->id)->with('adminuser')->with('assets');
|
||||
|
||||
$offset = request('offset', 0);
|
||||
$limit = $request->input('limit', 50);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$assets = $component_checkouts->assets()
|
||||
$assets = $component->assets()
|
||||
->where(function ($query) use ($request) {
|
||||
$search_str = '%'.$request->input('search').'%';
|
||||
$query->where('name', 'like', $search_str)
|
||||
@@ -314,20 +315,33 @@ class ComponentsController extends Controller
|
||||
}
|
||||
|
||||
if ($component->numRemaining() >= $request->input('assigned_qty')) {
|
||||
// Resolve the raw target first, then enforce FMCS explicitly.
|
||||
// Scoped lookup can hide cross-company records and lead to partial writes.
|
||||
$asset = Asset::withoutGlobalScopes()->find($request->input('assigned_to'));
|
||||
|
||||
$asset = Asset::find($request->input('assigned_to'));
|
||||
$component->assigned_to = $request->input('assigned_to');
|
||||
if (! $asset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')));
|
||||
}
|
||||
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_qty' => $request->input('assigned_qty', 1),
|
||||
'created_by' => auth()->id(),
|
||||
'asset_id' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($component->company_id !== $asset->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
|
||||
// Keep pivot + action log in one transaction so checkout is all-or-nothing.
|
||||
DB::transaction(function () use ($component, $request, $asset): void {
|
||||
$component->assigned_to = $request->input('assigned_to');
|
||||
|
||||
$component->assets()->attach($component->id, [
|
||||
'component_id' => $component->id,
|
||||
'created_at' => Carbon::now(),
|
||||
'assigned_qty' => $request->input('assigned_qty', 1),
|
||||
'created_by' => auth()->id(),
|
||||
'asset_id' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
|
||||
$component->logCheckout($request->input('note'), $asset, null, [], $request->get('assigned_qty', 1));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/components/message.checkout.success')));
|
||||
}
|
||||
|
||||
@@ -13,9 +13,11 @@ use App\Http\Transformers\ConsumablesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class ConsumablesController extends Controller
|
||||
{
|
||||
@@ -67,7 +69,7 @@ class ConsumablesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$consumables->where('name', '=', $request->input('name'));
|
||||
$consumables->where('consumables.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
@@ -79,27 +81,27 @@ class ConsumablesController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('category_id')) {
|
||||
$consumables->where('category_id', '=', $request->input('category_id'));
|
||||
$consumables->where('consumables.category_id', '=', $request->input('category_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('model_number')) {
|
||||
$consumables->where('model_number', '=', $request->input('model_number'));
|
||||
$consumables->where('consumables.model_number', '=', $request->input('model_number'));
|
||||
}
|
||||
|
||||
if ($request->filled('manufacturer_id')) {
|
||||
$consumables->where('manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
$consumables->where('consumables.manufacturer_id', '=', $request->input('manufacturer_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('supplier_id')) {
|
||||
$consumables->where('supplier_id', '=', $request->input('supplier_id'));
|
||||
$consumables->where('consumables.supplier_id', '=', $request->input('supplier_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$consumables->where('location_id', '=', $request->input('location_id'));
|
||||
$consumables->where('consumables.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$consumables->where('notes', '=', $request->input('notes'));
|
||||
$consumables->where('consumables.notes', '=', $request->input('notes'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -155,6 +157,7 @@ class ConsumablesController extends Controller
|
||||
$this->authorize('create', Consumable::class);
|
||||
$consumable = new Consumable;
|
||||
$consumable->fill($request->all());
|
||||
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$consumable = $request->handleImages($consumable);
|
||||
|
||||
if ($consumable->save()) {
|
||||
@@ -194,6 +197,7 @@ class ConsumablesController extends Controller
|
||||
$this->authorize('update', Consumable::class);
|
||||
$consumable = Consumable::findOrFail($id);
|
||||
$consumable->fill($request->all());
|
||||
$consumable->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$consumable = $request->handleImages($consumable);
|
||||
|
||||
if ($consumable->save()) {
|
||||
@@ -304,34 +308,42 @@ class ConsumablesController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/consumables/message.checkout.unavailable', ['requested' => $consumable->checkout_qty, 'remaining' => $consumable->numRemaining()])));
|
||||
}
|
||||
|
||||
// Check if the user exists - @TODO: this should probably be handled via validation, not here??
|
||||
if (! $user = User::find($request->input('assigned_to'))) {
|
||||
// Resolve the raw target first, then enforce FMCS explicitly.
|
||||
// Scoped lookup can hide cross-company users and make failures ambiguous.
|
||||
if (! $user = User::withoutGlobalScopes()->find($request->input('assigned_to'))) {
|
||||
// Return error message
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'No user found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($consumable->company_id !== $user->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
|
||||
// Update the consumable data
|
||||
$consumable->assigned_to = $request->input('assigned_to');
|
||||
|
||||
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
|
||||
$consumable->users()->attach($consumable->id,
|
||||
[
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]
|
||||
);
|
||||
}
|
||||
// Keep pivot writes and checkout log/event atomic to avoid partial checkout state.
|
||||
DB::transaction(function () use ($consumable, $request, $user): void {
|
||||
for ($i = 0; $i < $consumable->checkout_qty; $i++) {
|
||||
$consumable->users()->attach($consumable->id,
|
||||
[
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $user->id,
|
||||
'assigned_to' => $request->input('assigned_to'),
|
||||
'note' => $request->input('note'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
event(new CheckoutableCheckedOut(
|
||||
$consumable,
|
||||
$user,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
));
|
||||
event(new CheckoutableCheckedOut(
|
||||
$consumable,
|
||||
$user,
|
||||
auth()->user(),
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
));
|
||||
});
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/consumables/message.checkout.success')));
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreDepartmentRequest;
|
||||
use App\Http\Transformers\DepartmentsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\Department;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -50,23 +51,23 @@ class DepartmentsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
$departments->where('name', '=', $request->input('name'));
|
||||
$departments->where('departments.name', '=', $request->input('name'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$departments->where('company_id', '=', $request->input('company_id'));
|
||||
$departments->where('departments.company_id', '=', $request->input('company_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('manager_id')) {
|
||||
$departments->where('manager_id', '=', $request->input('manager_id'));
|
||||
$departments->where('departments.manager_id', '=', $request->input('manager_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('location_id')) {
|
||||
$departments->where('location_id', '=', $request->input('location_id'));
|
||||
$departments->where('departments.location_id', '=', $request->input('location_id'));
|
||||
}
|
||||
|
||||
if ($request->filled('tag_color')) {
|
||||
$departments->where('tag_color', '=', $request->input('departments.tag_color'));
|
||||
$departments->where('departments.tag_color', '=', $request->input('tag_color'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
@@ -111,6 +112,7 @@ class DepartmentsController extends Controller
|
||||
{
|
||||
$department = new Department;
|
||||
$department->fill($request->validated());
|
||||
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$department = $request->handleImages($department);
|
||||
|
||||
$department->created_by = auth()->id();
|
||||
@@ -155,6 +157,7 @@ class DepartmentsController extends Controller
|
||||
$this->authorize('update', Department::class);
|
||||
$department = Department::findOrFail($id);
|
||||
$department->fill($request->all());
|
||||
$department->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$department = $request->handleImages($department);
|
||||
|
||||
if ($department->save()) {
|
||||
|
||||
@@ -8,9 +8,11 @@ use App\Http\Transformers\LicenseSeatsTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class LicenseSeatsController extends Controller
|
||||
{
|
||||
@@ -106,7 +108,8 @@ class LicenseSeatsController extends Controller
|
||||
'prohibits:asset_id',
|
||||
// must be a valid user or null to unassign
|
||||
function ($attribute, $value, $fail) {
|
||||
if (! is_null($value) && ! User::where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
// Validate existence without company scopes; FMCS checks happen explicitly below.
|
||||
if (! is_null($value) && ! User::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
$fail('The selected assigned_to is invalid.');
|
||||
}
|
||||
},
|
||||
@@ -118,7 +121,8 @@ class LicenseSeatsController extends Controller
|
||||
'prohibits:assigned_to',
|
||||
// must be a valid asset or null to unassign
|
||||
function ($attribute, $value, $fail) {
|
||||
if (! is_null($value) && ! Asset::where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
// Validate existence without company scopes; FMCS checks happen explicitly below.
|
||||
if (! is_null($value) && ! Asset::withoutGlobalScopes()->where('id', $value)->whereNull('deleted_at')->exists()) {
|
||||
$fail('The selected asset_id is invalid.');
|
||||
}
|
||||
},
|
||||
@@ -139,6 +143,34 @@ class LicenseSeatsController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Seat does not belong to the specified license'));
|
||||
}
|
||||
|
||||
$targetUser = null;
|
||||
if (! is_null($request->input('assigned_to'))) {
|
||||
// Resolve unscoped target so we can return a clean cross-company error instead of a hidden-not-found.
|
||||
$targetUser = User::withoutGlobalScopes()->find($request->input('assigned_to'));
|
||||
|
||||
if (! $targetUser) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetUser->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
}
|
||||
|
||||
$targetAsset = null;
|
||||
if (! is_null($request->input('asset_id'))) {
|
||||
// Resolve unscoped target so FMCS company mismatch can be enforced explicitly.
|
||||
$targetAsset = Asset::withoutGlobalScopes()->find($request->input('asset_id'));
|
||||
|
||||
if (! $targetAsset) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'Target not found'));
|
||||
}
|
||||
|
||||
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($license->company_id !== $targetAsset->company_id)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.error_user_company')));
|
||||
}
|
||||
}
|
||||
|
||||
$oldUser = $licenseSeat->user;
|
||||
$oldAsset = $licenseSeat->asset;
|
||||
|
||||
@@ -166,11 +198,11 @@ class LicenseSeatsController extends Controller
|
||||
// the logging functions expect only one "target". if both asset and user are present in the request,
|
||||
// we simply let assets take precedence over users...
|
||||
if ($licenseSeat->isDirty('assigned_to')) {
|
||||
$target = $is_checkin ? $oldUser : User::find($licenseSeat->assigned_to);
|
||||
$target = $is_checkin ? $oldUser : $targetUser;
|
||||
}
|
||||
|
||||
if ($licenseSeat->isDirty('asset_id')) {
|
||||
$target = $is_checkin ? $oldAsset : Asset::find($licenseSeat->asset_id);
|
||||
$target = $is_checkin ? $oldAsset : $targetAsset;
|
||||
}
|
||||
|
||||
if ($assignmentTouched && is_null($target)) {
|
||||
@@ -181,13 +213,22 @@ class LicenseSeatsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($licenseSeat->save()) {
|
||||
// Keep seat updates and checkout/checkin logging atomic to prevent partial state changes.
|
||||
$updated = DB::transaction(function () use ($licenseSeat, $assignmentTouched, $is_checkin, $target, $request): bool {
|
||||
if (! $licenseSeat->save()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($assignmentTouched) {
|
||||
if ($is_checkin) {
|
||||
if (! $licenseSeat->license->reassignable) {
|
||||
$licenseSeat->unreassignable_seat = true;
|
||||
$licenseSeat->save();
|
||||
|
||||
if (! $licenseSeat->save()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// todo: skip if target is null?
|
||||
$licenseSeat->logCheckin($target, $licenseSeat->notes);
|
||||
} else {
|
||||
@@ -196,6 +237,10 @@ class LicenseSeatsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if ($updated) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $licenseSeat, trans('admin/licenses/message.update.success')));
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\LicensesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
use App\Models\License;
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -27,7 +28,7 @@ class LicensesController extends Controller
|
||||
{
|
||||
$this->authorize('view', License::class);
|
||||
|
||||
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser')->withCount('freeSeats as free_seats_count');
|
||||
$licenses = License::with('company', 'manufacturer', 'supplier', 'category', 'adminuser', 'licenseSeatsRelation', 'assignedCount')->withCount('freeSeats as free_seats_count');
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
if ($request->input('status') == 'inactive') {
|
||||
@@ -179,6 +180,7 @@ class LicensesController extends Controller
|
||||
$this->authorize('create', License::class);
|
||||
$license = new License;
|
||||
$license->fill($request->all());
|
||||
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
if ($license->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.create.success')));
|
||||
@@ -219,6 +221,7 @@ class LicensesController extends Controller
|
||||
|
||||
$license = License::findOrFail($id);
|
||||
$license->fill($request->all());
|
||||
$license->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
|
||||
if ($license->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', $license, trans('admin/licenses/message.update.success')));
|
||||
@@ -244,7 +247,7 @@ class LicensesController extends Controller
|
||||
if ($license->assigned_seats_count == 0) {
|
||||
// Delete the license and the associated license seats
|
||||
DB::table('license_seats')
|
||||
->where('id', $license->id)
|
||||
->where('license_id', $license->id)
|
||||
->update(['assigned_to' => null, 'asset_id' => null]);
|
||||
|
||||
$licenseSeats = $license->licenseseats();
|
||||
|
||||
@@ -257,9 +257,7 @@ class MaintenancesController extends Controller
|
||||
|
||||
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$asset = $maintenance->asset;
|
||||
$this->authorize('history', $asset);
|
||||
$this->authorize('history', $maintenance);
|
||||
$historyQuery = $maintenance->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
|
||||
@@ -6,8 +6,18 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ReportsController extends Controller
|
||||
@@ -125,4 +135,141 @@ class ReportsController extends Controller
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns time-series data for the reports overview charts.
|
||||
*
|
||||
* Accepts ?days=N (preset, default 30) OR ?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD.
|
||||
* Also returns the immediately preceding period of equal length for comparison lines.
|
||||
*/
|
||||
public function activityChart(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
|
||||
$allowedDays = [7, 14, 30, 60, 90, 180, 365];
|
||||
|
||||
if ($request->filled('start_date') && $request->filled('end_date')) {
|
||||
$curStart = Carbon::parse($request->input('start_date'))->startOfDay();
|
||||
$curEnd = Carbon::parse($request->input('end_date'))->endOfDay();
|
||||
if ($curEnd->lt($curStart)) {
|
||||
[$curStart, $curEnd] = [$curEnd, $curStart];
|
||||
}
|
||||
$days = max(1, (int) $curStart->diffInDays($curEnd) + 1);
|
||||
} else {
|
||||
$days = in_array((int) $request->input('days'), $allowedDays) ? (int) $request->input('days') : 30;
|
||||
$curEnd = Carbon::today()->endOfDay();
|
||||
$curStart = Carbon::today()->subDays($days - 1)->startOfDay();
|
||||
}
|
||||
|
||||
$prevEnd = $curStart->copy()->subSecond()->endOfDay();
|
||||
$prevStart = $prevEnd->copy()->subDays($days - 1)->startOfDay();
|
||||
|
||||
$buildDates = function (Carbon $start, Carbon $end): array {
|
||||
$dates = [];
|
||||
for ($d = $start->copy(); $d->lte($end); $d->addDay()) {
|
||||
$dates[] = $d->toDateString();
|
||||
}
|
||||
|
||||
return $dates;
|
||||
};
|
||||
|
||||
$curDates = $buildDates($curStart, $curEnd);
|
||||
$prevDates = $buildDates($prevStart, $prevEnd);
|
||||
|
||||
$pluckAction = function (string $actionType, Carbon $start, Carbon $end): array {
|
||||
return Actionlog::where('action_type', $actionType)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
// withTrashed() ensures records deleted after creation still appear in their creation-period counts.
|
||||
$pluckCreated = function (string $modelClass, Carbon $start, Carbon $end): array {
|
||||
return $modelClass::withTrashed()
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
// Maintenance has no company_id column and no CompanyableTrait, so scope through
|
||||
// its asset relationship — whereHas('asset') applies Asset's FMCS global scope.
|
||||
$pluckMaintenances = function (Carbon $start, Carbon $end): array {
|
||||
return Maintenance::withTrashed()
|
||||
->whereHas('asset')
|
||||
->whereBetween('maintenances.created_at', [$start, $end])
|
||||
->selectRaw('DATE(maintenances.created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
// Filters by both action_type and item_type for per-category checkout/checkin counts.
|
||||
$pluckActionByType = function (string $actionType, string $modelClass, Carbon $start, Carbon $end): array {
|
||||
return Actionlog::where('action_type', $actionType)
|
||||
->where('item_type', $modelClass)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
$pluckDeletedUsers = function (Carbon $start, Carbon $end): array {
|
||||
return User::withTrashed()
|
||||
->whereNotNull('deleted_at')
|
||||
->whereBetween('deleted_at', [$start, $end])
|
||||
->selectRaw('DATE(deleted_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
// Catches both 'checkin' and 'checkin from' action types used across different item types.
|
||||
$pluckCheckinsByType = function (string $modelClass, Carbon $start, Carbon $end): array {
|
||||
return Actionlog::whereIn('action_type', ['checkin', 'checkin from'])
|
||||
->where('item_type', $modelClass)
|
||||
->whereBetween('created_at', [$start, $end])
|
||||
->selectRaw('DATE(created_at) as date, COUNT(*) as count')
|
||||
->groupBy('date')
|
||||
->pluck('count', 'date')
|
||||
->toArray();
|
||||
};
|
||||
|
||||
$fill = fn (array $raw, array $dates) => array_map(fn ($d) => (int) ($raw[$d] ?? 0), $dates);
|
||||
|
||||
$datasets = [];
|
||||
foreach ([
|
||||
'new_users' => fn ($s, $e) => $pluckCreated(User::class, $s, $e),
|
||||
'deleted_users' => fn ($s, $e) => $pluckDeletedUsers($s, $e),
|
||||
'asset_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Asset::class, $s, $e),
|
||||
'asset_checkins' => fn ($s, $e) => $pluckCheckinsByType(Asset::class, $s, $e),
|
||||
'new_assets' => fn ($s, $e) => $pluckCreated(Asset::class, $s, $e),
|
||||
'new_maintenances' => fn ($s, $e) => $pluckMaintenances($s, $e),
|
||||
'new_audits' => fn ($s, $e) => $pluckAction('audit', $s, $e),
|
||||
'component_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Component::class, $s, $e),
|
||||
'component_checkins' => fn ($s, $e) => $pluckCheckinsByType(Component::class, $s, $e),
|
||||
'new_components' => fn ($s, $e) => $pluckCreated(Component::class, $s, $e),
|
||||
'consumable_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Consumable::class, $s, $e),
|
||||
'consumable_checkins' => fn ($s, $e) => $pluckCheckinsByType(Consumable::class, $s, $e),
|
||||
'new_consumables' => fn ($s, $e) => $pluckCreated(Consumable::class, $s, $e),
|
||||
'license_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', LicenseSeat::class, $s, $e),
|
||||
'license_checkins' => fn ($s, $e) => $pluckCheckinsByType(LicenseSeat::class, $s, $e),
|
||||
'new_licenses' => fn ($s, $e) => $pluckCreated(License::class, $s, $e),
|
||||
'accessory_checkouts' => fn ($s, $e) => $pluckActionByType('checkout', Accessory::class, $s, $e),
|
||||
'accessory_checkins' => fn ($s, $e) => $pluckCheckinsByType(Accessory::class, $s, $e),
|
||||
'new_accessories' => fn ($s, $e) => $pluckCreated(Accessory::class, $s, $e),
|
||||
] as $key => $query) {
|
||||
$datasets[$key] = $fill($query($curStart, $curEnd), $curDates);
|
||||
$datasets['prev_'.$key] = $fill($query($prevStart, $prevEnd), $prevDates);
|
||||
}
|
||||
|
||||
return response()->json(array_merge([
|
||||
'labels' => array_map(fn ($d) => Carbon::parse($d)->format('M j'), $curDates),
|
||||
'prev_label' => $prevStart->format('M j').' – '.$prevEnd->format('M j'),
|
||||
], $datasets));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,6 +175,10 @@ class AssetCheckinController extends Controller
|
||||
$asset->customFieldsForCheckinCheckout('display_checkin');
|
||||
|
||||
if ($asset->save()) {
|
||||
// Update the location of any child assets
|
||||
Asset::where('assigned_type', Asset::class)
|
||||
->where('assigned_to', $asset->id)
|
||||
->update(['location_id' => $asset->location_id]);
|
||||
|
||||
event(new CheckoutableCheckedIn($asset, $target, auth()->user(), $request->input('note'), $checkin_at, $originalValues));
|
||||
|
||||
|
||||
@@ -66,7 +66,8 @@ class AssetsController extends Controller
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$this->authorize('index', Asset::class);
|
||||
$company = Company::find($request->input('company_id'));
|
||||
$companyId = $request->input('company_id');
|
||||
$company = is_scalar($companyId) ? Company::find($companyId) : null;
|
||||
|
||||
return view('hardware/index')->with('company', $company);
|
||||
}
|
||||
|
||||
@@ -106,15 +106,21 @@ class LoginController extends Controller
|
||||
if ($saml->isEnabled() && ! empty($samlData)) {
|
||||
|
||||
try {
|
||||
|
||||
$user = $saml->samlLogin($samlData);
|
||||
$notValidAfter = new \Carbon\Carbon(@$samlData['assertionNotOnOrAfter']);
|
||||
if (\Carbon::now()->greaterThanOrEqualTo($notValidAfter)) {
|
||||
abort(400, 'Expired SAML Assertion');
|
||||
}
|
||||
if (SamlNonce::where('nonce', @$samlData['nonce'])->count() > 0) {
|
||||
abort(400, 'Assertion has already been used');
|
||||
try {
|
||||
SamlNonce::create([
|
||||
'nonce' => $samlData['nonce'],
|
||||
'not_valid_after' => $notValidAfter,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
\Log::error($e);
|
||||
abort(400, 'Assertion has already been used.');
|
||||
}
|
||||
Log::debug('okay, fine, this is a new nonce then. Good for you.');
|
||||
if (! is_null($user)) {
|
||||
Auth::login($user);
|
||||
} else {
|
||||
@@ -128,10 +134,6 @@ class LoginController extends Controller
|
||||
$user->last_login = \Carbon::now();
|
||||
$user->saveQuietly();
|
||||
}
|
||||
$s = new SamlNonce;
|
||||
$s->nonce = @$samlData['nonce'];
|
||||
$s->not_valid_after = $notValidAfter;
|
||||
$s->save();
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::debug('There was an error authenticating the SAML user: '.$e->getMessage());
|
||||
@@ -433,7 +435,7 @@ class LoginController extends Controller
|
||||
$user->saveQuietly();
|
||||
$request->session()->put('2fa_authed', $user->id);
|
||||
|
||||
return redirect()->route('home')->with('success', trans('auth/message.signin.success'));
|
||||
return redirect()->intended()->with('success', trans('auth/message.signin.success'));
|
||||
}
|
||||
|
||||
return redirect()->route('two-factor')->with('error', trans('auth/message.two_factor.invalid_code'));
|
||||
|
||||
@@ -74,8 +74,7 @@ class SamlController extends Controller
|
||||
public function login(Request $request)
|
||||
{
|
||||
$auth = $this->saml->getAuth();
|
||||
$ssoUrl = $auth->login(null, [], false, false, false, false);
|
||||
|
||||
$ssoUrl = $auth->login(session()->get('url.intended'), [], false, false, false, false);
|
||||
return redirect()->away($ssoUrl);
|
||||
}
|
||||
|
||||
@@ -96,6 +95,7 @@ class SamlController extends Controller
|
||||
$saml = $this->saml;
|
||||
$auth = $saml->getAuth();
|
||||
$saml_exception = false;
|
||||
session()->put('url.intended', $request->post('RelayState'));
|
||||
try {
|
||||
$auth->processResponse();
|
||||
} catch (\Exception $e) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Licenses;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\License;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class BulkLicensesController extends Controller
|
||||
{
|
||||
public function destroy(Request $request)
|
||||
{
|
||||
$this->authorize('delete', License::class);
|
||||
|
||||
$errors = [];
|
||||
$success_count = 0;
|
||||
|
||||
foreach ($request->ids as $id) {
|
||||
$license = License::find($id);
|
||||
|
||||
if (is_null($license)) {
|
||||
$errors[] = trans('admin/licenses/message.does_not_exist');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! Gate::allows('delete', $license)) {
|
||||
$errors[] = trans('general.insufficient_permissions');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($license->assigned_seats_count > 0) {
|
||||
$errors[] = trans('admin/licenses/message.delete.bulk_checkout_warning', ['license_name' => $license->name]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Since assigned_seats_count == 0, all seats already have assigned_to and asset_id as null,
|
||||
// so this update is effectively a no-op. It mirrors the single destroy() and is kept as a
|
||||
// safety net. Bypassing Eloquent events here is intentional and safe — there is nothing
|
||||
// assigned to trigger events on. Prior checkout/checkin history is preserved in action_log
|
||||
// (keyed by LicenseSeat item_type/item_id) and remains accessible even after soft-delete.
|
||||
DB::table('license_seats')
|
||||
->where('license_id', $license->id)
|
||||
->update(['assigned_to' => null, 'asset_id' => null]);
|
||||
|
||||
$license->licenseseats()->delete();
|
||||
$license->delete();
|
||||
$success_count++;
|
||||
}
|
||||
|
||||
if (count($errors) > 0) {
|
||||
if ($success_count > 0) {
|
||||
return redirect()->route('licenses.index')
|
||||
->with('success', trans_choice('admin/licenses/message.delete.partial_success', $success_count, ['count' => $success_count]))
|
||||
->with('multi_error_messages', $errors);
|
||||
}
|
||||
|
||||
return redirect()->route('licenses.index')->with('multi_error_messages', $errors);
|
||||
}
|
||||
|
||||
return redirect()->route('licenses.index')->with('success', trans('admin/licenses/message.delete.bulk_success'));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use League\Csv\EscapeFormula;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -388,6 +389,8 @@ class LicensesController extends Controller
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($licenses as $license) {
|
||||
// Add a new row with data
|
||||
$values = [
|
||||
@@ -419,7 +422,14 @@ class LicensesController extends Controller
|
||||
$license->created_at,
|
||||
];
|
||||
|
||||
fputcsv($handle, $values);
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $values);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($values));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -3,12 +3,15 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\UploadFileRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Maintenance;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
/**
|
||||
* This controller handles all actions related to Asset Maintenance for
|
||||
@@ -72,6 +75,7 @@ class MaintenancesController extends Controller
|
||||
public function store(ImageUploadRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', Asset::class);
|
||||
$this->validateUploadedFiles($request);
|
||||
|
||||
$assets = Asset::whereIn('id', $request->input('selected_assets'))->get();
|
||||
|
||||
@@ -102,12 +106,14 @@ class MaintenancesController extends Controller
|
||||
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
|
||||
}
|
||||
|
||||
$maintenance = $request->handleImages($maintenance);
|
||||
$request->handleImages($maintenance);
|
||||
|
||||
// Was the asset maintenance created?
|
||||
if (! $maintenance->save()) {
|
||||
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
|
||||
}
|
||||
|
||||
$this->storeUploadedFiles($request, $maintenance);
|
||||
}
|
||||
|
||||
return redirect()->route('maintenances.index')
|
||||
@@ -156,6 +162,7 @@ class MaintenancesController extends Controller
|
||||
{
|
||||
$this->authorize('update', Asset::class);
|
||||
$this->authorize('update', $maintenance->asset);
|
||||
$this->validateUploadedFiles($request);
|
||||
|
||||
$maintenance->supplier_id = $request->input('supplier_id');
|
||||
$maintenance->is_warranty = $request->input('is_warranty', 0);
|
||||
@@ -184,9 +191,11 @@ class MaintenancesController extends Controller
|
||||
$completionDate = Carbon::parse($maintenance->completion_date);
|
||||
$maintenance->asset_maintenance_time = (int) $completionDate->diffInDays($startDate, true);
|
||||
}
|
||||
$maintenance = $request->handleImages($maintenance);
|
||||
$request->handleImages($maintenance);
|
||||
|
||||
if ($maintenance->save()) {
|
||||
$this->storeUploadedFiles($request, $maintenance);
|
||||
|
||||
return redirect()->route('maintenances.index')
|
||||
->with('success', trans('admin/maintenances/message.edit.success'));
|
||||
}
|
||||
@@ -194,6 +203,56 @@ class MaintenancesController extends Controller
|
||||
return redirect()->back()->withInput()->withErrors($maintenance->getErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores any generic file uploads submitted from the maintenance form.
|
||||
*/
|
||||
private function storeUploadedFiles(ImageUploadRequest $request, Maintenance $maintenance): void
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$objectType = 'maintenances';
|
||||
$storagePath = self::$map_storage_path[$objectType];
|
||||
|
||||
if (! Storage::exists($storagePath)) {
|
||||
Storage::makeDirectory($storagePath, 775);
|
||||
}
|
||||
|
||||
$uploadFileRequest = app(UploadFileRequest::class);
|
||||
|
||||
foreach ((array) $request->file('file') as $file) {
|
||||
if (! $file) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fileName = $uploadFileRequest->handleFile(
|
||||
$storagePath,
|
||||
self::$map_file_prefix[$objectType].'-'.$maintenance->id,
|
||||
$file
|
||||
);
|
||||
|
||||
$maintenance->logUpload($fileName, $request->input('file_notes'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate generic file uploads with the shared UploadFileRequest rules.
|
||||
*/
|
||||
private function validateUploadedFiles(ImageUploadRequest $request): void
|
||||
{
|
||||
if (! $request->hasFile('file')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$uploadFileRequest = app(UploadFileRequest::class);
|
||||
|
||||
Validator::make(
|
||||
array_merge($request->all(), ['file' => $request->file('file')]),
|
||||
$uploadFileRequest->rules()
|
||||
)->validate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an asset maintenance
|
||||
*
|
||||
|
||||
@@ -211,14 +211,19 @@ class ProfileController extends Controller
|
||||
*/
|
||||
public function printInventory(): View
|
||||
{
|
||||
$show_users = User::where('id', auth()->user()->id)->get();
|
||||
$userId = auth()->id();
|
||||
|
||||
return view('users/print')
|
||||
->with('assets', auth()->user()->assets())
|
||||
->with('licenses', auth()->user()->licenses()->get())
|
||||
->with('accessories', auth()->user()->accessories()->get())
|
||||
->with('consumables', auth()->user()->consumables()->get())
|
||||
->with('users', $show_users)
|
||||
$show_user = User::withInventoryRelations($userId)->first();
|
||||
|
||||
$indirectItemsCount =
|
||||
$show_user->assets->flatMap->assignedAssets->count()
|
||||
+ $show_user->assets->flatMap->components->count()
|
||||
+ $show_user->assets->flatMap->licenses->count()
|
||||
+ $show_user->assets->flatMap->assignedAccessories->count();
|
||||
|
||||
return view('users.print')
|
||||
->with('users', [$show_user])
|
||||
->with('indirectItemsCount', $indirectItemsCount)
|
||||
->with('settings', Setting::getSettings());
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,31 @@ class ReportsController extends Controller
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function index(): View
|
||||
{
|
||||
$this->authorize('reports.view');
|
||||
$settings = Setting::getSettings();
|
||||
|
||||
$audit_alert_count = Asset::DueOrOverdueForAudit($settings)->count();
|
||||
$checkin_alert_count = Asset::DueOrOverdueForCheckin($settings)->count();
|
||||
// CheckoutAcceptance has no company_id column; scope through the checkoutable
|
||||
// relationship so each type's CompanyableTrait global scope is applied.
|
||||
$pending_acceptance_count = CheckoutAcceptance::pending()
|
||||
->whereHasMorph('checkoutable', [Asset::class, LicenseSeat::class, Accessory::class, Component::class, Consumable::class])
|
||||
->count();
|
||||
$licenses_low_count = License::withCount(['freeSeats as free_seats_count'])
|
||||
->get()
|
||||
->filter(fn ($l) => $l->free_seats_count <= 0)
|
||||
->count();
|
||||
|
||||
return view('reports/index', compact(
|
||||
'audit_alert_count',
|
||||
'checkin_alert_count',
|
||||
'pending_acceptance_count',
|
||||
'licenses_low_count',
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a view that displays the accessories report.
|
||||
*
|
||||
@@ -252,6 +277,7 @@ class ReportsController extends Controller
|
||||
|
||||
$response = new StreamedResponse(function () {
|
||||
Log::debug('Starting streamed response');
|
||||
Log::debug('CSV escaping is set to: '.config('app.escape_formulas'));
|
||||
|
||||
// Open output stream
|
||||
$handle = fopen('php://output', 'w');
|
||||
@@ -287,6 +313,8 @@ class ReportsController extends Controller
|
||||
Log::debug('Walking results: '.$executionTime);
|
||||
$count = 0;
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($actionlogs as $actionlog) {
|
||||
$count++;
|
||||
$target_name = '';
|
||||
@@ -317,7 +345,15 @@ class ReportsController extends Controller
|
||||
$actionlog->action_source,
|
||||
$actionlog->log_meta,
|
||||
];
|
||||
fputcsv($handle, $row);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $row);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($row));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -798,6 +834,14 @@ class ReportsController extends Controller
|
||||
$assets->onlyTrashed();
|
||||
}
|
||||
|
||||
if ($request->input('assignment_status') === 'assigned') {
|
||||
$assets->whereNotNull('assets.assigned_to');
|
||||
}
|
||||
|
||||
if ($request->input('assignment_status') === 'unassigned') {
|
||||
$assets->whereNull('assets.assigned_to');
|
||||
}
|
||||
|
||||
$assets->orderBy('assets.id', 'ASC')->chunk(500, function ($assets) use ($handle, $customfields, $request) {
|
||||
|
||||
$executionTime = microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'];
|
||||
@@ -844,7 +888,7 @@ class ReportsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('purchase_date')) {
|
||||
$row[] = ($asset->purchase_date) ? $asset->purchase_date : '';
|
||||
$row[] = ($asset->purchase_date) ? Carbon::parse($asset->purchase_date)->format('Y-m-d') : '';
|
||||
}
|
||||
|
||||
if ($request->filled('purchase_cost')) {
|
||||
@@ -852,7 +896,7 @@ class ReportsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('eol')) {
|
||||
$row[] = ($asset->asset_eol_date != '') ? $asset->asset_eol_date : '';
|
||||
$row[] = ($asset->asset_eol_date != '') ? Carbon::parse($asset->asset_eol_date)->format('Y-m-d') : '';
|
||||
}
|
||||
|
||||
if ($request->filled('warranty')) {
|
||||
@@ -918,7 +962,7 @@ class ReportsController extends Controller
|
||||
|
||||
if ($request->filled('user_company')) {
|
||||
if ($asset->checkedOutToUser()) {
|
||||
$row[] = ($asset->assignedto?->company) ? $asset->assignedto->company->display_name : '';
|
||||
$row[] = ($asset->assignedto?->company) ? $asset->assignedto?->company?->display_name : '';
|
||||
} else {
|
||||
$row[] = ''; // Empty string if unassigned
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Password;
|
||||
use League\Csv\EscapeFormula;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
/**
|
||||
@@ -572,6 +573,8 @@ class UsersController extends Controller
|
||||
|
||||
fputcsv($handle, $headers);
|
||||
|
||||
$formatter = new EscapeFormula('`');
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user_groups = '';
|
||||
|
||||
@@ -614,7 +617,14 @@ class UsersController extends Controller
|
||||
$user->created_at,
|
||||
];
|
||||
|
||||
fputcsv($handle, $values);
|
||||
// CSV_ESCAPE_FORMULAS is set to false in the .env
|
||||
if (config('app.escape_formulas') === false) {
|
||||
fputcsv($handle, $values);
|
||||
|
||||
// CSV_ESCAPE_FORMULAS is set to true or is not set in the .env
|
||||
} else {
|
||||
fputcsv($handle, $formatter->escapeRecord($values));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -639,32 +649,16 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('view', User::class);
|
||||
|
||||
$user = User::where('id', $id)
|
||||
->with([
|
||||
'assets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'assets.assignedAssets.defaultLoc',
|
||||
'assets.assignedAssets.location',
|
||||
'assets.assignedAssets.model.category',
|
||||
'assets.defaultLoc',
|
||||
'assets.location',
|
||||
'assets.model.category',
|
||||
'accessories.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'accessories.category',
|
||||
'accessories.manufacturer',
|
||||
'consumables.log' => fn ($query) => $query->withTrashed()->where('target_type', User::class)->where('target_id', $id)->where('action_type', 'accepted'),
|
||||
'consumables.category',
|
||||
'consumables.manufacturer',
|
||||
'licenses.category',
|
||||
])
|
||||
->withTrashed()
|
||||
->first();
|
||||
$user = User::withInventoryRelations($id)->first();
|
||||
|
||||
$indirectItemsCount = $user?->assets?->flatMap->assignedAssets->count() + $user?->assets?->flatMap->components->count() + $user?->assets?->flatMap->licenses->count() + $user?->assets?->flatMap->assignedAccessories->count();
|
||||
|
||||
if ($user) {
|
||||
$this->authorize('view', $user);
|
||||
|
||||
return view('users.print')
|
||||
->with('users', [$user])
|
||||
->with('indirectItemsCount', $indirectItemsCount)
|
||||
->with('settings', Setting::getSettings());
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class CheckForTwoFactor
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
redirect()->setIntendedUrl(url()->full()); // save the 'current' URL so we can send the user back to it?
|
||||
// Otherwise make sure they're enrolled and show them the 2FA code screen
|
||||
if ((auth()->user()->two_factor_secret != '') && (auth()->user()->two_factor_enrolled == '1')) {
|
||||
return redirect()->route('two-factor')->with('info', trans('auth/message.two_factor.enter_two_factor_code'));
|
||||
|
||||
@@ -29,6 +29,7 @@ class CustomAssetReportRequest extends Request
|
||||
public function rules()
|
||||
{
|
||||
return [
|
||||
'assignment_status' => 'nullable|in:all,assigned,unassigned',
|
||||
'purchase_start' => 'date|date_format:Y-m-d|nullable',
|
||||
'purchase_end' => 'date|date_format:Y-m-d|nullable',
|
||||
'purchase_cost_end' => 'numeric|nullable|gte:purchase_cost_start',
|
||||
|
||||
@@ -75,7 +75,10 @@ class LicensesTransformer
|
||||
'checkin' => Gate::allows('checkin', License::class),
|
||||
'clone' => Gate::allows('create', License::class),
|
||||
'update' => Gate::allows('update', License::class),
|
||||
'delete' => (Gate::allows('delete', License::class) && ($license->free_seats_count == $license->seats)) ? true : false,
|
||||
'delete' => $license->isDeletable(),
|
||||
'bulk_selectable' => [
|
||||
'delete' => $license->isDeletable(),
|
||||
],
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
|
||||
use App\Helpers\StorageHelper;
|
||||
use App\Models\Actionlog;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
@@ -24,6 +25,17 @@ class UploadedFilesTransformer
|
||||
public function transformFile(Actionlog $file)
|
||||
{
|
||||
$snipeModel = $file->item_type;
|
||||
$item = null;
|
||||
|
||||
if (is_string($snipeModel) && class_exists($snipeModel)) {
|
||||
$itemQuery = $snipeModel::query();
|
||||
|
||||
if (in_array(SoftDeletes::class, class_uses_recursive($snipeModel), true)) {
|
||||
$itemQuery->withTrashed();
|
||||
}
|
||||
|
||||
$item = $itemQuery->find($file->item_id);
|
||||
}
|
||||
|
||||
$array = [
|
||||
'id' => (int) $file->id,
|
||||
@@ -49,7 +61,7 @@ class UploadedFilesTransformer
|
||||
];
|
||||
|
||||
$permissions_array['available_actions'] = [
|
||||
'delete' => (Gate::allows('update', $snipeModel) && ($file->deleted_at == '')),
|
||||
'delete' => (Gate::allows('update', $item ?? $snipeModel) && ($file->deleted_at == '')),
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
@@ -34,6 +34,7 @@ use App\Notifications\CheckoutLicenseSeatNotification;
|
||||
use Exception;
|
||||
use GuzzleHttp\Exception\ClientException;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Notifications\Notification as BaseNotification;
|
||||
use Illuminate\Support\Facades\Context;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
@@ -126,12 +127,12 @@ class CheckoutableListener
|
||||
if ($shouldSendWebhookNotification) {
|
||||
try {
|
||||
if ($this->newMicrosoftTeamsWebhookEnabled()) {
|
||||
$message = $this->getCheckoutNotification($event)->toMicrosoftTeams();
|
||||
$message = $this->getCheckoutNotification($event, $acceptance, true)->toMicrosoftTeams();
|
||||
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
|
||||
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
|
||||
} else {
|
||||
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
|
||||
->notify($this->getCheckoutNotification($event, $acceptance));
|
||||
->notify($this->getCheckoutNotification($event, $acceptance, true));
|
||||
}
|
||||
} catch (ClientException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
@@ -233,12 +234,12 @@ class CheckoutableListener
|
||||
// Send Webhook notification
|
||||
try {
|
||||
if ($this->newMicrosoftTeamsWebhookEnabled()) {
|
||||
$message = $this->getCheckinNotification($event)->toMicrosoftTeams();
|
||||
$message = $this->getCheckinNotification($event, true)->toMicrosoftTeams();
|
||||
$notification = new TeamsNotification(Setting::getSettings()->webhook_endpoint);
|
||||
$notification->success()->sendMessage($message[0], $message[1]); // Send the message to Microsoft Teams
|
||||
} else {
|
||||
Notification::route($this->webhookSelected(), Setting::getSettings()->webhook_endpoint)
|
||||
->notify($this->getCheckinNotification($event));
|
||||
->notify($this->getCheckinNotification($event, true));
|
||||
}
|
||||
} catch (ClientException $e) {
|
||||
$status = $e->getResponse()->getStatusCode();
|
||||
@@ -312,12 +313,12 @@ class CheckoutableListener
|
||||
* @param CheckoutableCheckedIn $event
|
||||
* @return Notification
|
||||
*/
|
||||
private function getCheckinNotification($event)
|
||||
private function getCheckinNotification($event, bool $refreshCheckoutable = false): BaseNotification
|
||||
{
|
||||
|
||||
$notificationClass = null;
|
||||
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
|
||||
|
||||
switch (get_class($event->checkoutable)) {
|
||||
switch (get_class($checkoutable)) {
|
||||
case Accessory::class:
|
||||
$notificationClass = CheckinAccessoryNotification::class;
|
||||
break;
|
||||
@@ -334,7 +335,7 @@ class CheckoutableListener
|
||||
|
||||
Log::debug('Notification class: '.$notificationClass);
|
||||
|
||||
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
|
||||
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -344,11 +345,12 @@ class CheckoutableListener
|
||||
* @param CheckoutAcceptance|null $acceptance
|
||||
* @return Notification
|
||||
*/
|
||||
private function getCheckoutNotification($event, $acceptance = null)
|
||||
private function getCheckoutNotification($event, $acceptance = null, bool $refreshCheckoutable = false): BaseNotification
|
||||
{
|
||||
$notificationClass = null;
|
||||
$checkoutable = $this->getCheckoutableForNotification($event->checkoutable, $refreshCheckoutable);
|
||||
|
||||
switch (get_class($event->checkoutable)) {
|
||||
switch (get_class($checkoutable)) {
|
||||
case Accessory::class:
|
||||
$notificationClass = CheckoutAccessoryNotification::class;
|
||||
break;
|
||||
@@ -366,7 +368,16 @@ class CheckoutableListener
|
||||
break;
|
||||
}
|
||||
|
||||
return new $notificationClass($event->checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
|
||||
return new $notificationClass($checkoutable, $event->checkedOutTo, $event->checkedOutBy, $acceptance, $event->note);
|
||||
}
|
||||
|
||||
private function getCheckoutableForNotification(Model $checkoutable, bool $shouldRefresh): Model
|
||||
{
|
||||
if (! $shouldRefresh) {
|
||||
return $checkoutable;
|
||||
}
|
||||
|
||||
return $checkoutable->fresh() ?? $checkoutable;
|
||||
}
|
||||
|
||||
private function getCheckoutMailType($event, $acceptance)
|
||||
|
||||
@@ -135,14 +135,18 @@ class CheckoutablesCheckedOutInBulkListener
|
||||
return false;
|
||||
}
|
||||
|
||||
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?Model
|
||||
private function getNotifiableUser(CheckoutablesCheckedOutInBulk $event): ?User
|
||||
{
|
||||
$target = $event->target;
|
||||
|
||||
if ($target instanceof Asset) {
|
||||
$target->load('assignedTo');
|
||||
|
||||
return $target->assignedto;
|
||||
if ($target->assigned instanceof User) {
|
||||
return $target->assigned;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($target instanceof Location) {
|
||||
|
||||
@@ -639,6 +639,10 @@ class Importer extends Component
|
||||
'color code',
|
||||
trans('general.tag_color'),
|
||||
],
|
||||
'checkout_class' => [
|
||||
'checkout type',
|
||||
'checkout class',
|
||||
],
|
||||
];
|
||||
|
||||
$this->columnOptions[''] = $this->getColumns(''); // blank mode? I don't know what this is supposed to mean
|
||||
|
||||
@@ -58,10 +58,26 @@ class CheckinAssetMail extends BaseMailable
|
||||
{
|
||||
$this->item->load('status');
|
||||
$fields = [];
|
||||
$customFields = [];
|
||||
|
||||
// Check if the item has custom fields associated with it
|
||||
if (($this->item->model) && ($this->item->model->fieldset)) {
|
||||
$fields = $this->item->model->fieldset->fields;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (! $field->show_in_email || $field->field_encrypted == '1') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->item->{$field->db_column_name()};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Content(
|
||||
@@ -73,6 +89,7 @@ class CheckinAssetMail extends BaseMailable
|
||||
'note' => $this->note,
|
||||
'target' => $this->target,
|
||||
'fields' => $fields,
|
||||
'custom_fields' => $customFields,
|
||||
'expected_checkin' => $this->expected_checkin,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -75,6 +75,7 @@ class CheckoutAssetMail extends BaseMailable
|
||||
$eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : '';
|
||||
$req_accept = $this->requiresAcceptance();
|
||||
$fields = [];
|
||||
$customFields = [];
|
||||
$name = null;
|
||||
|
||||
if ($this->target instanceof User) {
|
||||
@@ -88,6 +89,21 @@ class CheckoutAssetMail extends BaseMailable
|
||||
// Check if the item has custom fields associated with it
|
||||
if (($this->item->model) && ($this->item->model->fieldset)) {
|
||||
$fields = $this->item->model->fieldset->fields;
|
||||
|
||||
foreach ($fields as $field) {
|
||||
if (! $field->show_in_email || $field->field_encrypted == '1') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $this->item->{$field->db_column_name()};
|
||||
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $field->name,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$accept_url = is_null($this->acceptance) ? null : route('account.accept.item', $this->acceptance);
|
||||
@@ -101,6 +117,7 @@ class CheckoutAssetMail extends BaseMailable
|
||||
'note' => $this->note,
|
||||
'target' => $name,
|
||||
'fields' => $fields,
|
||||
'custom_fields' => $customFields,
|
||||
'eula' => $eula,
|
||||
'req_accept' => $req_accept,
|
||||
'accept_url' => $accept_url,
|
||||
|
||||
+83
-18
@@ -9,9 +9,11 @@ use App\Presenters\ActionlogPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
@@ -53,6 +55,13 @@ class Actionlog extends SnipeModel
|
||||
|
||||
use Searchable;
|
||||
|
||||
/**
|
||||
* Cache whether a model table has a company_id column.
|
||||
*
|
||||
* @var array<string, bool>
|
||||
*/
|
||||
protected static array $companyColumnCache = [];
|
||||
|
||||
/**
|
||||
* The attributes that should be included when searching the model.
|
||||
*
|
||||
@@ -116,25 +125,81 @@ class Actionlog extends SnipeModel
|
||||
public static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
static::creating(
|
||||
function (self $actionlog) {
|
||||
// If the admin is a superadmin, let's see if the target instead has a company.
|
||||
if (auth()->user() && auth()->user()->isSuperUser()) {
|
||||
if ($actionlog->target) {
|
||||
$actionlog->company_id = $actionlog->target->company_id;
|
||||
} elseif ($actionlog->item) {
|
||||
$actionlog->company_id = $actionlog->item->company_id;
|
||||
}
|
||||
} elseif (auth()->user() && auth()->user()->company) {
|
||||
$actionlog->company_id = auth()->user()->company_id;
|
||||
}
|
||||
|
||||
if ($actionlog->action_date == '') {
|
||||
$actionlog->action_date = Carbon::now();
|
||||
}
|
||||
|
||||
static::creating(function (self $actionlog): void {
|
||||
// Only resolve company_id if it was never explicitly set by the caller.
|
||||
// Using array_key_exists on getRawOriginal() / getAttributes() lets us
|
||||
// distinguish "was set to null intentionally" from "was never set at all".
|
||||
if (! array_key_exists('company_id', $actionlog->getAttributes())) {
|
||||
$actionlog->company_id = static::resolveCompanyIdFromAttributes(
|
||||
$actionlog->target_type,
|
||||
$actionlog->target_id,
|
||||
$actionlog->item_type,
|
||||
$actionlog->item_id,
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if ($actionlog->action_date == '') {
|
||||
$actionlog->action_date = Carbon::now();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_id for a new action log by querying the item model
|
||||
* directly, bypassing all global scopes to avoid FMCS filtering issues.
|
||||
*
|
||||
* We intentionally prefer the item (asset, license, etc.) over the target
|
||||
* (user, location) because FMCS visibility is based on who *owns* the item,
|
||||
* not who it was checked out to. If the item has no company_id we fall back
|
||||
* to the target so that logs on unowned items still get a company stamp where
|
||||
* possible.
|
||||
*
|
||||
* This has to include an exception for the asset models table, since they are
|
||||
* not company-constrained (on purpose.)
|
||||
*/
|
||||
protected static function resolveCompanyIdFromAttributes(
|
||||
?string $targetType,
|
||||
?int $targetId,
|
||||
?string $itemType,
|
||||
?int $itemId,
|
||||
): ?int {
|
||||
// Prefer the item (the thing being acted upon) for FMCS ownership.
|
||||
$companyId = static::resolveCompanyIdFromModelClass($itemType, $itemId);
|
||||
|
||||
if ($companyId !== null) {
|
||||
return $companyId;
|
||||
}
|
||||
|
||||
// Fall back to target only when the item has no company_id.
|
||||
return static::resolveCompanyIdFromModelClass($targetType, $targetId);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve company_id from a model class and ID, but only if that model's
|
||||
* table has a company_id column.
|
||||
*/
|
||||
protected static function resolveCompanyIdFromModelClass(?string $modelClass, ?int $id): ?int
|
||||
{
|
||||
if (! $modelClass || ! $id || ! class_exists($modelClass) || ! is_subclass_of($modelClass, Model::class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Model $instance */
|
||||
$instance = app($modelClass);
|
||||
$table = $instance->getTable();
|
||||
|
||||
$hasCompanyColumn = static::$companyColumnCache[$table]
|
||||
??= Schema::hasColumn($table, 'company_id');
|
||||
|
||||
if (! $hasCompanyColumn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $modelClass::withoutGlobalScopes()
|
||||
->whereKey($id)
|
||||
->value('company_id');
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -228,6 +228,8 @@ class Asset extends Depreciable
|
||||
protected $searchableRelationAliases = [
|
||||
'status_label' => 'status',
|
||||
'assigned_to' => 'assignedTo',
|
||||
'model_number' => 'model',
|
||||
'rtd_location' => 'defaultLoc',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
|
||||
@@ -243,13 +243,27 @@ class CheckoutAcceptance extends Model
|
||||
if ($data['item_serial'] != null) {
|
||||
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
|
||||
}
|
||||
if (!empty($data['custom_fields']) && is_iterable($data['custom_fields'])) {
|
||||
foreach ($data['custom_fields'] as $customField) {
|
||||
$label = $customField['label'] ?? null;
|
||||
$value = $customField['value'] ?? null;
|
||||
|
||||
if (($label !== null) && ($value !== null) && ($value !== '')) {
|
||||
$pdf->writeHTML(e((string) $label) . ': ' . e((string) $value), true, 0, true, 0, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (($data['qty'] != null) && ($data['qty'] > 1)) {
|
||||
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
|
||||
}
|
||||
$pdf->Ln();
|
||||
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
|
||||
$pdf->writeHTML(trans('general.assignee').': '.e($data['assigned_to']).($data['employee_num'] ? ' ('.$data['employee_num'].')' : ''), true, 0, true, 0, '');
|
||||
if ($data['email'] != null) {
|
||||
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
|
||||
}
|
||||
|
||||
$pdf->Ln();
|
||||
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
|
||||
|
||||
|
||||
@@ -253,7 +253,7 @@ class Component extends SnipeModel
|
||||
*/
|
||||
public function requireAcceptance()
|
||||
{
|
||||
return $this->category->require_acceptance;
|
||||
return $this->category?->require_acceptance ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -322,7 +322,7 @@ class Consumable extends SnipeModel
|
||||
*/
|
||||
public function requireAcceptance()
|
||||
{
|
||||
return $this->category->require_acceptance;
|
||||
return $this->category?->require_acceptance ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -81,6 +81,12 @@ class Group extends SnipeModel
|
||||
return $this->belongsToMany(User::class, 'users_groups');
|
||||
}
|
||||
|
||||
/* this is just a shim for SCIM to work */
|
||||
public function members()
|
||||
{
|
||||
return $this->users();
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode JSON permissions into array
|
||||
*
|
||||
|
||||
@@ -640,11 +640,11 @@ class License extends Depreciable
|
||||
/**
|
||||
* This is really dumb - needs to be refactored, since we have ~3 diff methods that do almost the same thing
|
||||
*
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
* @return int
|
||||
*
|
||||
* @since [v2.0]
|
||||
*
|
||||
* @return Relation
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*/
|
||||
public function numRemaining()
|
||||
{
|
||||
|
||||
@@ -32,6 +32,8 @@ class Maintenance extends SnipeModel implements ICompanyableChild
|
||||
|
||||
protected $presenter = MaintenancesPresenter::class;
|
||||
|
||||
protected $with = ['asset', 'asset.company'];
|
||||
|
||||
protected $table = 'maintenances';
|
||||
|
||||
protected $rules = [
|
||||
|
||||
@@ -13,4 +13,12 @@ class SCIMUser extends User
|
||||
$attributes['password'] = $this->noPassword();
|
||||
parent::__construct($attributes);
|
||||
}
|
||||
}
|
||||
|
||||
// Have to re-define this here because Eloquent will try to 'guess' a foreign key of s_c_i_m_user_id
|
||||
// from SCIMUser
|
||||
public function groups()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\Group::class, 'users_groups', 'user_id', 'group_id');
|
||||
}
|
||||
|
||||
}
|
||||
+460
-211
@@ -2,248 +2,497 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Exceptions\SCIMException;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Helper;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Parser\Path;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIM\Schema;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Attribute;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Collection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Complex;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Constant;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Eloquent;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\JSONCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Meta;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\MutableCollection;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\Schema as AttributeSchema;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use ArieTimmerman\Laravel\SCIMServer\Attribute\AttributeMapping;
|
||||
use ArieTimmerman\Laravel\SCIMServer\SCIMConfig;
|
||||
|
||||
class SnipeSCIMConfig extends SCIMConfig
|
||||
function a($name = null): Attribute
|
||||
{
|
||||
return new Attribute($name);
|
||||
}
|
||||
|
||||
function complex($name = null): Complex
|
||||
{
|
||||
return new Complex($name);
|
||||
}
|
||||
|
||||
function eloquent($name, $attribute = null): Attribute
|
||||
{
|
||||
return new Eloquent($name, $attribute);
|
||||
}
|
||||
|
||||
class EloquentWithRemove extends Eloquent
|
||||
{
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
{
|
||||
$object->{$this->attribute} = null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class MappedTable extends Attribute
|
||||
{
|
||||
public function __construct(
|
||||
private string $scim_attribute_name,
|
||||
private string $relationship_name,
|
||||
private string $relationship_class,
|
||||
private string $relationship_id_field,
|
||||
private string $relationship_field)
|
||||
{
|
||||
parent::__construct($this->scim_attribute_name);
|
||||
}
|
||||
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return $object->{$this->relationship_name}?->{$this->relationship_field};
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$object->{$this->relationship_id_field} = $value ? $this->relationship_class::firstOrCreate([$this->relationship_field => $value])->id : null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class UpdatableComplex extends Complex
|
||||
{
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
throw new \Exception("doWrite is not implemented yet for Operation: $operation " . ($subop ? "($subop)" : "") . "on attribute " . $this->getFullKey());
|
||||
}
|
||||
|
||||
public function add($value, Model &$object)
|
||||
{
|
||||
$this->doWrite("add", null, $value, $object);
|
||||
}
|
||||
|
||||
public function replace($value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("replace", null, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function patch($operation, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
$this->doWrite("patch", $operation, $value, $object, $path, $removeIfNotSet);
|
||||
}
|
||||
|
||||
public function remove($value, Model &$object, Path $path = null)
|
||||
{
|
||||
$this->doWrite("remove", null, null, $object, $path);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class SnipeSCIMConfig
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function getConfigForResource($name)
|
||||
{
|
||||
$result = $this->getConfig();
|
||||
return @$result[$name];
|
||||
}
|
||||
|
||||
public function getGroupClass()
|
||||
{
|
||||
return Group::class;
|
||||
}
|
||||
|
||||
const ENTERPRISE = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
|
||||
const GROKABILITY = 'urn:ietf:params:scim:schemas:extension:grokability:2.0:User';
|
||||
|
||||
public function getUserConfig()
|
||||
{
|
||||
// Much of this is copied verbatim from the library, then adjusted for our needs
|
||||
|
||||
/*
|
||||
more snipe-it attributes I'd like to check out (to map to 'enterprise' maybe?):
|
||||
- website
|
||||
- notes?
|
||||
- remote???
|
||||
- location_id ?
|
||||
- company_id to "organization?"
|
||||
*/
|
||||
|
||||
$user_prefix = 'urn:ietf:params:scim:schemas:core:2.0:User:';
|
||||
$enterprise_prefix = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:';
|
||||
|
||||
return [
|
||||
|
||||
// Set to 'null' to make use of auth.providers.users.model (App\User::class)
|
||||
'class' => SCIMUser::class,
|
||||
|
||||
'validations' => [
|
||||
$user_prefix.'userName' => 'required',
|
||||
$user_prefix.'displayName' => 'nullable|string',
|
||||
$user_prefix.'name.givenName' => 'required',
|
||||
$user_prefix.'name.familyName' => 'nullable|string',
|
||||
$user_prefix.'externalId' => 'nullable|string',
|
||||
$user_prefix.'emails' => 'nullable|array',
|
||||
$user_prefix.'emails.*.value' => 'nullable|email',
|
||||
$user_prefix.'active' => 'boolean',
|
||||
$user_prefix.'phoneNumbers' => 'nullable|array',
|
||||
$user_prefix.'phoneNumbers.*.value' => 'nullable|string',
|
||||
$user_prefix.'addresses' => 'nullable|array',
|
||||
$user_prefix.'addresses.*.streetAddress' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.locality' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.region' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.postalCode' => 'nullable|string',
|
||||
$user_prefix.'addresses.*.country' => 'nullable|string',
|
||||
$user_prefix.'title' => 'nullable|string',
|
||||
$user_prefix.'preferredLanguage' => 'nullable|string',
|
||||
|
||||
// Enterprise validations:
|
||||
$enterprise_prefix.'employeeNumber' => 'nullable|string',
|
||||
$enterprise_prefix.'department' => 'nullable|string',
|
||||
$enterprise_prefix.'manager' => 'nullable',
|
||||
$enterprise_prefix.'manager.value' => 'nullable|string',
|
||||
],
|
||||
|
||||
'singular' => 'User',
|
||||
'schema' => [Schema::SCHEMA_USER],
|
||||
|
||||
// eager loading
|
||||
'withRelations' => [],
|
||||
'map_unmapped' => false,
|
||||
// 'unmapped_namespace' => 'urn:ietf:params:scim:schemas:laravel:unmapped',
|
||||
'description' => 'User Account',
|
||||
|
||||
// Map a SCIM attribute to an attribute of the object.
|
||||
'mapping' => [
|
||||
|
||||
'id' => (new AttributeMapping)->setRead(
|
||||
function (&$object) {
|
||||
return (string) $object->id;
|
||||
'map' => complex()->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)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant { // TODO - this 'id' is in the same namespace for objects OR groups?
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
}
|
||||
)->disableWrite(),
|
||||
|
||||
'externalId' => AttributeMapping::eloquent('scim_externalid'), // FIXME - I have a PR that changes a lot of this.
|
||||
|
||||
'meta' => [
|
||||
'created' => AttributeMapping::eloquent('created_at')->disableWrite(),
|
||||
'lastModified' => AttributeMapping::eloquent('updated_at')->disableWrite(),
|
||||
|
||||
'location' => (new AttributeMapping)->setRead(
|
||||
function ($object) {
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Users',
|
||||
'resourceObject' => $object->id,
|
||||
]
|
||||
);
|
||||
public function remove($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
),
|
||||
new Meta('Users'),
|
||||
(new AttributeSchema(Schema::SCHEMA_USER, true))->withSubAttributes(
|
||||
eloquent('userName', 'username')->ensure('required'),
|
||||
(new class ('active', 'activated') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (bool)$object->activated; // need this extension to force boolean-ness
|
||||
}
|
||||
)->disableWrite(),
|
||||
}),
|
||||
complex('name')->withSubAttributes(
|
||||
eloquent('givenName', 'first_name')->ensure('required'),
|
||||
eloquent('familyName', 'last_name'),
|
||||
), // ->ensure('required'), It *is* a bit weird, but I would've thought 'name' is required since 'givenName' is required? But apparently not?
|
||||
eloquent('displayName', 'display_name'), //yes, this is *not* under 'name' - that's the spec
|
||||
//eloquent('password')->ensure('nullable')->setReturned('never'),
|
||||
eloquent('externalId', 'scim_externalid'),
|
||||
|
||||
'resourceType' => AttributeMapping::constant('User'),
|
||||
],
|
||||
|
||||
'schemas' => AttributeMapping::constant(
|
||||
[
|
||||
'urn:ietf:params:scim:schemas:core:2.0:User',
|
||||
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
|
||||
]
|
||||
)->ignoreWrite(),
|
||||
|
||||
'urn:ietf:params:scim:schemas:core:2.0:User' => [
|
||||
|
||||
'userName' => AttributeMapping::eloquent('username'),
|
||||
|
||||
'name' => [
|
||||
'formatted' => (new AttributeMapping)->ignoreWrite()->setRead(
|
||||
function (&$object) {
|
||||
return $object->getFullNameAttribute();
|
||||
}
|
||||
),
|
||||
'familyName' => AttributeMapping::eloquent('last_name'),
|
||||
'givenName' => AttributeMapping::eloquent('first_name'),
|
||||
'middleName' => null,
|
||||
'honorificPrefix' => null,
|
||||
'honorificSuffix' => null,
|
||||
],
|
||||
|
||||
'displayName' => AttributeMapping::eloquent('display_name'),
|
||||
'nickName' => null,
|
||||
'profileUrl' => null,
|
||||
'title' => AttributeMapping::eloquent('jobtitle'),
|
||||
'userType' => null,
|
||||
'preferredLanguage' => AttributeMapping::eloquent('locale'), // Section 5.3.5 of [RFC7231]
|
||||
'locale' => null, // see RFC5646
|
||||
'timezone' => null, // see RFC6557
|
||||
'active' => (new AttributeMapping)->setAdd(
|
||||
function ($value, &$object) {
|
||||
$object->activated = $value;
|
||||
// Email chonk
|
||||
(new class ('emails') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return collect([$object->email])->map(function ($email) {
|
||||
return [
|
||||
'value' => $email,
|
||||
'type' => 'work', //TODO - is this how we always have done it?
|
||||
'primary' => true
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$object->activated = $value;
|
||||
}
|
||||
)->setRead(
|
||||
// this works as specified.
|
||||
function (&$object) {
|
||||
return (bool) $object->activated;
|
||||
}
|
||||
),
|
||||
'password' => AttributeMapping::eloquent('password')->disableRead(),
|
||||
|
||||
// Multi-Valued Attributes
|
||||
'emails' => [[
|
||||
'value' => AttributeMapping::eloquent('email'),
|
||||
'display' => null,
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
|
||||
]],
|
||||
|
||||
'phoneNumbers' => [[
|
||||
'value' => AttributeMapping::eloquent('phone'),
|
||||
'display' => null,
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(),
|
||||
]],
|
||||
|
||||
'ims' => [[
|
||||
'value' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
'primary' => null,
|
||||
]], // Instant messaging addresses for the User
|
||||
|
||||
'photos' => [[
|
||||
'value' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
'primary' => null,
|
||||
]],
|
||||
|
||||
'addresses' => [[
|
||||
'type' => AttributeMapping::constant('work')->ignoreWrite(),
|
||||
'formatted' => AttributeMapping::constant('n/a')->ignoreWrite(), // TODO - is this right? This doesn't look right.
|
||||
'streetAddress' => AttributeMapping::eloquent('address'),
|
||||
'locality' => AttributeMapping::eloquent('city'),
|
||||
'region' => AttributeMapping::eloquent('state'),
|
||||
'postalCode' => AttributeMapping::eloquent('zip'),
|
||||
'country' => AttributeMapping::eloquent('country'),
|
||||
'primary' => AttributeMapping::constant(true)->ignoreWrite(), // this isn't in the example?
|
||||
]],
|
||||
|
||||
'groups' => [[
|
||||
'value' => null,
|
||||
'$ref' => null,
|
||||
'display' => null,
|
||||
'type' => null,
|
||||
]],
|
||||
|
||||
'entitlements' => null,
|
||||
'roles' => null,
|
||||
'x509Certificates' => null,
|
||||
],
|
||||
|
||||
'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User' => [
|
||||
'employeeNumber' => AttributeMapping::eloquent('employee_num'),
|
||||
'department' => (new AttributeMapping)->setAdd( // FIXME parent?
|
||||
function ($value, &$object) {
|
||||
$department = Department::where('name', $value)->first();
|
||||
if ($department) {
|
||||
$object->department_id = $department->id;
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
if ($value) {
|
||||
try {
|
||||
$object->email = $value[0]['value'];
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown email object: '" . print_r($value, true) . "'", 422);
|
||||
}
|
||||
} else {
|
||||
$object->email = null;
|
||||
}
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$department = Department::where('name', $value)->first();
|
||||
if ($department) {
|
||||
$object->department_id = $department->id;
|
||||
})->withSubAttributes(
|
||||
eloquent('value', 'email')->ensure('email', 'nullable'), //Weird, this 'needs' nullable to work?
|
||||
new Constant('type', 'work'),
|
||||
(new Constant('primary', true))->ensure('boolean')
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
// phone chonk
|
||||
(new class ('phoneNumbers') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
$phones = [];
|
||||
if ($object->phone) {
|
||||
$phones[] = [
|
||||
'value' => $object->phone,
|
||||
'type' => 'work'
|
||||
];
|
||||
|
||||
}
|
||||
if ($object->mobile) {
|
||||
$phones[] = [
|
||||
'value' => $object->mobile,
|
||||
'type' => 'mobile'
|
||||
];
|
||||
}
|
||||
return $phones;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("Phones 'value' is: " . print_r($value, true));
|
||||
try {
|
||||
if ($operation == "patch") {
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
if ((string) $path == 'phoneNumbers[type eq "mobile"].value') {
|
||||
$object->mobile = $value; //I don't know why the value is the raw value, but it is?
|
||||
return;
|
||||
}
|
||||
if ((string) $path == 'phoneNumbers[type eq "work"].value') {
|
||||
$object->phone = $value; //similar, don't know why, but it is
|
||||
return;
|
||||
}
|
||||
}
|
||||
parent::patch($subop, $value, $object, $path, $removeIfNotSet);
|
||||
return;
|
||||
}
|
||||
foreach ($value as $phone) {
|
||||
switch ($phone['type']) {
|
||||
case 'work':
|
||||
$object->phone = $phone['value'];
|
||||
break;
|
||||
|
||||
case 'mobile':
|
||||
$object->mobile = $phone['value'];
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new SCIMException("Unknown phone type '" . @$phone['type'] . "'", 400);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
\Log::debug($e);
|
||||
throw new SCIMException("Unknown phone object(s) '" . print_r($value, true) . "'", 422);
|
||||
}
|
||||
}
|
||||
)->setRead(
|
||||
function (&$object) {
|
||||
return $object->department ? $object->department->name : null;
|
||||
}
|
||||
),
|
||||
'manager' => [
|
||||
// FIXME - manager writes are disabled. This kinda works but it leaks errors all over the place. Not cool.
|
||||
// '$ref' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
|
||||
// 'displayName' => (new AttributeMapping())->ignoreWrite()->ignoreRead(),
|
||||
// NOTE: you could probably do a 'plain' Eloquent mapping here, but we don't for future-proofing
|
||||
'value' => (new AttributeMapping)->setAdd(
|
||||
function ($value, &$object) {
|
||||
$manager = User::find($value);
|
||||
if ($manager) {
|
||||
$object->manager_id = $manager->id;
|
||||
|
||||
})->withSubAttributes( // TODO: I suspect these 'sub-attributes' aren't being checked at all
|
||||
(new Constant('value', 'email'))->ensure('string'), // TODO - this is WRONG, but it works somehow? Probably because it's ignored
|
||||
new Constant('type', 'other'), // TODO uh, *also* wrong? but, again, seems to be ignored
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
// addresses chonk
|
||||
(new class ('addresses') extends UpdatableComplex {
|
||||
static $addressmap = [
|
||||
'streetAddress' => 'address',
|
||||
'locality' => 'city',
|
||||
'region' => 'state',
|
||||
'postalCode' => 'zip',
|
||||
'country' => 'country'
|
||||
];
|
||||
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
$address = [];
|
||||
foreach (self::$addressmap as $scim_field => $db_field) {
|
||||
if ($object->{$db_field}) {
|
||||
$address[$scim_field] = $object->{$db_field};
|
||||
}
|
||||
}
|
||||
)->setReplace(
|
||||
function ($value, &$object) {
|
||||
$manager = User::find($value);
|
||||
if ($manager) {
|
||||
$object->manager_id = $manager->id;
|
||||
if (count($address) > 0) {
|
||||
$address['type'] = 'work';
|
||||
$address['primary'] = true;
|
||||
}
|
||||
return $address;
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, Path $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
// TODO - this is validated *just* for 'patch' operations, so this may not work in other write contexts
|
||||
if ($path->getValuePathFilter() != null) {
|
||||
\Log::debug("path for update $path");
|
||||
// get the part of the $path that we actually care about - something like:
|
||||
// addresses[type eq "work"]
|
||||
$matches = null;
|
||||
if (!preg_match('/^.+\[type eq "([a-zA-Z]+)"](?:\.([a-zA-Z]+))?$/', (string)$path, $matches)) {
|
||||
throw new SCIMException("Unknown path type '$path'", 422);
|
||||
}
|
||||
$type = $matches[1];
|
||||
if ($type != 'work') {
|
||||
throw new SCIMException("Unknown object type '$type'", 422);
|
||||
}
|
||||
$attribute = array_key_exists(2, $matches) ? $matches[2] : null;
|
||||
if (array_key_exists($attribute, self::$addressmap)) {
|
||||
$object->{self::$addressmap[$attribute]} = $value;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
throw new SCIMException("Could not handle path for update $path", 422);
|
||||
}
|
||||
)->setRead(
|
||||
function (&$object) {
|
||||
return $object->manager_id;
|
||||
}
|
||||
|
||||
})->withSubAttributes(
|
||||
eloquent('streetAddress', 'address'),
|
||||
eloquent('locality', 'city'),
|
||||
eloquent('region', 'state'),
|
||||
eloquent('postalCode', 'zip'),
|
||||
eloquent('country', 'country'),
|
||||
new Constant('type', 'other'),
|
||||
(new Constant('primary', true))->ensure('boolean')
|
||||
)->ensure('array')
|
||||
->setMultiValued(true),
|
||||
|
||||
eloquent('title', 'jobtitle'),
|
||||
eloquent('preferredLanguage', 'locale'),
|
||||
(new Collection('groups'))->withSubAttributes(
|
||||
eloquent('value', 'id'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Group',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
]
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
}),
|
||||
eloquent('display', 'name')
|
||||
),
|
||||
(new JSONCollection('roles'))->withSubAttributes( // TODO - what is this?
|
||||
eloquent('value')->ensure('required', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('display')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('type')->ensure('nullable', 'min:3', 'alpha_dash:ascii'),
|
||||
eloquent('primary')->ensure('boolean')->default(false)
|
||||
)->ensure('nullable', 'array', 'max:20')
|
||||
),
|
||||
(new AttributeSchema(self::ENTERPRISE, false))->withSubAttributes(
|
||||
eloquent('employeeNumber', 'employee_num')->ensure('nullable'),
|
||||
new MappedTable('department', 'department', Department::class, 'department_id', 'name'),
|
||||
(new class('manager') extends UpdatableComplex {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
if (!$object->manager) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'value' => $object->manager->id, //TODO - ID's aren't unique like they're supposed to be :/
|
||||
'$ref' => route('scim.resource', ['resourceType' => 'User', 'resourceObject' => $object->manager->id]),
|
||||
'displayName' => $object->manager->display_name,
|
||||
];
|
||||
}
|
||||
|
||||
public function doWrite($operation, $subop, $value, Model &$object, $path = null, $removeIfNotSet = false)
|
||||
{
|
||||
\Log::debug("What type of value is value? " . gettype($value));
|
||||
$manager_id = null;
|
||||
if (is_scalar($value)) {
|
||||
\Log::debug("Weird Microsoft mode - set manager to the \$value and move on with life?");
|
||||
$manager_id = $value;
|
||||
} elseif (array_key_exists('$ref', $value)) {
|
||||
// Here's the spec: https://datatracker.ietf.org/doc/html/rfc7643#section-4.3
|
||||
|
||||
// according to the spec it's _recommended_ to do:
|
||||
// $ref - which should be the URI of the manager
|
||||
|
||||
// extract ID from URL, jam it in?
|
||||
$url = $value['$ref'];
|
||||
$users_prefix = route('scim.resources', ['resourceType' => 'User']) . '/';
|
||||
if (string_starts_with($url, $users_prefix)) {
|
||||
$manager_id = substr($url, strlen($users_prefix));
|
||||
}
|
||||
} elseif (array_key_exists('value', $value)) {
|
||||
// this is _Snipe-IT_'s ID being passed as 'value' I believe?
|
||||
// if you use the 'managerId' field in Okta, you get:
|
||||
// [value] => 9999999
|
||||
// that, at least, is the spec - but *what* ID is that?! It's supposed to be a Snipe-IT one!
|
||||
$manager_id = $value['value'];
|
||||
}
|
||||
\Log::debug("Non-Microsoft - Trying to '$operation' for manager with value: " . print_r($value, true));
|
||||
if ($manager_id && User::find($manager_id)) {
|
||||
$object->manager_id = $manager_id;
|
||||
return;
|
||||
}
|
||||
throw new SCIMException("No manager given, or manager doesn't exist", 400);
|
||||
}
|
||||
}) // ->withSubAttributes() ... -> ensure() ?
|
||||
),
|
||||
(new AttributeSchema(self::GROKABILITY, false))->withSubAttributes(
|
||||
new MappedTable('location', 'location', Location::class, 'location_id', 'name'),
|
||||
new MappedTable('company', 'company', Company::class, 'company_id', 'name'),
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function getGroupConfig()
|
||||
{
|
||||
return [
|
||||
|
||||
'class' => $this->getGroupClass(),
|
||||
'singular' => 'Group',
|
||||
|
||||
//eager loading
|
||||
'withRelations' => [],
|
||||
'description' => 'Group',
|
||||
|
||||
'map' => complex()->withSubAttributes(
|
||||
new class ('schemas', [
|
||||
"urn:ietf:params:scim:schemas:core:2.0:Group",
|
||||
]) extends Constant {
|
||||
public function replace($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
$this->dirty = true;
|
||||
}
|
||||
},
|
||||
(new class ('id', null) extends Constant {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return (string)$object->id;
|
||||
}
|
||||
|
||||
public function remove($value, &$object, $path = null)
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
),
|
||||
new EloquentWithRemove('externalId', 'scim_externalid'),
|
||||
new Meta('Groups'),
|
||||
(new AttributeSchema(Schema::SCHEMA_GROUP, true))->withSubAttributes(
|
||||
eloquent('displayName', 'name')->ensure('required', 'min:3', function ($attribute, $value, $fail) {
|
||||
// check if group does not exist or if it exists, it is the same group
|
||||
$group = $this->getGroupClass()::where('name', $value)->first();
|
||||
if ($group && (request()->route('resourceObject') == null || $group->id != request()->route('resourceObject')->id)) {
|
||||
$fail('The name has already been taken.');
|
||||
}
|
||||
}),
|
||||
(new MutableCollection('members'))->withSubAttributes(
|
||||
eloquent('value', 'id')->ensure('required'),
|
||||
(new class ('$ref') extends Eloquent {
|
||||
protected function doRead(&$object, $attributes = [])
|
||||
{
|
||||
return route(
|
||||
'scim.resource',
|
||||
[
|
||||
'resourceType' => 'Users',
|
||||
'resourceObject' => $object->id ?? "not-saved"
|
||||
]
|
||||
);
|
||||
}
|
||||
}),
|
||||
eloquent('display', 'name')
|
||||
)->ensure('nullable', 'array')
|
||||
)
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
public function getConfig()
|
||||
{
|
||||
return [
|
||||
'Users' => $this->getUserConfig(),
|
||||
'Groups' => $this->getGroupConfig(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\ICompanyableChild;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Location;
|
||||
@@ -177,6 +178,7 @@ trait Loggable
|
||||
$log->note = $note;
|
||||
$log->action_date = $action_date;
|
||||
$log->quantity = $quantity;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
|
||||
$changed = [];
|
||||
$array_to_flip = array_keys($fields_array);
|
||||
@@ -221,6 +223,37 @@ trait Loggable
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the company_id that should be stamped on an action log entry.
|
||||
*
|
||||
* LicenseSeat does not carry a company_id directly — it belongs to a License,
|
||||
* so we fetch the parent license's company_id in that case. All other models
|
||||
* that use the Loggable trait have a company_id column directly.
|
||||
*/
|
||||
private function resolveLoggableCompanyId(): ?int
|
||||
{
|
||||
if (static::class === LicenseSeat::class) {
|
||||
return $this->license?->company_id;
|
||||
}
|
||||
|
||||
if (isset($this->company_id)) {
|
||||
return $this->company_id;
|
||||
}
|
||||
|
||||
// Companyable children (like Maintenance) inherit company visibility from parents.
|
||||
if ($this instanceof ICompanyableChild) {
|
||||
foreach ((array) $this->getCompanyableParents() as $parentRelation) {
|
||||
$parent = $this->{$parentRelation} ?? null;
|
||||
|
||||
if (isset($parent?->company_id)) {
|
||||
return $parent->company_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
|
||||
*
|
||||
@@ -267,6 +300,7 @@ trait Loggable
|
||||
$log->location_id = null;
|
||||
$log->note = $note;
|
||||
$log->action_date = $action_date;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
|
||||
if (! $action_date) {
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
@@ -383,6 +417,8 @@ trait Loggable
|
||||
$log->created_by = auth()->id();
|
||||
$log->filename = $filename;
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
// Explicitly stamp company_id from the item being audited so FMCS scoping works correctly.
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->logaction('audit');
|
||||
|
||||
$params = [
|
||||
@@ -468,6 +504,7 @@ trait Loggable
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
$log->note = $note;
|
||||
$log->created_by = $created_by;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->logaction('create');
|
||||
$log->save();
|
||||
|
||||
@@ -494,6 +531,7 @@ trait Loggable
|
||||
$log->created_by = auth()->id();
|
||||
$log->note = $note;
|
||||
$log->target_id = null;
|
||||
$log->company_id = $this->resolveLoggableCompanyId();
|
||||
$log->created_at = date('Y-m-d H:i:s');
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
$log->filename = $filename;
|
||||
|
||||
@@ -56,9 +56,12 @@ trait Searchable
|
||||
$preparedSearch = $this->prepareSearchInput((string) $search);
|
||||
$terms = $preparedSearch['terms'];
|
||||
$filters = $preparedSearch['filters'];
|
||||
$filterOperator = $preparedSearch['filter_operator'];
|
||||
|
||||
if (! empty($filters)) {
|
||||
return $this->applySearchFilters($query, $filters);
|
||||
// Structured advanced-search filters are mutually exclusive with free-text terms.
|
||||
// Once we detect structured payloads, we avoid the broad OR-based free-text path.
|
||||
return $this->applySearchFilters($query, $filters, $filterOperator);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -101,15 +104,27 @@ trait Searchable
|
||||
return [
|
||||
'terms' => [],
|
||||
'filters' => $parsedFilters,
|
||||
'filter_operator' => $this->resolveStructuredFilterOperator(),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'terms' => $this->prepeareSearchTerms($search),
|
||||
'filters' => [],
|
||||
'filter_operator' => 'and',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the structured advanced-search operator from the current request.
|
||||
*/
|
||||
private function resolveStructuredFilterOperator(): string
|
||||
{
|
||||
$operator = strtolower((string) request()->input('filter_operator', 'and'));
|
||||
|
||||
return $operator === 'or' ? 'or' : 'and';
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a structured filter payload into scalar string filters.
|
||||
*/
|
||||
@@ -122,6 +137,7 @@ trait Searchable
|
||||
$payload = $search;
|
||||
|
||||
if (str_starts_with($search, 'filter:')) {
|
||||
// Some callers send filter payloads with an explicit "filter:" prefix.
|
||||
$payload = substr($search, 7);
|
||||
} elseif (! (str_starts_with($search, '{') && str_ends_with($search, '}'))) {
|
||||
return null;
|
||||
@@ -147,6 +163,7 @@ trait Searchable
|
||||
$normalizedValue = trim((string) ($value ?? ''));
|
||||
|
||||
if ($normalizedValue === '') {
|
||||
// Ignore empty fields so clearing an input does not create noisy no-op filters.
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -174,83 +191,305 @@ trait Searchable
|
||||
*
|
||||
* @param array<string, string> $filters
|
||||
*/
|
||||
private function applySearchFilters(Builder $query, array $filters): Builder
|
||||
private function applySearchFilters(Builder $query, array $filters, string $filterOperator = 'and'): Builder
|
||||
{
|
||||
if ($filterOperator === 'or') {
|
||||
$query->where(function (Builder $filterQuery) use ($filters) {
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
$this->applySingleSearchFilter($filterQuery, $filterKey, $filterValue, 'or');
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
$this->applySingleSearchFilter($query, $filterKey, $filterValue);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a raw filter value for an optional negation, null-check, or exact-match prefix.
|
||||
*
|
||||
* Supported syntax:
|
||||
* - "!flarb" → operator = not_like, value = "flarb"
|
||||
* - "not:flarb" → operator = not_like, value = "flarb"
|
||||
* - "is:null" → operator = is_null, value = "" (reserved token)
|
||||
* - "is:not_null" → operator = is_not_null, value = "" (reserved token)
|
||||
* - "is:flarb" → operator = exact, value = "flarb" (exact equality)
|
||||
* - "is_not:flarb"→ operator = exact_not, value = "flarb" (exact inequality)
|
||||
*
|
||||
* `is:null` and `is:not_null` are checked before the generic `is:` prefix so they always
|
||||
* resolve to their dedicated null-check operators regardless of casing.
|
||||
*
|
||||
* The legacy `negate` boolean is preserved alongside `operator` so that
|
||||
* existing callers that only check `negate` still work correctly.
|
||||
*
|
||||
* @return array{value: string, negate: bool, operator: string}
|
||||
*/
|
||||
private function parseFilterValue(string $raw): array
|
||||
{
|
||||
$lower = strtolower($raw);
|
||||
|
||||
if ($lower === 'is:null') {
|
||||
// Reserved token: interpreted as null-check operator, not exact match string.
|
||||
return ['value' => '', 'negate' => false, 'operator' => 'is_null'];
|
||||
}
|
||||
|
||||
if ($lower === 'is:not_null') {
|
||||
// Reserved token: interpreted as non-null check operator.
|
||||
return ['value' => '', 'negate' => false, 'operator' => 'is_not_null'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'is:')) {
|
||||
// Generic exact-match prefix. This is checked after reserved is:null/is:not_null tokens.
|
||||
$exactValue = ltrim(substr($raw, 3));
|
||||
|
||||
return ['value' => $exactValue, 'negate' => false, 'operator' => 'exact'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'is_not:')) {
|
||||
$exactNotValue = ltrim(substr($raw, 7));
|
||||
|
||||
return ['value' => $exactNotValue, 'negate' => true, 'operator' => 'exact_not'];
|
||||
}
|
||||
|
||||
if (str_starts_with($raw, '!')) {
|
||||
return ['value' => substr($raw, 1), 'negate' => true, 'operator' => 'not_like'];
|
||||
}
|
||||
|
||||
if (str_starts_with($lower, 'not:')) {
|
||||
return ['value' => substr($raw, 4), 'negate' => true, 'operator' => 'not_like'];
|
||||
}
|
||||
|
||||
return ['value' => $raw, 'negate' => false, 'operator' => 'like'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a single structured filter using the provided boolean operator.
|
||||
*
|
||||
* Negation: if the filter value is prefixed with "!" or "not:", the filter
|
||||
* uses NOT LIKE (for attributes/custom fields) or whereDoesntHave (for
|
||||
* relations), effectively excluding records matching the value.
|
||||
*
|
||||
* For relation filters, negation uses NOT LIKE inside whereHas, meaning
|
||||
* "has a related record where the column does NOT contain the value".
|
||||
* Records with no related record (e.g. unassigned assets) are excluded;
|
||||
* use a plain empty-string filter if you need to match NULLs.
|
||||
*/
|
||||
private function applySingleSearchFilter(Builder $query, string $filterKey, string $filterValue, string $boolean = 'and'): Builder
|
||||
{
|
||||
$parsed = $this->parseFilterValue($filterValue);
|
||||
$value = $parsed['value'];
|
||||
$negate = $parsed['negate'];
|
||||
$operator = $parsed['operator'];
|
||||
|
||||
// IS NULL / IS NOT NULL are handled before value-based filtering,
|
||||
// because there is no meaningful value to pass to LIKE for them.
|
||||
if ($operator === 'is_null' || $operator === 'is_not_null') {
|
||||
return $this->applyNullFilter($query, $filterKey, $operator === 'is_null', $boolean);
|
||||
}
|
||||
|
||||
// Skip gracefully if stripping the prefix leaves an empty value.
|
||||
if ($value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$searchableAttributes = $this->getSearchableAttributes();
|
||||
$searchableCounts = $this->getSearchableCounts();
|
||||
$searchableRelations = $this->getSearchableRelations();
|
||||
$table = $this->getTable();
|
||||
$whereMethod = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
|
||||
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
|
||||
|
||||
foreach ($filters as $filterKey => $filterValue) {
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
$query->where($table.'.'.$filterKey, 'LIKE', '%'.$filterValue.'%');
|
||||
|
||||
continue;
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
if ($isExactOperator) {
|
||||
$query->{$whereMethod}($table.'.'.$filterKey, $exactComparisonOperator, $value);
|
||||
} else {
|
||||
$query->{$whereMethod}($table.'.'.$filterKey, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
|
||||
if (in_array($filterKey, $searchableCounts, true)) {
|
||||
$query = $this->applyCountAliasFilter($query, $filterKey, $filterValue);
|
||||
return $query;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
// Handle virtual columns — keys that are not real DB columns but map to a set
|
||||
// of real columns searched via CONCAT (e.g. "name" → first_name + last_name on User).
|
||||
$virtualColumns = $this->getSearchableVirtualColumns();
|
||||
|
||||
// Check if this is a custom field (only for Assets - for *now*).
|
||||
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
|
||||
// collisions with standard attributes or relation filter keys.
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
if (array_key_exists($filterKey, $virtualColumns)) {
|
||||
$qualifiedColumns = array_map(
|
||||
fn ($col) => $table.'.'.$col,
|
||||
$virtualColumns[$filterKey]
|
||||
);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($isExactOperator) {
|
||||
// Exact match on the full CONCAT'd value, e.g. "John Smith" matches only
|
||||
// users whose first_name + ' ' + last_name equals exactly "John Smith".
|
||||
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
|
||||
// buildMultipleColumnSearch intentionally returns a fragment ending in "LIKE ?";
|
||||
// for exact matches we rewrite only the operator and keep the same SQL scaffold.
|
||||
$concatSql = str_replace(' LIKE ?', $operator === 'exact_not' ? ' <> ?' : ' = ?', $concatSql);
|
||||
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
|
||||
$query->{$rawMethod}($concatSql, [$value]);
|
||||
} else {
|
||||
$concatSql = $this->buildMultipleColumnSearch($qualifiedColumns);
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationColumns = (array) $searchableRelations[$resolvedRelationKey];
|
||||
|
||||
$query->whereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $filterValue) {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($negate) {
|
||||
$concatSql = str_replace(' LIKE ?', ' NOT LIKE ?', $concatSql);
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$relationQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(
|
||||
[
|
||||
$rawMethod = $boolean === 'or' ? 'orWhereRaw' : 'whereRaw';
|
||||
$query->{$rawMethod}($concatSql, ['%'.$value.'%']);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
if (in_array($filterKey, $searchableCounts, true)) {
|
||||
return $this->applyCountAliasFilter($query, $filterKey, $value, $boolean, $negate, $isExactOperator);
|
||||
}
|
||||
|
||||
// Check if this is a custom field (only for Assets - for *now*).
|
||||
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
|
||||
// collisions with standard attributes or relation filter keys.
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
if ($isExactOperator) {
|
||||
$query->{$whereMethod}($table.'.'.$dbColumn, $exactComparisonOperator, $value);
|
||||
} else {
|
||||
$query->{$whereMethod}($table.'.'.$dbColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
return $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $value, $boolean, $negate, $operator);
|
||||
}
|
||||
|
||||
$relationColumns = $this->getStructuredFilterRelationColumns(
|
||||
filterKey: $filterKey,
|
||||
resolvedRelationKey: $resolvedRelationKey,
|
||||
searchableRelations: $searchableRelations,
|
||||
);
|
||||
|
||||
// For negated relation filters (e.g. location: !dam), include rows with
|
||||
// no related record as well as rows with related records that do not match.
|
||||
// This aligns advanced-search behavior with user expectation for "not X".
|
||||
if ($operator === 'not_like' || $operator === 'exact_not') {
|
||||
$compoundMethod = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$compoundMethod}(function (Builder $compoundQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
|
||||
// Critical behavior: "not X" on relations should include records with no relation.
|
||||
// Example: location=!dam should include users without a location.
|
||||
$compoundQuery->doesntHave($resolvedRelationKey)
|
||||
->orWhereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $operator): void {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
$relationComparisonOperator = $operator === 'exact_not' ? '!=' : 'NOT LIKE';
|
||||
$relationComparisonValue = $operator === 'exact_not' ? $value : '%'.$value.'%';
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// For negation we AND the NOT LIKE conditions so all columns must not match.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $relationComparisonOperator, $relationComparisonValue);
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$concatSql = $this->buildMultipleColumnSearch([
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
]
|
||||
),
|
||||
["%{$filterValue}%"]
|
||||
);
|
||||
}
|
||||
]);
|
||||
|
||||
if ($operator === 'exact_not') {
|
||||
$relationQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$value]);
|
||||
} else {
|
||||
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
$relationMethod = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
|
||||
|
||||
$query->{$relationMethod}($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $value, $likeOperator, $operator) {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
if (! $firstConditionAdded) {
|
||||
if ($operator === 'exact') {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '=', $value);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
|
||||
} else {
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operator === 'exact') {
|
||||
// For exact matches across multiple columns, OR them — any column matching
|
||||
// the exact value is sufficient (e.g. name OR slug).
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, '=', $value);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
// For exact exclusions we AND the conditions so no column can equal the value.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, '!=', $value);
|
||||
} elseif ($likeOperator === 'NOT LIKE') {
|
||||
// For negation we AND the NOT LIKE conditions so all columns must not match.
|
||||
$relationQuery->where($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
} else {
|
||||
// For normal LIKE we OR them so any column matching is sufficient.
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, $likeOperator, '%'.$value.'%');
|
||||
}
|
||||
}
|
||||
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$concatSql = $this->buildMultipleColumnSearch([
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
'users.display_name',
|
||||
]);
|
||||
|
||||
if ($operator === 'exact') {
|
||||
$concatSql = str_replace(' LIKE ?', ' = ?', $concatSql);
|
||||
$relationQuery->orWhereRaw($concatSql, [$value]);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$concatSql = str_replace(' LIKE ?', ' <> ?', $concatSql);
|
||||
$relationQuery->whereRaw($concatSql, [$value]);
|
||||
} elseif ($likeOperator === 'NOT LIKE') {
|
||||
$relationQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$value}%"]);
|
||||
} else {
|
||||
$relationQuery->orWhereRaw($concatSql, ["%{$value}%"]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
@@ -302,8 +541,13 @@ trait Searchable
|
||||
|
||||
/**
|
||||
* Apply filters for assignees with type-specific searchable columns.
|
||||
*
|
||||
* When $negate is true, NOT LIKE is used inside whereHasMorph, so results
|
||||
* are records that have an assignee whose columns do NOT contain $filterValue.
|
||||
* (Records with no assignee are excluded; they do not satisfy "has an assignee
|
||||
* where column NOT LIKE '%value%'".)
|
||||
*/
|
||||
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder
|
||||
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue, string $boolean = 'and', bool $negate = false, string $operator = 'like'): Builder
|
||||
{
|
||||
$relationName = $this->resolveAssignedToRelationName();
|
||||
|
||||
@@ -311,10 +555,15 @@ trait Searchable
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHasMorph(
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
$isExactOperator = in_array($operator, ['exact', 'exact_not'], true);
|
||||
$exactComparisonOperator = $operator === 'exact_not' ? '!=' : '=';
|
||||
$relationMethod = $boolean === 'or' ? 'orWhereHasMorph' : 'whereHasMorph';
|
||||
|
||||
return $query->{$relationMethod}(
|
||||
$relationName,
|
||||
[User::class, Asset::class, Location::class],
|
||||
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) {
|
||||
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue, $likeOperator, $negate, $operator, $isExactOperator, $exactComparisonOperator) {
|
||||
$columns = $this->getAssigneeColumnsByType($assigneeType);
|
||||
|
||||
if (empty($columns)) {
|
||||
@@ -326,20 +575,41 @@ trait Searchable
|
||||
|
||||
foreach ($columns as $column) {
|
||||
if (! $firstConditionAdded) {
|
||||
$assigneeQuery->where($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($isExactOperator) {
|
||||
$assigneeQuery->where($table.'.'.$column, $exactComparisonOperator, $filterValue);
|
||||
} else {
|
||||
$assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$assigneeQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
// For negation, AND the conditions (all columns must not match).
|
||||
// For normal LIKE, OR them (any column matching is sufficient).
|
||||
if ($operator === 'exact') {
|
||||
$assigneeQuery->orWhere($table.'.'.$column, '=', $filterValue);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$assigneeQuery->where($table.'.'.$column, '!=', $filterValue);
|
||||
} else {
|
||||
$negate
|
||||
? $assigneeQuery->where($table.'.'.$column, $likeOperator, '%'.$filterValue.'%')
|
||||
: $assigneeQuery->orWhere($table.'.'.$column, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
}
|
||||
|
||||
if ($assigneeType === User::class) {
|
||||
$assigneeQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
|
||||
["%{$filterValue}%"]
|
||||
);
|
||||
$concatSql = $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']);
|
||||
|
||||
if ($operator === 'exact') {
|
||||
$assigneeQuery->orWhereRaw(str_replace(' LIKE ?', ' = ?', $concatSql), [$filterValue]);
|
||||
} elseif ($operator === 'exact_not') {
|
||||
$assigneeQuery->whereRaw(str_replace(' LIKE ?', ' <> ?', $concatSql), [$filterValue]);
|
||||
} else {
|
||||
$negate
|
||||
? $assigneeQuery->whereRaw(str_replace('LIKE', 'NOT LIKE', $concatSql), ["%{$filterValue}%"])
|
||||
: $assigneeQuery->orWhereRaw($concatSql, ["%{$filterValue}%"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -384,13 +654,154 @@ trait Searchable
|
||||
/**
|
||||
* Apply filtering on computed count aliases (for example withCount aliases).
|
||||
*/
|
||||
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue): Builder
|
||||
private function applyCountAliasFilter(Builder $query, string $countAlias, string $filterValue, string $boolean = 'and', bool $negate = false, bool $exact = false): Builder
|
||||
{
|
||||
$havingMethod = $boolean === 'or' ? 'orHaving' : 'having';
|
||||
|
||||
if (is_numeric($filterValue)) {
|
||||
return $query->having($countAlias, '=', (int) $filterValue);
|
||||
$operator = $negate ? '!=' : '=';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $operator, (int) $filterValue);
|
||||
}
|
||||
|
||||
return $query->having($countAlias, 'LIKE', '%'.$filterValue.'%');
|
||||
if ($exact) {
|
||||
$operator = $negate ? '!=' : '=';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $operator, $filterValue);
|
||||
}
|
||||
|
||||
$likeOperator = $negate ? 'NOT LIKE' : 'LIKE';
|
||||
|
||||
return $query->{$havingMethod}($countAlias, $likeOperator, '%'.$filterValue.'%');
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an IS NULL / IS NOT NULL filter for the given filter key.
|
||||
*
|
||||
* Supported targets:
|
||||
*
|
||||
* Direct attributes → WHERE col IS [NOT] NULL
|
||||
*
|
||||
* Virtual columns → IS NULL: all constituent columns must be null
|
||||
* IS NOT NULL: at least one constituent column must not be null
|
||||
*
|
||||
* Relation keys → IS NULL: doesntHave (no related record)
|
||||
* IS NOT NULL: whereHas (has a related record)
|
||||
*
|
||||
* Any unrecognised key is silently ignored.
|
||||
*/
|
||||
private function applyNullFilter(Builder $query, string $filterKey, bool $isNull, string $boolean = 'and'): Builder
|
||||
{
|
||||
$table = $this->getTable();
|
||||
$searchableAttributes = $this->getSearchableAttributes();
|
||||
|
||||
// Custom field db_column key (Asset only).
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$column = $table.'.'.$dbColumn;
|
||||
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
|
||||
if ($isNull) {
|
||||
$subQuery->whereNull($column)
|
||||
->orWhere($column, '=', '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery->whereNotNull($column)
|
||||
->where($column, '!=', '');
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
|
||||
// Direct attribute column.
|
||||
if (in_array($filterKey, $searchableAttributes, true)) {
|
||||
$column = $table.'.'.$filterKey;
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
|
||||
$query->{$method}(function (Builder $subQuery) use ($column, $isNull): void {
|
||||
if ($isNull) {
|
||||
$subQuery->whereNull($column)
|
||||
->orWhere($column, '=', '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subQuery->whereNotNull($column)
|
||||
->where($column, '!=', '');
|
||||
});
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Virtual columns (e.g. 'name' → ['first_name', 'last_name'] on User).
|
||||
$virtualColumns = $this->getSearchableVirtualColumns();
|
||||
|
||||
if (array_key_exists($filterKey, $virtualColumns)) {
|
||||
$qualifiedColumns = array_map(
|
||||
fn ($col) => $table.'.'.$col,
|
||||
$virtualColumns[$filterKey]
|
||||
);
|
||||
|
||||
if ($isNull) {
|
||||
// All constituent columns must be null (= no name at all).
|
||||
foreach ($qualifiedColumns as $col) {
|
||||
$query->whereNull($col);
|
||||
}
|
||||
} else {
|
||||
// At least one constituent column must have a value.
|
||||
$query->where(function (Builder $sub) use ($qualifiedColumns): void {
|
||||
foreach ($qualifiedColumns as $col) {
|
||||
$sub->orWhereNotNull($col);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
// Relation key: no related record = "null", has a related record = "not null".
|
||||
$searchableRelations = $this->getSearchableRelations();
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey !== null && $this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$method = $boolean === 'or' ? 'orWhere' : 'where';
|
||||
// Polymorphic assignment is present only when both columns are set; null matches either side missing.
|
||||
|
||||
if ($isNull) {
|
||||
$query->{$method}(function (Builder $assigneeNullQuery) use ($table): void {
|
||||
$assigneeNullQuery->whereNull($table.'.assigned_to')
|
||||
->orWhereNull($table.'.assigned_type');
|
||||
});
|
||||
} else {
|
||||
$query->{$method}(function (Builder $assigneeNotNullQuery) use ($table): void {
|
||||
$assigneeNotNullQuery->whereNotNull($table.'.assigned_to')
|
||||
->whereNotNull($table.'.assigned_type');
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
if ($resolvedRelationKey !== null) {
|
||||
if ($isNull) {
|
||||
$method = $boolean === 'or' ? 'orDoesntHave' : 'doesntHave';
|
||||
$query->{$method}($resolvedRelationKey);
|
||||
} else {
|
||||
$method = $boolean === 'or' ? 'orWhereHas' : 'whereHas';
|
||||
$query->{$method}($resolvedRelationKey);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -653,6 +1064,20 @@ trait Searchable
|
||||
return $this->searchableCounts ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get virtual column aliases defined on the model.
|
||||
*
|
||||
* These are filter keys that map to a set of real columns searched via
|
||||
* CONCAT — for example, "name" → ['first_name', 'last_name'] on User,
|
||||
* because "name" is not a real database column on that table.
|
||||
*
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
private function getSearchableVirtualColumns(): array
|
||||
{
|
||||
return $this->searchableVirtualColumns ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation aliases defined on the model.
|
||||
*
|
||||
@@ -672,6 +1097,36 @@ trait Searchable
|
||||
return $this->searchableRelationAliases ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get structured-filter relation columns for a given filter key.
|
||||
*
|
||||
* By default, this uses all configured searchable relation columns for the
|
||||
* resolved relation key. Models can narrow specific advanced-search fields
|
||||
* via $searchableRelationFilterColumns, keyed by the incoming filter key
|
||||
* shown in the UI/API (for example: 'location' => ['name']).
|
||||
*
|
||||
* @param array<string, array<int, string>> $searchableRelations
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getStructuredFilterRelationColumns(string $filterKey, string $resolvedRelationKey, array $searchableRelations): array
|
||||
{
|
||||
$defaultColumns = (array) ($searchableRelations[$resolvedRelationKey] ?? []);
|
||||
|
||||
$overrides = $this->searchableRelationFilterColumns ?? [];
|
||||
|
||||
if (! array_key_exists($filterKey, $overrides)) {
|
||||
return $defaultColumns;
|
||||
}
|
||||
|
||||
$overrideColumns = array_values(array_filter((array) $overrides[$filterKey], 'is_string'));
|
||||
|
||||
// Keep only columns that are actually searchable on the resolved relation,
|
||||
// so model-level overrides cannot accidentally reference unknown columns.
|
||||
$validColumns = array_values(array_intersect($overrideColumns, $defaultColumns));
|
||||
|
||||
return $validColumns !== [] ? $validColumns : $defaultColumns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table name of a relation.
|
||||
*
|
||||
@@ -729,6 +1184,9 @@ trait Searchable
|
||||
*/
|
||||
private function buildMultipleColumnSearch(array $columns): string
|
||||
{
|
||||
// This method deliberately returns only an SQL fragment ending with "LIKE ?"
|
||||
// so callers can reuse it and swap operators (NOT LIKE / =) without duplicating
|
||||
// driver-specific CONCAT syntax.
|
||||
$mappedColumns = collect($columns)->map(fn ($column) => DB::getTablePrefix().$column)->toArray();
|
||||
|
||||
$driver = config('database.connections.'.config('database.default').'.driver');
|
||||
|
||||
+83
-1
@@ -181,6 +181,44 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
'manages_locations_count',
|
||||
];
|
||||
|
||||
/**
|
||||
* Virtual column aliases that map a single filter key to a set of real columns
|
||||
* searched via CONCAT (SQL) so that, for example, filtering by "name" searches
|
||||
* across both first_name and last_name together.
|
||||
*
|
||||
* Because "name" is not a real column on the users table we cannot add it to
|
||||
* $searchableAttributes; this map bridges that gap for structured filter queries.
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
protected $searchableVirtualColumns = [
|
||||
'name' => ['first_name', 'last_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps filter/API keys to the actual Eloquent relation names used in
|
||||
* $searchableRelations. The User model uses "userloc" as its location
|
||||
* relation name (to avoid a collision with the framework's own "location"
|
||||
* magic), but every consumer — UI and API alike — sends the key "location".
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected $searchableRelationAliases = [
|
||||
'location' => 'userloc',
|
||||
];
|
||||
|
||||
/**
|
||||
* Narrow structured-filter relation columns for specific UI/API filter keys.
|
||||
*
|
||||
* The advanced-search "location" field represents the location name, so
|
||||
* structured filters should target only userloc.name (not address/city/etc).
|
||||
*
|
||||
* @var array<string, list<string>>
|
||||
*/
|
||||
protected $searchableRelationFilterColumns = [
|
||||
'location' => ['name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* This sets the name property on the user. It's not a real field in the database
|
||||
* (since we use first_name and last_name), but the Laravel mailable method
|
||||
@@ -687,6 +725,10 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
{
|
||||
return $this->belongsToMany(License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at');
|
||||
}
|
||||
public function directLicenses()
|
||||
{
|
||||
return $this->belongsToMany(\App\Models\License::class, 'license_seats', 'assigned_to', 'license_id')->withPivot('id', 'created_at', 'updated_at')->wherePivotNull('asset_id')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> reportTemplates relationship
|
||||
@@ -1351,7 +1393,47 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->orwhereRaw('CONCAT(users.first_name," ",users.last_name) LIKE \''.$search.'%\'');
|
||||
|
||||
}
|
||||
|
||||
public function scopeWithInventoryRelations($query, int $id)
|
||||
{
|
||||
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',
|
||||
'assets.licenses',
|
||||
'assets.licenses.category',
|
||||
'assets.assignedAccessories',
|
||||
'assets.assignedAccessories.accessory.category',
|
||||
'accessories.log' => fn ($query) => $query->withTrashed()
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $id)
|
||||
->where('action_type', 'accepted'),
|
||||
'accessories.category',
|
||||
'accessories.manufacturer',
|
||||
'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();
|
||||
}
|
||||
/**
|
||||
* Get all direct and indirect subordinates for this user.
|
||||
*
|
||||
|
||||
@@ -33,6 +33,7 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->note = $params['note'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +77,7 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
|
||||
])
|
||||
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
|
||||
|
||||
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
$this->settings = Setting::getSettings();
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
|
||||
])
|
||||
->attach($pdf_path)
|
||||
|
||||
@@ -13,8 +13,25 @@ class MaintenanceObserver
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function updated(Maintenance $maintenance)
|
||||
public function updating(Maintenance $maintenance)
|
||||
{
|
||||
$changed = [];
|
||||
|
||||
foreach ($maintenance->getRawOriginal() as $key => $value) {
|
||||
if (array_key_exists($key, $maintenance->getAttributes())
|
||||
&& $maintenance->getRawOriginal()[$key] != $maintenance->getAttributes()[$key]
|
||||
) {
|
||||
$changed[$key] = [
|
||||
'old' => $maintenance->getRawOriginal()[$key],
|
||||
'new' => $maintenance->getAttributes()[$key],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($changed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logAction = new Actionlog;
|
||||
$logAction->item_type = Maintenance::class;
|
||||
$logAction->item_id = $maintenance->id;
|
||||
@@ -23,6 +40,7 @@ class MaintenanceObserver
|
||||
$logAction->created_at = date('Y-m-d H:i:s');
|
||||
$logAction->action_date = date('Y-m-d H:i:s');
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->log_meta = json_encode($changed);
|
||||
if ($maintenance->imported) {
|
||||
$logAction->setActionSource('importer');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
/**
|
||||
* Policy for Asset Maintenances.
|
||||
*
|
||||
* A user may view or create maintenances on an asset if they have permission
|
||||
* to edit that asset. All other standard CRUD operations fall back to the
|
||||
* assets.edit permission, consistent with the rest of the application.
|
||||
*/
|
||||
final class MaintenancePolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
/**
|
||||
* Superusers and admins are handled globally in AuthServiceProvider::boot().
|
||||
* Company-scoping is enforced at the model level via CompanyableChildTrait.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Determine whether the user can list maintenances.
|
||||
* Requires asset edit permission (no specific asset to check against).
|
||||
*/
|
||||
public function index(User $user): bool
|
||||
{
|
||||
return $user->hasAccess('assets.view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view a specific maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function view(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create a maintenance record.
|
||||
* When checking against the class (no instance), fall back to assets.edit.
|
||||
* When an asset instance is provided via context, check update on that asset.
|
||||
*/
|
||||
public function create(User $user, ?Asset $asset = null): bool
|
||||
{
|
||||
if ($asset instanceof Asset) {
|
||||
return Gate::allows('update', $asset);
|
||||
}
|
||||
|
||||
return $user->hasAccess('assets.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update a maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function update(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete a maintenance record.
|
||||
* Allowed if the user can edit the associated asset and the record is not soft-deleted.
|
||||
*/
|
||||
public function delete(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return empty($maintenance->deleted_at)
|
||||
&& Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can upload or manage files attached to a maintenance record.
|
||||
* Allowed if the user can edit the associated asset.
|
||||
*/
|
||||
public function files(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('update', $maintenance->asset);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view history for a maintenance record.
|
||||
* Allowed when the user can view the maintenance itself, or has global activity view permission.
|
||||
*/
|
||||
public function history(User $user, Maintenance $maintenance): bool
|
||||
{
|
||||
return Gate::allows('view', $maintenance->asset)
|
||||
|| Gate::allows('view', $maintenance)
|
||||
|| $user->hasAccess('activity.view');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\CustomField;
|
||||
|
||||
final class CustomFieldPresenter
|
||||
{
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
public static function visibilityIconsArray(CustomField $field): array
|
||||
{
|
||||
$icons = [];
|
||||
|
||||
if ($field->display_checkout) {
|
||||
$label = e(trans('admin/custom_fields/general.display_checkout'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_checkin) {
|
||||
$label = e(trans('admin/custom_fields/general.display_checkin'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_audit) {
|
||||
$label = e(trans('admin/custom_fields/general.display_audit'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->display_in_user_view) {
|
||||
$label = e(trans('admin/custom_fields/general.display_in_user_view_table'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-user text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_listview) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_listview_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-list text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_email) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_email_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fas fa-envelope text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
if ($field->show_in_requestable_list) {
|
||||
$label = e(trans('admin/custom_fields/general.show_in_requestable_list_short'));
|
||||
$icons[] = '<span title="'.$label.'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted" aria-hidden="true"></i><span class="sr-only">'.$label.'</span></span>';
|
||||
}
|
||||
|
||||
return $icons;
|
||||
}
|
||||
|
||||
public static function visibilityIcons(CustomField $field): string
|
||||
{
|
||||
return implode(' ', self::visibilityIconsArray($field));
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,13 @@ class LicensePresenter extends Presenter
|
||||
{
|
||||
$layout = [
|
||||
[
|
||||
'field' => 'checkbox',
|
||||
'checkbox' => true,
|
||||
'formatter' => 'checkboxEnabledFormatter',
|
||||
'titleTooltip' => trans('general.select_all_none'),
|
||||
'printIgnore' => true,
|
||||
'class' => 'hidden-print',
|
||||
], [
|
||||
'field' => 'id',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
@@ -115,7 +122,7 @@ class LicensePresenter extends Presenter
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'switchable' => true,
|
||||
'title' => '% ' . trans('general.remaining'),
|
||||
'title' => '% '.trans('general.remaining'),
|
||||
'visible' => true,
|
||||
'formatter' => 'progressBarFormatter',
|
||||
], [
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Department;
|
||||
use App\Models\Depreciation;
|
||||
use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Maintenance;
|
||||
use App\Models\Manufacturer;
|
||||
use App\Models\PredefinedKit;
|
||||
use App\Models\Statuslabel;
|
||||
@@ -33,6 +34,7 @@ use App\Policies\DepartmentPolicy;
|
||||
use App\Policies\DepreciationPolicy;
|
||||
use App\Policies\LicensePolicy;
|
||||
use App\Policies\LocationPolicy;
|
||||
use App\Policies\MaintenancePolicy;
|
||||
use App\Policies\ManufacturerPolicy;
|
||||
use App\Policies\PredefinedKitPolicy;
|
||||
use App\Policies\StatuslabelPolicy;
|
||||
@@ -68,6 +70,7 @@ class AuthServiceProvider extends ServiceProvider
|
||||
Depreciation::class => DepreciationPolicy::class,
|
||||
License::class => LicensePolicy::class,
|
||||
Location::class => LocationPolicy::class,
|
||||
Maintenance::class => MaintenancePolicy::class,
|
||||
PredefinedKit::class => PredefinedKitPolicy::class,
|
||||
Statuslabel::class => StatuslabelPolicy::class,
|
||||
Supplier::class => SupplierPolicy::class,
|
||||
|
||||
@@ -23,7 +23,7 @@ class NumericEncrypted implements ValidationRule
|
||||
try {
|
||||
$attributeName = trim(preg_replace('/_+|snipeit|\d+/', ' ', $attribute));
|
||||
$decrypted = Crypt::decrypt($value);
|
||||
if (!$this->validateNumeric($attributeName, $decrypted) && !is_null($decrypted)) {
|
||||
if (!$this->validateNumeric($attributeName, $decrypted, []) && !is_null($decrypted)) {
|
||||
$fail(trans('validation.numeric', ['attribute' => $attributeName]));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
|
||||
+6
-6
@@ -25,7 +25,7 @@
|
||||
"ext-mbstring": "*",
|
||||
"ext-pdo": "*",
|
||||
"alek13/slack": "^2.0",
|
||||
"arietimmerman/laravel-scim-server": "dev-master",
|
||||
"arietimmerman/laravel-scim-server": "dev-scimv2_with_logging",
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
"doctrine/cache": "^1.10",
|
||||
"doctrine/dbal": "^3.1",
|
||||
@@ -40,7 +40,7 @@
|
||||
"javiereguiluz/easyslugger": "^1.0",
|
||||
"laravel-notification-channels/google-chat": "^3.0",
|
||||
"laravel-notification-channels/microsoft-teams": "^1.2",
|
||||
"laravel/framework": "^11.0",
|
||||
"laravel/framework": "^12.0",
|
||||
"laravel/helpers": "^1.4",
|
||||
"laravel/passport": "^12.0",
|
||||
"laravel/slack-notification-channel": "^3.4",
|
||||
@@ -60,10 +60,10 @@
|
||||
"paragonie/constant_time_encoding": "^2.3",
|
||||
"paragonie/sodium_compat": "^1.19",
|
||||
"phpdocumentor/reflection-docblock": "^5.1",
|
||||
"phpspec/prophecy": "^1.10",
|
||||
"phpspec/prophecy": "^1.26",
|
||||
"pragmarx/google2fa-laravel": "^1.3",
|
||||
"rollbar/rollbar-laravel": "^8.0",
|
||||
"spatie/laravel-backup": "^8.8",
|
||||
"spatie/laravel-backup": "^9.0",
|
||||
"spatie/laravel-ignition": "^2.0",
|
||||
"tabuna/breadcrumbs": "^4.2",
|
||||
"tecnickcom/tc-lib-barcode": "^1.15",
|
||||
@@ -79,13 +79,13 @@
|
||||
},
|
||||
"require-dev": {
|
||||
"fruitcake/laravel-debugbar": "^4.0",
|
||||
"larastan/larastan": "^2.9",
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/pint": "^1.29",
|
||||
"laravel/telescope": "^5.11",
|
||||
"mockery/mockery": "^1.4",
|
||||
"nunomaduro/phpinsights": "^2.11",
|
||||
"php-mock/php-mock-phpunit": "^2.10",
|
||||
"phpunit/phpunit": "^10.0",
|
||||
"phpunit/phpunit": "^11.0",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"symfony/css-selector": "^4.4",
|
||||
"symfony/dom-crawler": "^4.4"
|
||||
|
||||
Generated
+1180
-805
File diff suppressed because it is too large
Load Diff
+2
-9
@@ -100,21 +100,14 @@ return [
|
||||
'email' => 'auth.emails.password',
|
||||
'table' => 'password_resets',
|
||||
'expire' => env('RESET_PASSWORD_LINK_EXPIRES', 900),
|
||||
'throttle' => [
|
||||
'max_attempts' => env('LOGIN_MAX_ATTEMPTS', 5),
|
||||
'lockout_duration' => env('LOGIN_LOCKOUT_DURATION', 60),
|
||||
],
|
||||
|
||||
'throttle' => env('LOGIN_MAX_ATTEMPTS', 5),
|
||||
],
|
||||
|
||||
'invites' => [
|
||||
'provider' => 'users',
|
||||
'table' => 'password_resets',
|
||||
'expire' => env('INVITE_PASSWORD_LINK_EXPIRES', 2880),
|
||||
'throttle' => [
|
||||
'max_attempts' => env('LOGIN_MAX_ATTEMPTS', 5),
|
||||
'lockout_duration' => env('LOGIN_LOCKOUT_DURATION', 60),
|
||||
],
|
||||
'throttle' => env('LOGIN_MAX_ATTEMPTS', 5),
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
+6
-3
@@ -119,7 +119,6 @@ return [
|
||||
'temporary_directory' => storage_path('app/backup-temp'),
|
||||
|
||||
// 'encryption' => \ZipArchive::EM_AES_256,
|
||||
'encryption' => null,
|
||||
],
|
||||
|
||||
/*
|
||||
@@ -147,10 +146,14 @@ return [
|
||||
'notifiable' => Notifiable::class,
|
||||
|
||||
'mail' => [
|
||||
'to' => env('MAIL_BACKUP_NOTIFICATION_ADDRESS', null),
|
||||
// it might seem weird to set an address here, but laravel-backup will crash
|
||||
// after running "composer install" without a valid email address here. It
|
||||
// _shouldn't_ matter, since if you don't set MAIL_BACKUP_NOTIFICATION_DRIVER
|
||||
// then no emails will be sent at all (each notification is assigned to 'null')
|
||||
'to' => env('MAIL_BACKUP_NOTIFICATION_ADDRESS') ?? 'hello@example.com',
|
||||
|
||||
'from' => [
|
||||
'address' => env('MAIL_FROM_ADDR', 'hello@example.com'),
|
||||
'address' => filter_var(env('MAIL_FROM_ADDR'), FILTER_VALIDATE_EMAIL) ?: 'hello@example.com',
|
||||
'name' => env('MAIL_FROM_NAME', 'Example'),
|
||||
],
|
||||
],
|
||||
|
||||
+7
-8
@@ -1,11 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'app_version' => 'v8.4.1',
|
||||
'full_app_version' => 'v8.4.1 - build 22183-g5898205480',
|
||||
'build_version' => '22183',
|
||||
return array(
|
||||
'app_version' => 'v8.5.0',
|
||||
'full_app_version' => 'v8.5.0 - build 22652-g80d1bf6a7a',
|
||||
'build_version' => '22652',
|
||||
'prerelease_version' => '',
|
||||
'hash_version' => 'g5898205480',
|
||||
'full_hash' => 'v8.4.1-901-g5898205480',
|
||||
'hash_version' => 'g80d1bf6a7a',
|
||||
'full_hash' => 'v8.5.0-467-g80d1bf6a7a',
|
||||
'branch' => 'master',
|
||||
];
|
||||
);
|
||||
@@ -314,7 +314,7 @@ class AssetFactory extends Factory
|
||||
{
|
||||
return $this->state(function () {
|
||||
return [
|
||||
'model_id' => 1,
|
||||
'model_id' => AssetModel::factory(),
|
||||
'assigned_to' => Asset::factory(),
|
||||
'assigned_type' => Asset::class,
|
||||
];
|
||||
|
||||
@@ -103,7 +103,7 @@ class CheckoutAcceptanceFactory extends Factory
|
||||
$acceptance->checkoutable->assetlog()->create([
|
||||
'action_type' => 'checkout',
|
||||
'target_id' => $acceptance->assigned_to_id,
|
||||
'target_type' => get_class($acceptance->assignedTo),
|
||||
'target_type' => User::class,
|
||||
'item_id' => $acceptance->checkoutable_id,
|
||||
'item_type' => $acceptance->checkoutable_type,
|
||||
]);
|
||||
|
||||
@@ -11,7 +11,8 @@ return new class extends Migration
|
||||
public function up(): void
|
||||
{
|
||||
//
|
||||
Artisan::call('snipeit:clean-checkout-acceptances');
|
||||
//Artisan::call('snipeit:clean-checkout-acceptances');
|
||||
// Commenting this out to prevent crashing due to a missing deleted_at clause
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('permission_groups', function (Blueprint $table) {
|
||||
$table->string('scim_externalid')->nullable()->default(null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('permission_groups', function (Blueprint $table) {
|
||||
$table->dropColumn('scim_externalid');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
//
|
||||
Artisan::call('snipeit:clean-checkout-acceptances');
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
$table->index(['parent_id','deleted_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('locations', function (Blueprint $table) {
|
||||
$table->dropIndex(['parent_id','deleted_at']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('accessories_checkout', function (Blueprint $table) {
|
||||
$table->index(['assigned_to','assigned_type']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('accessories_checkout', function (Blueprint $table) {
|
||||
$table->dropIndex(['assigned_to','assigned_type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Backfill action_logs.company_id only for legacy asset audits where the
|
||||
* value is currently NULL.
|
||||
*
|
||||
* Audits are only recorded on assets, so this migration intentionally scopes
|
||||
* to action_type='audit' and item_type=App\Models\Asset.
|
||||
*
|
||||
* Rows whose asset genuinely has no company (assets.company_id IS NULL) are
|
||||
* left as NULL.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
private const ASSET_CLASS = 'App\\Models\\Asset';
|
||||
|
||||
private const AUDIT_ACTION = 'audit';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->updateAssetAuditLogs(DB::getDriverName());
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// This backfill is intentionally non-reversible — we cannot know which
|
||||
// rows were NULL before the migration ran vs which were backfilled.
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp company_id for legacy audit rows tied to assets.
|
||||
*/
|
||||
private function updateAssetAuditLogs(string $driver): void
|
||||
{
|
||||
if ($driver === 'mysql' || $driver === 'mariadb') {
|
||||
// MySQL/MariaDB supports UPDATE ... JOIN directly
|
||||
DB::statement('
|
||||
UPDATE action_logs al
|
||||
INNER JOIN assets src
|
||||
ON src.id = al.item_id
|
||||
AND src.company_id IS NOT NULL
|
||||
SET al.company_id = src.company_id
|
||||
WHERE al.action_type = ?
|
||||
AND al.item_type = ?
|
||||
AND al.company_id IS NULL
|
||||
AND al.deleted_at IS NULL
|
||||
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
|
||||
} else {
|
||||
// SQLite / PostgreSQL: use a correlated subquery update
|
||||
DB::statement('
|
||||
UPDATE action_logs
|
||||
SET company_id = (
|
||||
SELECT src.company_id
|
||||
FROM assets src
|
||||
WHERE src.id = action_logs.item_id
|
||||
AND src.company_id IS NOT NULL
|
||||
LIMIT 1
|
||||
)
|
||||
WHERE action_type = ?
|
||||
AND item_type = ?
|
||||
AND company_id IS NULL
|
||||
AND deleted_at IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM assets src2
|
||||
WHERE src2.id = action_logs.item_id
|
||||
AND src2.company_id IS NOT NULL
|
||||
)
|
||||
', [self::AUDIT_ACTION, self::ASSET_CLASS]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
|
||||
Artisan::call('saml:clear_expired_nonces');
|
||||
Schema::table('saml_nonces', function (Blueprint $table) {
|
||||
$table->dropIndex(['nonce']);
|
||||
$table->unique('nonce');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('saml_nonces', function (Blueprint $table) {
|
||||
$table->dropUnique(['nonce']);
|
||||
$table->index('nonce');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'manage' => 'crwdns6501:0crwdne6501:0',
|
||||
'field' => 'crwdns1487:0crwdne1487:0',
|
||||
'about_fieldsets_title' => 'crwdns1488:0crwdne1488:0',
|
||||
'about_fieldsets_text' => 'crwdns14566:0crwdne14566:0',
|
||||
'about_fieldsets_text' => 'crwdns14734:0crwdne14734:0',
|
||||
'custom_format' => 'crwdns6505:0crwdne6505:0',
|
||||
'encrypt_field' => 'crwdns1792:0crwdne1792:0',
|
||||
'encrypt_field_help' => 'crwdns1683:0crwdne1683:0',
|
||||
|
||||
@@ -99,6 +99,9 @@ return [
|
||||
'success' => 'crwdns753:0crwdne753:0',
|
||||
'user_does_not_exist' => 'crwdns754:0crwdne754:0',
|
||||
'already_checked_in' => 'crwdns1603:0crwdne1603:0',
|
||||
'force_checkin_orphaned_success' => 'crwdns14742:0crwdne14742:0',
|
||||
'force_checkin_not_orphaned' => 'crwdns14744:0crwdne14744:0',
|
||||
'force_checkin_error' => 'crwdns14746:0crwdne14746:0',
|
||||
|
||||
],
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ return [
|
||||
'confirm' => 'crwdns943:0crwdne943:0',
|
||||
'error' => 'crwdns944:0crwdne944:0',
|
||||
'success' => 'crwdns945:0crwdne945:0',
|
||||
'bulk_success' => 'crwdns14793:0crwdne14793:0',
|
||||
'partial_success' => 'crwdns14795:0crwdne14795:0',
|
||||
'bulk_checkout_warning' => 'crwdns14797:0crwdne14797:0',
|
||||
],
|
||||
|
||||
'checkout' => [
|
||||
|
||||
@@ -438,7 +438,7 @@ return [
|
||||
'timezone' => 'crwdns11831:0crwdne11831:0',
|
||||
'test_mail' => 'crwdns12973:0crwdne12973:0',
|
||||
'profile_edit' => 'crwdns12280:0crwdne12280:0',
|
||||
'profile_edit_help' => 'crwdns12282:0crwdne12282:0',
|
||||
'profile_edit_help' => 'crwdns14789:0crwdne14789:0',
|
||||
'default_avatar' => 'crwdns12987:0crwdne12987:0',
|
||||
'default_avatar_help' => 'crwdns12590:0crwdne12590:0',
|
||||
'restore_default_avatar' => 'crwdns12592:0crwdne12592:0',
|
||||
|
||||
@@ -122,7 +122,7 @@ return [
|
||||
'debug_warning_text' => 'crwdns1828:0crwdne1828:0',
|
||||
'delete' => 'crwdns1046:0crwdne1046:0',
|
||||
'delete_confirm' => 'crwdns2020:0crwdne2020:0',
|
||||
'delete_confirm_no_undo' => 'crwdns14568:0crwdne14568:0',
|
||||
'delete_confirm_no_undo' => 'crwdns14736:0crwdne14736:0',
|
||||
'deleted' => 'crwdns1047:0crwdne1047:0',
|
||||
'delete_seats' => 'crwdns1430:0crwdne1430:0',
|
||||
'deletion_failed' => 'crwdns6117:0crwdne6117:0',
|
||||
@@ -167,7 +167,7 @@ return [
|
||||
'image_upload' => 'crwdns1058:0crwdne1058:0',
|
||||
'filetypes_accepted_help' => 'crwdns12622:0crwdne12622:0',
|
||||
'filetypes_size_help' => 'crwdns12624:0crwdne12624:0',
|
||||
'image_filetypes_help' => 'crwdns14570:0crwdne14570:0',
|
||||
'image_filetypes_help' => 'crwdns14738:0crwdne14738:0',
|
||||
'unaccepted_image_type' => 'crwdns11365:0crwdne11365:0',
|
||||
'import' => 'crwdns1411:0crwdne1411:0',
|
||||
'documentation' => 'crwdns14462:0crwdne14462:0',
|
||||
@@ -196,6 +196,7 @@ return [
|
||||
'license' => 'crwdns1060:0crwdne1060:0',
|
||||
'license_report' => 'crwdns1141:0crwdne1141:0',
|
||||
'licenses_available' => 'crwdns12172:0crwdne12172:0',
|
||||
'licenses_with_no_seats' => 'crwdns14761:0crwdne14761:0',
|
||||
'licenses' => 'crwdns1062:0crwdne1062:0',
|
||||
'list_all' => 'crwdns1063:0crwdne1063:0',
|
||||
'loading' => 'crwdns12628:0crwdne12628:0',
|
||||
@@ -484,8 +485,22 @@ return [
|
||||
'set_to_null' => 'crwdns12738:0crwdne12738:0',
|
||||
'set_users_field_to_null' => 'crwdns11449:0crwdne11449:0',
|
||||
'na_no_purchase_date' => 'crwdns10540:0crwdne10540:0',
|
||||
'assets_by_category' => 'crwdns14763:0crwdne14763:0',
|
||||
'assets_by_status' => 'crwdns10542:0crwdne10542:0',
|
||||
'assets_by_status_type' => 'crwdns10544:0crwdne10544:0',
|
||||
'activity_overview' => 'crwdns14765:0crwdne14765:0',
|
||||
'checkouts_checkins' => 'crwdns14767:0crwdne14767:0',
|
||||
'assets_newly_added' => 'crwdns14769:0crwdne14769:0',
|
||||
'checkouts' => 'crwdns14771:0crwdne14771:0',
|
||||
'checkins' => 'crwdns14773:0crwdne14773:0',
|
||||
'assets' => 'crwdns14775:0crwdne14775:0',
|
||||
|
||||
'vs_prior_period' => 'crwdns14777:0crwdne14777:0',
|
||||
'time_range' => 'crwdns14779:0crwdne14779:0',
|
||||
'last_n_days' => 'crwdns14781:0crwdne14781:0',
|
||||
'custom_range' => 'crwdns14783:0crwdne14783:0',
|
||||
'download_chart' => 'crwdns14785:0crwdne14785:0',
|
||||
'fullscreen' => 'crwdns14787:0crwdne14787:0',
|
||||
'pie_chart_type' => 'crwdns10546:0crwdne10546:0',
|
||||
'hello_name' => 'crwdns10548:0crwdne10548:0',
|
||||
'unaccepted_profile_warning' => 'crwdns12686:0crwdne12686:0',
|
||||
@@ -599,10 +614,16 @@ return [
|
||||
'status_compatibility' => 'crwdns11910:0crwdne11910:0',
|
||||
'rtd_location_help' => 'crwdns11912:0crwdne11912:0',
|
||||
'item_not_found' => 'crwdns11914:0crwdne11914:0',
|
||||
'item_target_not_found_hard' => 'crwdns14748:0crwdne14748:0',
|
||||
'force_checkin' => 'crwdns14750:0crwdne14750:0',
|
||||
'item_not_found_short' => 'crwdns14752:0crwdne14752:0',
|
||||
'action_permission_denied' => 'crwdns11916:0crwdne11916:0',
|
||||
'action_permission_generic' => 'crwdns11918:0crwdne11918:0',
|
||||
'edit' => 'crwdns11920:0crwdne11920:0',
|
||||
'search_operator' => 'crwdns14754:0crwdne14754:0',
|
||||
'and' => 'crwdns14756:0crwdne14756:0',
|
||||
'action_source' => 'crwdns11924:0crwdne11924:0',
|
||||
'search_tip' => 'crwdns14759:0crwdne14759:0',
|
||||
'or' => 'crwdns12024:0crwdne12024:0',
|
||||
'url' => 'crwdns12054:0crwdne12054:0',
|
||||
'phone' => 'crwdns13254:0crwdne13254:0',
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'am-ET' => 'crwdns11934:0crwdne11934:0',
|
||||
'af-ZA' => 'crwdns11936:0crwdne11936:0',
|
||||
'ar-SA' => 'crwdns11938:0crwdne11938:0',
|
||||
'hy-AM' => 'crwdns14740:0crwdne14740:0',
|
||||
'bg-BG' => 'crwdns11940:0crwdne11940:0',
|
||||
'zh-CN' => 'crwdns10572:0crwdne10572:0',
|
||||
'zh-TW' => 'crwdns10574:0crwdne10574:0',
|
||||
|
||||
@@ -48,6 +48,7 @@ return [
|
||||
'asset_tag' => 'crwdns6068:0crwdne6068:0',
|
||||
'assets_warrantee_alert' => 'crwdns13812:0crwdne13812:0',
|
||||
'assigned_to' => 'crwdns1714:0crwdne1714:0',
|
||||
'assigned_to_assets' => 'crwdns14791:0crwdne14791:0',
|
||||
'eol' => 'crwdns13814:0crwdne13814:0',
|
||||
'best_regards' => 'crwdns1715:0crwdne1715:0',
|
||||
'canceled' => 'crwdns12718:0crwdne12718:0',
|
||||
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'manage' => 'Manage',
|
||||
'field' => 'veld',
|
||||
'about_fieldsets_title' => 'Oor Fieldsets',
|
||||
'about_fieldsets_text' => 'Fieldsets allow you to create groups of custom fields that are frequently re-used for specific asset model types.',
|
||||
'about_fieldsets_text' => 'Veldstelle stel jou in staat om groepe van persoonlike velde te skep wat gereeld hergebruik word vir spesifieke tipe bates.',
|
||||
'custom_format' => 'Custom Regex format...',
|
||||
'encrypt_field' => 'Enkripteer die waarde van hierdie veld in die databasis',
|
||||
'encrypt_field_help' => 'WAARSKUWING: Om \'n veld te enkripteer, maak dit onondersoekbaar.',
|
||||
|
||||
@@ -99,6 +99,9 @@ return [
|
||||
'success' => 'Die bate is suksesvol nagegaan.',
|
||||
'user_does_not_exist' => 'Die gebruiker is ongeldig. Probeer asseblief weer.',
|
||||
'already_checked_in' => 'Daardie bate is reeds nagegaan.',
|
||||
'force_checkin_orphaned_success' => 'Invalid assignment cleared successfully.',
|
||||
'force_checkin_not_orphaned' => 'Item is not in an invalid assignment state.',
|
||||
'force_checkin_error' => 'Could not clear invalid assignment.',
|
||||
|
||||
],
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ return [
|
||||
'confirm' => 'Is jy seker jy wil hierdie lisensie uitvee?',
|
||||
'error' => 'Daar was \'n probleem met die verwydering van die lisensie. Probeer asseblief weer.',
|
||||
'success' => 'Die lisensie is suksesvol verwyder.',
|
||||
'bulk_success' => 'The selected licenses were deleted successfully.',
|
||||
'partial_success' => 'License deleted successfully. See additional information below. | :count licenses were deleted successfully. See additional information below.',
|
||||
'bulk_checkout_warning' => ':license_name has seats that are currently checked out and cannot be deleted. Please check in all seats before deleting.',
|
||||
],
|
||||
|
||||
'checkout' => [
|
||||
|
||||
@@ -438,7 +438,7 @@ return [
|
||||
'timezone' => 'Timezone',
|
||||
'test_mail' => 'Test Mail',
|
||||
'profile_edit' => 'Edit Profile',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles.',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles. (This does not include accessibility settings like light/dark mode, audio feedback, or link colors.)',
|
||||
'default_avatar' => 'Custom Default Avatar',
|
||||
'default_avatar_help' => 'This image will be displayed as a profile if a user does not have a profile photo.',
|
||||
'restore_default_avatar' => 'Restore <a href=":default_avatar" data-toggle="lightbox" data-type="image">original system default avatar</a>',
|
||||
|
||||
@@ -122,7 +122,7 @@ return [
|
||||
'debug_warning_text' => 'Hierdie program word uitgevoer in die produksiemodus met debugging aangeskakel. Dit kan sensitiewe data blootstel indien u aansoek vir die buitewêreld toeganklik is. Deaktiveer debug-modus deur die <code>APP_DEBUG</code>-waarde in jou <code>.env</code>-lêer te stel na <code>false</code>.',
|
||||
'delete' => 'verwyder',
|
||||
'delete_confirm' => 'Are you sure you wish to delete :item?',
|
||||
'delete_confirm_no_undo' => 'Are you sure, you wish to delete :item? This cannot be undone.',
|
||||
'delete_confirm_no_undo' => 'Are you sure you wish to delete :item? This cannot be undone.',
|
||||
'deleted' => 'geskrap',
|
||||
'delete_seats' => 'Plekke verwyder',
|
||||
'deletion_failed' => 'Deletion failed',
|
||||
@@ -167,7 +167,7 @@ return [
|
||||
'image_upload' => 'Laai prent op',
|
||||
'filetypes_accepted_help' => 'Accepted filetype is :types. The maximum size allowed is :size.|Accepted filetypes are :types. The maximum upload size allowed is :size.',
|
||||
'filetypes_size_help' => 'The maximum upload size allowed is :size.',
|
||||
'image_filetypes_help' => 'Accepted Filetypes are jpg, webp, png, gif, svg, and avif. The maximum upload size allowed is :size.',
|
||||
'image_filetypes_help' => 'Accepted filetypes are jpg, webp, png, gif, svg, and avif. The maximum upload size allowed is :size.',
|
||||
'unaccepted_image_type' => 'This image file was not readable. Accepted filetypes are jpg, webp, png, gif, and svg. The mimetype of this file is: :mimetype.',
|
||||
'import' => 'invoer',
|
||||
'documentation' => 'Open documentation in a new link',
|
||||
@@ -196,6 +196,7 @@ return [
|
||||
'license' => 'lisensie',
|
||||
'license_report' => 'Lisensie Verslag',
|
||||
'licenses_available' => 'Licenses available',
|
||||
'licenses_with_no_seats' => 'Licenses with No Available Seats',
|
||||
'licenses' => 'lisensies',
|
||||
'list_all' => 'Lys almal',
|
||||
'loading' => 'Loading... please wait...',
|
||||
@@ -485,8 +486,22 @@ return [
|
||||
'set_to_null' => 'Delete values for this selection|Delete values for all :selection_count selections ',
|
||||
'set_users_field_to_null' => 'Delete :field values for this user|Delete :field values for all :user_count users ',
|
||||
'na_no_purchase_date' => 'N/A - No purchase date provided',
|
||||
'assets_by_category' => 'Assets by Category',
|
||||
'assets_by_status' => 'Assets by Status',
|
||||
'assets_by_status_type' => 'Assets by Status Type',
|
||||
'activity_overview' => 'Activity Overview',
|
||||
'checkouts_checkins' => 'Checkouts & Check-ins',
|
||||
'assets_newly_added' => 'Assets Added',
|
||||
'checkouts' => 'Checkouts',
|
||||
'checkins' => 'Check-ins',
|
||||
'assets' => 'bates',
|
||||
|
||||
'vs_prior_period' => 'vs. prior period',
|
||||
'time_range' => 'Select Date Range',
|
||||
'last_n_days' => 'Last :days Days',
|
||||
'custom_range' => 'Custom Range',
|
||||
'download_chart' => 'Download Chart as PNG',
|
||||
'fullscreen' => 'Fullscreen',
|
||||
'pie_chart_type' => 'Dashboard Pie Chart Type',
|
||||
'hello_name' => 'Hello, :name!',
|
||||
'unaccepted_profile_warning' => 'You have one item requiring acceptance. Click here to accept or decline it | You have :count items requiring acceptance. Click here to accept or decline them',
|
||||
@@ -600,10 +615,16 @@ return [
|
||||
'status_compatibility' => 'If assets are already assigned, they cannot be changed to a non-deployable status type and this value change will be skipped.',
|
||||
'rtd_location_help' => 'This is the location of the asset when it is not checked out',
|
||||
'item_not_found' => ':item_type ID :id does not exist or has been deleted',
|
||||
'item_target_not_found_hard' => ':item_type ID :id does not exist or has been hard-deleted. Would you like to force a checkin?',
|
||||
'force_checkin' => 'Force Checkin',
|
||||
'item_not_found_short' => ':item_type ID :id does not exist',
|
||||
'action_permission_denied' => 'You do not have permission to :action :item_type ID :id',
|
||||
'action_permission_generic' => 'You do not have permission to :action this :item_type',
|
||||
'edit' => 'wysig',
|
||||
'search_operator' => 'Search operator',
|
||||
'and' => 'and',
|
||||
'action_source' => 'Action Source',
|
||||
'search_tip' => 'Searches return a partial match by default. For more specific results, you can use <code>not:value</code> to fuzzy-exclude, <code>is:value</code> for an exact match, <code>is_not:value</code> for an exact exclusion, <code>is:null</code> for empty values, and <code>is:not_null</code> for non-empty. (In these examples, <code>value</code> is the text you are searching for.)',
|
||||
'or' => 'or',
|
||||
'url' => 'URL',
|
||||
'phone' => 'Foon',
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'am-ET' => 'Amharic',
|
||||
'af-ZA' => 'Afrikaans',
|
||||
'ar-SA' => 'Arabic',
|
||||
'hy-AM' => 'Armenian',
|
||||
'bg-BG' => 'Bulgarian',
|
||||
'zh-CN' => 'Chinese Simplified',
|
||||
'zh-TW' => 'Chinese Traditional',
|
||||
|
||||
@@ -48,6 +48,7 @@ return [
|
||||
'asset_tag' => 'Bate-tag',
|
||||
'assets_warrantee_alert' => 'There is :count asset with an expiring warranty or that are reaching their end of life in the next :threshold days.|There are :count assets with expiring warranties or that are reaching their end of life in the next :threshold days.',
|
||||
'assigned_to' => 'Toevertrou aan',
|
||||
'assigned_to_assets' => 'Assignments to Assets',
|
||||
'eol' => 'EOL',
|
||||
'best_regards' => 'Beste wense,',
|
||||
'canceled' => 'Gekanselleer',
|
||||
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'manage' => 'Manage',
|
||||
'field' => 'Field',
|
||||
'about_fieldsets_title' => 'About Fieldsets',
|
||||
'about_fieldsets_text' => 'Fieldsets allow you to create groups of custom fields that are frequently re-used for specific asset model types.',
|
||||
'about_fieldsets_text' => 'Fieldsets allow you to create groups of custom fields that are frequently re-used used for specific asset model types.',
|
||||
'custom_format' => 'Custom Regex format...',
|
||||
'encrypt_field' => 'Encrypt the value of this field in the database',
|
||||
'encrypt_field_help' => 'WARNING: Encrypting a field makes it unsearchable.',
|
||||
|
||||
@@ -99,6 +99,9 @@ return [
|
||||
'success' => 'Asset checked in successfully.',
|
||||
'user_does_not_exist' => 'That user is invalid. Please try again.',
|
||||
'already_checked_in' => 'That asset is already checked in.',
|
||||
'force_checkin_orphaned_success' => 'Invalid assignment cleared successfully.',
|
||||
'force_checkin_not_orphaned' => 'Item is not in an invalid assignment state.',
|
||||
'force_checkin_error' => 'Could not clear invalid assignment.',
|
||||
|
||||
],
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ return [
|
||||
'confirm' => 'Are you sure you wish to delete this license?',
|
||||
'error' => 'There was an issue deleting the license. Please try again.',
|
||||
'success' => 'The license was deleted successfully.',
|
||||
'bulk_success' => 'The selected licenses were deleted successfully.',
|
||||
'partial_success' => 'License deleted successfully. See additional information below. | :count licenses were deleted successfully. See additional information below.',
|
||||
'bulk_checkout_warning' => ':license_name has seats that are currently checked out and cannot be deleted. Please check in all seats before deleting.',
|
||||
],
|
||||
|
||||
'checkout' => [
|
||||
|
||||
@@ -438,7 +438,7 @@ return [
|
||||
'timezone' => 'Timezone',
|
||||
'test_mail' => 'Test Mail',
|
||||
'profile_edit' => 'Edit Profile',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles.',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles. (This does not include accessibility settings like light/dark mode, audio feedback, or link colors.)',
|
||||
'default_avatar' => 'Custom Default Avatar',
|
||||
'default_avatar_help' => 'This image will be displayed as a profile if a user does not have a profile photo.',
|
||||
'restore_default_avatar' => 'Restore <a href=":default_avatar" data-toggle="lightbox" data-type="image">original system default avatar</a>',
|
||||
|
||||
@@ -122,7 +122,7 @@ return [
|
||||
'debug_warning_text' => 'This application is running in production mode with debugging enabled. This can expose sensitive data if your application is accessible to the outside world. Disable debug mode by setting the <code>APP_DEBUG</code> value in your <code>.env</code> file to <code>false</code>.',
|
||||
'delete' => 'Delete',
|
||||
'delete_confirm' => 'Are you sure you wish to delete :item?',
|
||||
'delete_confirm_no_undo' => 'Are you sure, you wish to delete :item? This cannot be undone.',
|
||||
'delete_confirm_no_undo' => 'Are you sure you wish to delete :item? This cannot be undone.',
|
||||
'deleted' => 'Deleted',
|
||||
'delete_seats' => 'Deleted Seats',
|
||||
'deletion_failed' => 'Deletion failed',
|
||||
@@ -167,7 +167,7 @@ return [
|
||||
'image_upload' => 'Upload Image',
|
||||
'filetypes_accepted_help' => 'Accepted filetype is :types. The maximum size allowed is :size.|Accepted filetypes are :types. The maximum upload size allowed is :size.',
|
||||
'filetypes_size_help' => 'The maximum upload size allowed is :size.',
|
||||
'image_filetypes_help' => 'Accepted Filetypes are jpg, webp, png, gif, svg, and avif. The maximum upload size allowed is :size.',
|
||||
'image_filetypes_help' => 'Accepted filetypes are jpg, webp, png, gif, svg, and avif. The maximum upload size allowed is :size.',
|
||||
'unaccepted_image_type' => 'This image file was not readable. Accepted filetypes are jpg, webp, png, gif, and svg. The mimetype of this file is: :mimetype.',
|
||||
'import' => 'Import',
|
||||
'documentation' => 'Open documentation in a new link',
|
||||
@@ -196,6 +196,7 @@ return [
|
||||
'license' => 'License',
|
||||
'license_report' => 'License Report',
|
||||
'licenses_available' => 'Licenses available',
|
||||
'licenses_with_no_seats' => 'Licenses with No Available Seats',
|
||||
'licenses' => 'Licenses',
|
||||
'list_all' => 'List All',
|
||||
'loading' => 'Loading... please wait...',
|
||||
@@ -485,8 +486,22 @@ return [
|
||||
'set_to_null' => 'Delete values for this selection|Delete values for all :selection_count selections ',
|
||||
'set_users_field_to_null' => 'Delete :field values for this user|Delete :field values for all :user_count users ',
|
||||
'na_no_purchase_date' => 'N/A - No purchase date provided',
|
||||
'assets_by_category' => 'Assets by Category',
|
||||
'assets_by_status' => 'Assets by Status',
|
||||
'assets_by_status_type' => 'Assets by Status Type',
|
||||
'activity_overview' => 'Activity Overview',
|
||||
'checkouts_checkins' => 'Checkouts & Check-ins',
|
||||
'assets_newly_added' => 'Assets Added',
|
||||
'checkouts' => 'Checkouts',
|
||||
'checkins' => 'Check-ins',
|
||||
'assets' => 'ንብረቶች',
|
||||
|
||||
'vs_prior_period' => 'vs. prior period',
|
||||
'time_range' => 'Select Date Range',
|
||||
'last_n_days' => 'Last :days Days',
|
||||
'custom_range' => 'Custom Range',
|
||||
'download_chart' => 'Download Chart as PNG',
|
||||
'fullscreen' => 'Fullscreen',
|
||||
'pie_chart_type' => 'Dashboard Pie Chart Type',
|
||||
'hello_name' => 'Hello, :name!',
|
||||
'unaccepted_profile_warning' => 'You have one item requiring acceptance. Click here to accept or decline it | You have :count items requiring acceptance. Click here to accept or decline them',
|
||||
@@ -600,10 +615,16 @@ return [
|
||||
'status_compatibility' => 'If assets are already assigned, they cannot be changed to a non-deployable status type and this value change will be skipped.',
|
||||
'rtd_location_help' => 'This is the location of the asset when it is not checked out',
|
||||
'item_not_found' => ':item_type ID :id does not exist or has been deleted',
|
||||
'item_target_not_found_hard' => ':item_type ID :id does not exist or has been hard-deleted. Would you like to force a checkin?',
|
||||
'force_checkin' => 'Force Checkin',
|
||||
'item_not_found_short' => ':item_type ID :id does not exist',
|
||||
'action_permission_denied' => 'You do not have permission to :action :item_type ID :id',
|
||||
'action_permission_generic' => 'You do not have permission to :action this :item_type',
|
||||
'edit' => 'edit',
|
||||
'search_operator' => 'Search operator',
|
||||
'and' => 'and',
|
||||
'action_source' => 'Action Source',
|
||||
'search_tip' => 'Searches return a partial match by default. For more specific results, you can use <code>not:value</code> to fuzzy-exclude, <code>is:value</code> for an exact match, <code>is_not:value</code> for an exact exclusion, <code>is:null</code> for empty values, and <code>is:not_null</code> for non-empty. (In these examples, <code>value</code> is the text you are searching for.)',
|
||||
'or' => 'or',
|
||||
'url' => 'URL',
|
||||
'phone' => 'Phone',
|
||||
|
||||
@@ -9,6 +9,7 @@ return [
|
||||
'am-ET' => 'Amharic',
|
||||
'af-ZA' => 'Afrikaans',
|
||||
'ar-SA' => 'Arabic',
|
||||
'hy-AM' => 'Armenian',
|
||||
'bg-BG' => 'Bulgarian',
|
||||
'zh-CN' => 'Chinese Simplified',
|
||||
'zh-TW' => 'Chinese Traditional',
|
||||
|
||||
@@ -48,6 +48,7 @@ return [
|
||||
'asset_tag' => 'የንብረት መለያ',
|
||||
'assets_warrantee_alert' => 'There is :count asset with an expiring warranty or that are reaching their end of life in the next :threshold days.|There are :count assets with expiring warranties or that are reaching their end of life in the next :threshold days.',
|
||||
'assigned_to' => 'Assigned To',
|
||||
'assigned_to_assets' => 'Assignments to Assets',
|
||||
'eol' => 'EOL',
|
||||
'best_regards' => 'Best regards,',
|
||||
'canceled' => 'Canceled',
|
||||
|
||||
@@ -5,7 +5,7 @@ return [
|
||||
'manage' => 'إدارة',
|
||||
'field' => 'حقل',
|
||||
'about_fieldsets_title' => 'حول مجموعة الحقول',
|
||||
'about_fieldsets_text' => 'مجموعات الحقول تسمح لك بإنشاء مجموعات من الحقول المخصصة التي يعاد استخدامها في كثير من الأحيان لأنواع معينة من نماذج الأصول.',
|
||||
'about_fieldsets_text' => '(مجموعات الحقول) تسمح لك بإنشاء مجموعات من الحقول اللتي يمكن إعادة إستخدامها مع موديل محدد.',
|
||||
'custom_format' => 'تنسيق Regex المخصص...',
|
||||
'encrypt_field' => 'تشفير قيمة هذا الحقل في قاعدة البيانات',
|
||||
'encrypt_field_help' => 'تحذير: تشفير الحقل يجعله غير قابل للبحث.',
|
||||
|
||||
@@ -99,6 +99,9 @@ return [
|
||||
'success' => 'تم ادخال الأصل بنجاح.',
|
||||
'user_does_not_exist' => 'هذا المستخدم غير صالح. حاول مرة اخرى.',
|
||||
'already_checked_in' => 'تم ادخال هذا الأصل مسبقا.',
|
||||
'force_checkin_orphaned_success' => 'Invalid assignment cleared successfully.',
|
||||
'force_checkin_not_orphaned' => 'Item is not in an invalid assignment state.',
|
||||
'force_checkin_error' => 'Could not clear invalid assignment.',
|
||||
|
||||
],
|
||||
|
||||
|
||||
@@ -37,6 +37,9 @@ return [
|
||||
'confirm' => 'هل أنت متأكد من رغبتك في حذف هذا الترخيص؟',
|
||||
'error' => 'حدثت مشكلة أثناء حذف الترخيص. يرجى إعادة المحاولة.',
|
||||
'success' => 'تم حذف الترخيص بنجاح.',
|
||||
'bulk_success' => 'The selected licenses were deleted successfully.',
|
||||
'partial_success' => 'License deleted successfully. See additional information below. | :count licenses were deleted successfully. See additional information below.',
|
||||
'bulk_checkout_warning' => ':license_name has seats that are currently checked out and cannot be deleted. Please check in all seats before deleting.',
|
||||
],
|
||||
|
||||
'checkout' => [
|
||||
|
||||
@@ -438,7 +438,7 @@ return [
|
||||
'timezone' => 'Timezone',
|
||||
'test_mail' => 'Test Mail',
|
||||
'profile_edit' => 'Edit Profile',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles.',
|
||||
'profile_edit_help' => 'Allow users to edit their own profiles. (This does not include accessibility settings like light/dark mode, audio feedback, or link colors.)',
|
||||
'default_avatar' => 'Custom Default Avatar',
|
||||
'default_avatar_help' => 'This image will be displayed as a profile if a user does not have a profile photo.',
|
||||
'restore_default_avatar' => 'Restore <a href=":default_avatar" data-toggle="lightbox" data-type="image">original system default avatar</a>',
|
||||
|
||||
@@ -122,7 +122,7 @@ return [
|
||||
'debug_warning_text' => 'هذا التطبيق يعمل في وضع الإنتاج مع تمكين التصحيح. هذا يمكن أن يعرض البيانات الحساسة إذا كان التطبيق الخاص بك هو في متناول العالم الخارجي. تعطيل وضع التصحيح عن طريق تعيين قيمة <code>APP_DEBUG</code> في ملف <code>.env</code> إلى <code>false</code>.',
|
||||
'delete' => 'حذف',
|
||||
'delete_confirm' => 'هل أنت متأكد من حذف :المنتج؟',
|
||||
'delete_confirm_no_undo' => 'هل أنت متأكد من أنك ترغب في حذف :item؟ لا يمكن التراجع بعد الحذف.',
|
||||
'delete_confirm_no_undo' => 'Are you sure you wish to delete :item? This cannot be undone.',
|
||||
'deleted' => 'تم حذفها',
|
||||
'delete_seats' => 'المقاعد المحذوفة',
|
||||
'deletion_failed' => 'فشل الحذف',
|
||||
@@ -167,7 +167,7 @@ return [
|
||||
'image_upload' => 'رفع صورة',
|
||||
'filetypes_accepted_help' => 'Accepted filetype is :types. The maximum size allowed is :size.|Accepted filetypes are :types. The maximum upload size allowed is :size.',
|
||||
'filetypes_size_help' => 'الحد الأقصى المسموح لحجم التصعيد هو :size.',
|
||||
'image_filetypes_help' => 'أنواع الملفات المسموحة هي jpg، webpp، png، gif، svg، tif. الحد الأقصى لحجم التصعيد المسموح به هو :size.',
|
||||
'image_filetypes_help' => 'Accepted filetypes are jpg, webp, png, gif, svg, and avif. The maximum upload size allowed is :size.',
|
||||
'unaccepted_image_type' => 'ملف الصورة هذا غير قابل للقراءة. أنواع الملفات المقبولة هي jpg، webpp، png، gif، svg. نوع هذا الملف هو: :mimetype.',
|
||||
'import' => 'استيراد',
|
||||
'documentation' => 'Open documentation in a new link',
|
||||
@@ -196,6 +196,7 @@ return [
|
||||
'license' => 'الترخيص',
|
||||
'license_report' => 'تقرير الترخيص',
|
||||
'licenses_available' => 'التراخيص المتاحة',
|
||||
'licenses_with_no_seats' => 'Licenses with No Available Seats',
|
||||
'licenses' => 'التراخيص',
|
||||
'list_all' => 'عرض الكل',
|
||||
'loading' => 'جار التحميل. أرجو الإنتظار...',
|
||||
@@ -485,8 +486,22 @@ return [
|
||||
'set_to_null' => 'Delete values for this selection|Delete values for all :selection_count selections ',
|
||||
'set_users_field_to_null' => 'حذف :field القيم لهذا المستخدم <unk> حذف :field القيم لجميع :user_count المستخدمين ',
|
||||
'na_no_purchase_date' => 'N/A - لا يوجد تاريخ شراء',
|
||||
'assets_by_category' => 'Assets by Category',
|
||||
'assets_by_status' => 'الأصول حسب الحالة',
|
||||
'assets_by_status_type' => 'الأصول حسب نوع الحالة',
|
||||
'activity_overview' => 'Activity Overview',
|
||||
'checkouts_checkins' => 'Checkouts & Check-ins',
|
||||
'assets_newly_added' => 'Assets Added',
|
||||
'checkouts' => 'الخارج',
|
||||
'checkins' => 'Check-ins',
|
||||
'assets' => 'الأصول',
|
||||
|
||||
'vs_prior_period' => 'vs. prior period',
|
||||
'time_range' => 'Select Date Range',
|
||||
'last_n_days' => 'Last :days Days',
|
||||
'custom_range' => 'Custom Range',
|
||||
'download_chart' => 'Download Chart as PNG',
|
||||
'fullscreen' => 'Fullscreen',
|
||||
'pie_chart_type' => 'نوع مخطط دائري لوحة التحكم',
|
||||
'hello_name' => 'مرحبا، :name!',
|
||||
'unaccepted_profile_warning' => 'You have one item requiring acceptance. Click here to accept or decline it | You have :count items requiring acceptance. Click here to accept or decline them',
|
||||
@@ -600,10 +615,16 @@ return [
|
||||
'status_compatibility' => 'إذا تم تعيين الأصول بالفعل، فإنه لا يمكن تغييرها إلى نوع حالة غير قابل للنشر وسيتم تخطي هذا التغيير في القيمة.',
|
||||
'rtd_location_help' => 'هذا هو موقع الأصل عندما لا يتم التحقق منه',
|
||||
'item_not_found' => ':item_type ID :id غير موجود أو تم حذفه',
|
||||
'item_target_not_found_hard' => ':item_type ID :id does not exist or has been hard-deleted. Would you like to force a checkin?',
|
||||
'force_checkin' => 'Force Checkin',
|
||||
'item_not_found_short' => ':item_type ID :id does not exist',
|
||||
'action_permission_denied' => 'ليس لديك الإذن لـ :action :item_type ID :id',
|
||||
'action_permission_generic' => 'ليس لديك الإذن لـ :action هذا :item_type',
|
||||
'edit' => 'تعديل',
|
||||
'search_operator' => 'Search operator',
|
||||
'and' => 'and',
|
||||
'action_source' => 'مصدر العمل',
|
||||
'search_tip' => 'Searches return a partial match by default. For more specific results, you can use <code>not:value</code> to fuzzy-exclude, <code>is:value</code> for an exact match, <code>is_not:value</code> for an exact exclusion, <code>is:null</code> for empty values, and <code>is:not_null</code> for non-empty. (In these examples, <code>value</code> is the text you are searching for.)',
|
||||
'or' => 'أو',
|
||||
'url' => 'URL',
|
||||
'phone' => 'رقم الهاتف',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user