Compare commits

...

610 Commits

Author SHA1 Message Date
snipe 240196c3a1 Fixed logging 2026-05-04 11:46:34 +01:00
snipe 409ce69e32 Added some logging 2026-05-04 11:34:04 +01:00
snipe 0bdd3034d1 Pint 2026-05-04 11:00:34 +01:00
snipe f04a41408d Include custom fields in checkout emails (if they’re supposed to be included) 2026-05-04 11:00:27 +01:00
snipe 15effb1974 Formatting changes 2026-04-30 10:34:17 +01:00
snipe ccd60eb6d0 Added signed in place admin info 2026-04-30 10:34:06 +01:00
snipe 2f54f5b051 Fixed docblock 2026-04-30 10:13:48 +01:00
snipe 509584d1ff Make sure we’re logging action_source, IP, etc 2026-04-30 10:13:38 +01:00
snipe 95ac268046 Small cleanups 2026-04-30 09:54:08 +01:00
snipe f4b9736862 Fixed tests 2026-04-30 09:53:52 +01:00
snipe a0bf7a018c Only allow admins/super admins to delete or resend from unaccepted items 2026-04-29 17:18:05 +01:00
snipe ac4975e1d1 Added tests 2026-04-29 17:17:41 +01:00
snipe c7c3b04c5f Deleted old blade 2026-04-29 16:59:34 +01:00
snipe 88f87db4fd Renamed blade 2026-04-29 16:59:16 +01:00
snipe ad0199f662 Updated test strings, added form permission check 2026-04-29 16:47:15 +01:00
snipe 75a276d9fa Moved checkbox 2026-04-29 16:06:44 +01:00
snipe ead0047629 Migration to add qty to consumables_users table 2026-04-29 15:22:03 +01:00
snipe 46f6766bb4 Added qty to signature 2026-04-29 15:21:48 +01:00
snipe 8a470d3ef9 Added method for acceptance on consumable 2026-04-29 15:21:28 +01:00
snipe 5506245959 Fixed breadcrumbs 2026-04-29 15:11:51 +01:00
snipe 0fc175581a Check for app.always_send_email 2026-04-29 15:11:20 +01:00
snipe cd9005f82b RMB for consumables 2026-04-29 15:11:05 +01:00
snipe 24237d4259 Added always_send_email env var 2026-04-29 14:58:05 +01:00
snipe b2d707aaab Pint 2026-04-28 16:25:55 +01:00
snipe a432f23692 Add icons to show visibility 2026-04-28 16:25:48 +01:00
snipe a63b9ec627 Pint 2026-04-28 13:13:39 +01:00
snipe a35820d612 Added assigning user and indicator that it was signed in place 2026-04-28 13:13:31 +01:00
snipe ecf8ce3ec1 Pint 2026-04-28 12:54:34 +01:00
snipe 1908beb671 Check for custom fields with show_in_email = 1, field_encrypted = 0 2026-04-28 12:54:23 +01:00
snipe 1872c6eed9 Merge pull request #18950 from grokability/show-hide-password
🎥 Added password toggle JS/HTML
2026-04-28 10:42:21 +01:00
snipe 53199b9737 Added password toggle JS/HTML 2026-04-28 10:35:36 +01:00
snipe 73861c6a04 Merge pull request #18948 from marcusmoore/fixes/index-history-test
Fixed test name
2026-04-27 22:58:34 +01:00
Marcus Moore e2969dd3e2 Fix filename 2026-04-27 13:59:40 -07:00
snipe c5296fd76d Fixed selected attribute on multiselect (normal, not select2) for groups 2026-04-27 13:49:11 +01:00
snipe 3cb3284b26 Merge pull request #18942 from grokability/#18939-blank-tag-after-submit
Fixed #18939 - blank audit field in scanner audit screen
2026-04-27 13:22:12 +01:00
snipe d5d0d00ecc Fixed #18939 - blank audit field in scanner audit screen 2026-04-27 13:20:08 +01:00
snipe 5db9d67e65 Merge pull request #18936 from grokability/info-panel-button-blade
Refactor show/hide info button into blade component
2026-04-23 14:26:19 +01:00
snipe f64dfa7f92 Refactor show/hide info button into blade component 2026-04-23 14:18:19 +01:00
snipe 06584d17a6 Merge pull request #18874 from marcusmoore/fixes/fd-54740-user-avatar-via-api-master
[FD-54740] Fixed managing user avatar via API
2026-04-23 13:02:36 +01:00
snipe 75cb1041ec Merge pull request #18932 from grokability/small-tweak-to-info-element-status
🎥 Small tweak to info element status
2026-04-22 17:37:15 +01:00
snipe b61ed66d9d Updated logic 2026-04-22 17:25:48 +01:00
snipe 48ebd7faf5 Refine checkin checkout button display 2026-04-22 17:25:26 +01:00
snipe d6de3baa6e Hide button if orphaned 2026-04-22 17:21:33 +01:00
snipe 1be44a4c05 Pint 2026-04-22 17:18:07 +01:00
snipe f17f34f730 Added routes and logging 2026-04-22 17:17:58 +01:00
snipe da5bb6126a Add a warning well if the asset’s checkout is bonked 2026-04-22 17:09:58 +01:00
snipe a5d04d2e65 Added tests 2026-04-22 17:09:34 +01:00
snipe 22d07214fe Sort of fixed #18918 - prevent showing more permissions if user is admin or superadmin 2026-04-22 15:50:27 +01:00
snipe 8d4523d250 Merge pull request #18930 from grokability/#18920-sort-by-eula-in-categories
Added #18920 - sort by EULA in categories
2026-04-22 15:30:55 +01:00
snipe 37a3d694d4 Pint 2026-04-22 15:24:52 +01:00
snipe d21ccdfcbf Added #18920 - sorting by EULA in category view 2026-04-22 15:24:45 +01:00
snipe 4eba97d388 Added Armenian as a possible language 2026-04-22 15:01:11 +01:00
snipe 613137551a Pint 2026-04-22 14:58:03 +01:00
snipe 23f941c810 Updated language strings 2026-04-22 14:57:16 +01:00
snipe e7312801ac Fixed division by zero in new label engine 2026-04-22 14:24:11 +01:00
snipe cd69a7ea53 Merge pull request #18927 from grokability/enforce-basenames-on-filenames
Enforce basenames on filenames
2026-04-22 13:42:09 +01:00
snipe 13ffdda12e Piiiint 2026-04-22 13:35:24 +01:00
snipe 372e74aad3 Adds basename to places where we pass a filename 2026-04-22 13:35:16 +01:00
snipe 2a32f7d372 Merge pull request #18926 from grokability/#18172-correctly-decrypt-encrypted-custom-fields-in-custom-asset-report
Fixed #18172 - Encrypted custom fields not correctly decrypted in custom asset report
2026-04-22 12:56:11 +01:00
snipe a2d34cca76 Pint 2026-04-22 12:49:55 +01:00
snipe c513ed5fc3 Fixed #18172 - correctly dencrypt custom fields in custom asset report 2026-04-22 12:49:48 +01:00
snipe 34cd5dcf7c Add @Husky-Devel as a contributor 2026-04-22 12:24:19 +01:00
snipe 16e981d99d Merge pull request #18916 from marcusmoore/fixes/fd-54943-acceptance-email
Fixed display of email setting on category show page
2026-04-22 12:21:47 +01:00
snipe 16eb899ba7 Merge pull request #18924 from grokability/#18367-add-option-to-audit-by-serial-in-quickscan
Fixed #18367 - Added option to audit by serial in quickscan audit
2026-04-22 12:21:13 +01:00
snipe 3367f8e5c7 Edited audit method to allow searching by serial 2026-04-22 12:04:27 +01:00
snipe ad635ab95c Updated text 2026-04-22 12:04:11 +01:00
snipe b94e7fd8a0 Added select2 2026-04-22 12:04:04 +01:00
snipe 683fbd7953 Moved nav option 2026-04-22 12:00:41 +01:00
snipe 246ec9e20b Added tests 2026-04-22 11:57:58 +01:00
snipe 81d669d62a Pint 2026-04-22 09:05:01 +01:00
snipe 9ff951d379 Added redundent migration for delete_at 2026-04-22 09:04:53 +01:00
Marcus Moore e327303b3c Fix display of alert_on_response 2026-04-20 16:18:23 -07:00
snipe 1c5d81cb04 Parse through carbon to make suyre the dates match properly 2026-04-20 15:54:45 +01:00
snipe d37f43daba Merge pull request #18912 from grokability/small-info-panel-tweaks
Small info panel tweaks
2026-04-20 15:40:39 +01:00
snipe dbabd1bab3 Removed stupid space from phpstorm 2026-04-20 15:34:25 +01:00
snipe 6b5398139a Pull date out of the progress bar
This was squishing weird on smaller screens
2026-04-20 15:32:24 +01:00
snipe 0ec45a4fd0 Add text to dates, added class if almost depreciated 2026-04-20 15:31:16 +01:00
snipe b99fd237f3 Use refactored methods 2026-04-20 15:30:55 +01:00
snipe 5d7123eb05 Added tests 2026-04-20 15:30:25 +01:00
snipe 7eb6ebb60d Refactor the percent logic out of the blade 2026-04-20 15:30:16 +01:00
snipe 5bc273686e Use support_url in manufacturer blade component 2026-04-20 14:18:58 +01:00
snipe 37a37318aa Merge pull request #18901 from grokability/display-effective-permissions
Display effective permissions
2026-04-17 12:50:58 +01:00
snipe 74e831c4f0 Oh, Pint 2026-04-17 12:36:56 +01:00
snipe be36390b0f Display effective permissions on user view 2026-04-17 12:36:43 +01:00
snipe cf44119bc6 Merge pull request #18710 from ArturoSirvent/fix/backup-disk-s3-driver
Fix backup disk driver configuration for S3 support
2026-04-17 12:09:34 +01:00
snipe faa2adbde2 Pint 2026-04-17 12:04:31 +01:00
snipe 7fae60d5c3 Log requestable check on checkin/checkout 2026-04-17 12:04:21 +01:00
snipe 3cad34821e Merge pull request #18900 from grokability/#7418-toggle-requestable
Fixed #7418 - Ability to toggle requestable on checkin/checkout
2026-04-17 11:57:46 +01:00
snipe 1e4353f0db Added controller logic and form request constraints 2026-04-17 11:40:37 +01:00
snipe 7520a1b2a3 Allow the user to leave the requestability unchanged in bulk checkout 2026-04-17 11:38:45 +01:00
snipe cdd91e498a Updated test 2026-04-17 11:37:58 +01:00
snipe 2daf0458a7 Added checkboxes and JS to checkin/checkout blades 2026-04-17 11:35:10 +01:00
snipe 6299fc09bf Added tests 2026-04-17 11:33:12 +01:00
snipe 2327cc6866 Merge pull request #18895 from grokability/resend-acceptance-on-user-page
Resend acceptance on user page
2026-04-16 20:01:50 +01:00
snipe b235df0bbf Fixed div 2026-04-16 19:57:25 +01:00
snipe 6d56ab9b63 Re-added import 2026-04-16 19:51:50 +01:00
snipe ce5de8fe06 Allow admin to resend unaccepted assets 2026-04-16 15:50:11 +01:00
snipe ce3f80246e Pint 2026-04-16 15:49:32 +01:00
snipe 046ef82c65 Added gates for 2FA reset API endpoint 2026-04-16 15:49:25 +01:00
snipe 0f347e8453 Use translation for unauthorized 2026-04-16 15:28:36 +01:00
snipe 1cb0ca84ab Add wrapping for address 2026-04-16 14:52:03 +01:00
snipe 7625646c11 Remove wrapping from wells 2026-04-16 14:51:42 +01:00
snipe 324530fb8c Updated language 2026-04-16 14:12:07 +01:00
snipe 68acf7b90a Merge pull request #18884 from grokability/#8414-sign-in-place
Added #8414 - acceptance sign in place
2026-04-16 14:11:11 +01:00
snipe 9c610f51af Updated language, nicer form 2026-04-16 13:37:31 +01:00
snipe 40ec0627c4 Check for global acceptance requirement as well 2026-04-16 13:14:21 +01:00
snipe 645e66b30c Updated language 2026-04-16 13:07:32 +01:00
snipe 2311d56836 Only show the sign-in-place box if the category requires it 2026-04-16 13:06:18 +01:00
snipe 1f3481c54b Use breadcrumb action in route 2026-04-16 12:51:57 +01:00
snipe 07fa51aa4c Lots of tests 2026-04-16 12:51:38 +01:00
snipe 0866469cc0 Added checkout date and admin to acceptance form 2026-04-16 12:51:22 +01:00
snipe 3d9bb29b1b Updated string 2026-04-16 12:50:59 +01:00
snipe 5a67bcaf17 Pint doing pint things 2026-04-16 12:50:52 +01:00
snipe 01b18513f1 Safe redirect if request is weird 2026-04-16 12:49:02 +01:00
snipe d92ec582fa Check for “stale” acceptance 2026-04-16 12:47:30 +01:00
snipe 205eb7fd37 Moved breadcrumbs into action to get the logic out of the route 2026-04-16 12:35:31 +01:00
snipe 0798e62417 Normalize the box header - it looked kinda weird before 2026-04-16 11:31:12 +01:00
snipe 83adcc61bc More cleanup 2026-04-16 11:22:39 +01:00
snipe 788e07947f Nicer styling 2026-04-16 11:14:35 +01:00
snipe 83fec75bc8 Avoid crashing if assignedto isn’t valid 2026-04-16 10:00:45 +01:00
snipe 53c240f13f Pint 2026-04-15 21:29:32 +01:00
snipe f142eb7a44 Moved back in time migration 2026-04-15 21:29:26 +01:00
snipe 495382c42f Merge pull request #18885 from grokability/fix-soft-deleted-companies-in-migrations
Fix soft deleted companies in migrations
2026-04-14 23:19:33 +01:00
snipe 029634707b Oh, Pint 2026-04-14 22:50:32 +01:00
snipe fd5736fac4 Belt and suspenders 2026-04-14 22:50:21 +01:00
snipe f3ed2d9dd8 Derp. Run if the column DOES exist 2026-04-14 22:38:44 +01:00
snipe 676cd66e4b Make the temp datetime nullable 2026-04-14 22:31:00 +01:00
snipe 17c73c4017 Pint :( 2026-04-14 22:30:26 +01:00
snipe 5983a4530f Fix back in time migrations for very old restores or upgrades 2026-04-14 22:30:05 +01:00
snipe 1cd8395b23 Piiiint 2026-04-14 21:44:08 +01:00
snipe ea4374a855 Oops. Forgot to commit accessories :D 2026-04-14 21:44:00 +01:00
snipe 061b913413 Better naming for session variable 2026-04-14 20:54:42 +01:00
snipe e79af0163a Oh, Pint 2026-04-14 19:49:18 +01:00
snipe 91e41049bd Skip the initial checkout email to the recipient if sign_in_place was checked 2026-04-14 19:49:05 +01:00
snipe 18f67bcce5 Store the sign in place in the session so it’s remembered 2026-04-14 12:20:51 +01:00
snipe 264347e323 Added redirects 2026-04-14 12:16:46 +01:00
snipe 18d9f7dbf1 Updated JS assets 2026-04-14 12:13:59 +01:00
snipe 702af91c84 Added tests 2026-04-14 12:13:02 +01:00
snipe e9db3b3861 Added sign-in-place language keys 2026-04-14 12:12:54 +01:00
snipe 896922fde5 Added acceptance checkbox to checkout blades 2026-04-14 12:09:42 +01:00
snipe 7c2bb69bc9 Nicer width for field 2026-04-14 12:02:43 +01:00
snipe d2921346a2 Fixed datepicker width 2026-04-14 11:07:59 +01:00
snipe cc06b2f0eb Merge pull request #18878 from grokability/fixes-n+1-on-history
Fixes n+1 on history
2026-04-14 11:06:44 +01:00
snipe d98c7bddba Added test to prevent n+1 regression 2026-04-14 10:25:55 +01:00
snipe fe308ef2e4 Use query clone to prevent n+1 2026-04-14 10:25:37 +01:00
snipe 9b43835e2d Added forHistory scope in ActionLog, use it in Loggable 2026-04-14 10:23:36 +01:00
snipe 019f0af282 Fixed typo 2026-04-14 09:13:45 +01:00
Marcus Moore c6619a621c Move comment 2026-04-13 16:42:47 -07:00
Marcus Moore 565e3de183 Allow updating user avatar via API 2026-04-13 16:41:21 -07:00
snipe 1a852cdacf Merge pull request #18872 from grokability/location-breadcrumbs
Fixes #9037 - Added breadcrumbs to top breadcrumb trail
2026-04-13 16:19:34 +01:00
snipe 4ea527d980 Added breadcrumb to info-panel 2026-04-13 16:16:33 +01:00
snipe 392af4f127 Fixes #9037 - Added breadcrumbs to top breadcrumb trail 2026-04-13 16:09:10 +01:00
snipe 1c198500c6 Merge pull request #18871 from grokability/#18869-throw-error-if-no-MAIL_REPLYTO_ADDR-is-set
Fixed #18869 - skip mail test if no `MAIL_REPLYTO_ADDR` is given
2026-04-13 15:20:45 +01:00
snipe 8620f25c0e Fixed #18869 - skip mail test if no MAIL_REPLYTO_ADDR is given 2026-04-13 15:17:23 +01:00
snipe 8a59809937 Merge pull request #18870 from grokability/#18736-add-cumulative-cost-of-an-asset-with-maintenances
#18736 add cumulative cost of an asset with maintenances
2026-04-13 15:00:38 +01:00
snipe bac8299ea6 Added accessor for component qty 2026-04-13 14:51:48 +01:00
snipe 500dcdd582 Re-jigger the order 2026-04-13 14:51:24 +01:00
snipe cbae494c54 Fixed totals 2026-04-13 14:08:23 +01:00
snipe 09bec66406 Meh 2026-04-13 13:55:05 +01:00
snipe 2b2291dc7e Pint 2026-04-13 13:48:21 +01:00
snipe 1357b45e24 Added content to blade, refactoring some relationships 2026-04-13 13:48:14 +01:00
snipe 6935cf1dde Merge pull request #18867 from grokability/fixed-#18856-duplicate-icons
Fixed #18856 - clicking and canceling would result in multiple icons in modal
2026-04-13 11:57:36 +01:00
snipe cf384373df Fixed #18856 - clicking and canceling would result in multiple icons in modal 2026-04-13 11:50:41 +01:00
snipe 48c4f34af3 Fixed #18863 - backfill status vs status_type for older integrations 2026-04-13 11:08:14 +01:00
snipe 7b800152ee Merge pull request #18866 from grokability/adds-validation-check-console-command
Adds validation check console command - helps with #18851
2026-04-13 10:45:49 +01:00
snipe f289691e22 Merge pull request #18865 from Joly0/patch-1
Add artisan command to clear compiled views (for docker startup.sh)
2026-04-13 10:45:25 +01:00
snipe 50421494c5 Pint 2026-04-13 10:33:55 +01:00
snipe 33b8861ae3 Adds conosole command for listing invalid assets 2026-04-13 10:33:49 +01:00
Joly0 31f90d20f8 Add artisan command to clear compiled views
After the recent update to 8.4.1 this command was mandatory, but wasnt applied to docker environment, therefore (atleast for my company) views were broken (empty licenses for example). Had to manually exec into the container and execute this command.

Adding it to the startup script should bring no real downsides, but should fix this for all others and all future version of snipe-it that have the same requirement for clearing compiled views
2026-04-13 09:49:51 +02:00
snipe 87e65893d3 Updated tests with new text 2026-04-11 10:48:37 +01:00
snipe 405540aea2 Clarified text 2026-04-11 10:38:42 +01:00
snipe ccfebee5f1 Not sure why the timestamps wouldn’t handle this for us, but… 2026-04-11 10:34:03 +01:00
snipe 1d9469a3df Fix action_date on action_logs on bulk checkin and delete 2026-04-11 10:32:04 +01:00
snipe 51ea1327cf Merge pull request #18861 from grokability/log-authed-user-header
Log authed user ID header
2026-04-09 20:16:12 +01:00
snipe a88ad35b68 Added token name and ID 2026-04-09 19:35:09 +01:00
snipe 6e60f59265 Changed the name because reasons 2026-04-09 19:24:11 +01:00
snipe a866bfafcd Oh ffs pint 2026-04-09 19:23:29 +01:00
snipe 97d1677568 Check for bearer token in header 2026-04-09 19:23:21 +01:00
snipe f4562db0c0 Pint 2026-04-09 19:19:56 +01:00
snipe a616da3e5c Moved to an API-only header 2026-04-09 19:19:50 +01:00
snipe a895566b02 Pint fixes 2026-04-09 19:09:32 +01:00
snipe 5d75765aae Optionally log the user’s ID in the header 2026-04-09 19:09:21 +01:00
snipe 3bc34fcd5e Added nice icons for revoked/not revoked status 2026-04-09 18:46:18 +01:00
snipe 99c3ac56e9 Fixed typo 2026-04-09 18:32:50 +01:00
snipe 371f0b82f6 This is superadmin, do not scope to just the authed user 2026-04-09 18:06:27 +01:00
snipe 114b5d3db0 Updated text 2026-04-09 18:01:44 +01:00
snipe 9ab0f60b41 Merge pull request #18858 from grokability/_api-token-rework
Api token rework
2026-04-09 17:42:09 +01:00
snipe 33b8226ebe A little more cleanup 2026-04-09 17:36:32 +01:00
snipe e062062cb3 Added livewire component for persoinal access tokens for admin 2026-04-09 17:24:51 +01:00
snipe 5f713862fb Updated tests 2026-04-09 17:24:20 +01:00
snipe fe013b5ea0 A bit more polish 2026-04-09 17:20:16 +01:00
snipe 57df2dc2cf Removed extra column 2026-04-09 16:21:39 +01:00
snipe c86fa4c521 Moved tabs 2026-04-09 16:00:49 +01:00
snipe c90acf53d5 One more quick fix for consumables qty percent 2026-04-09 15:14:10 +01:00
snipe fece4d2fdc Handle 0 qty for consumables 2026-04-09 15:10:58 +01:00
snipe f49837e5fe Fix division by zero? 2026-04-09 15:08:46 +01:00
snipe e6ead7c6fa Added tests 2026-04-09 14:52:30 +01:00
snipe 3e84af83d8 Added translations to OAuthClients livewire 2026-04-09 14:52:22 +01:00
snipe aff375c799 Added OAuth table button - I don’t know if this really works properly? 2026-04-09 14:52:01 +01:00
snipe 6ada1a646f Tabbed UI 2026-04-09 14:51:33 +01:00
snipe 42bfd24a8f Expanded OAuth language 2026-04-09 14:49:33 +01:00
snipe c7c6b41dab Added revoke to user model 2026-04-09 14:49:12 +01:00
snipe 449e6b5f5c Added revoke controllers 2026-04-09 14:48:50 +01:00
snipe bbe0c7409f Added TokenRevoked action 2026-04-09 14:48:22 +01:00
snipe 41bb9c378b Added keywords for translation 2026-04-09 14:48:03 +01:00
snipe 9a82b890d4 Translation 2026-04-09 14:47:52 +01:00
snipe 2d2180c9e8 Added revoke/unrevoke routes 2026-04-09 14:26:11 +01:00
snipe 798a590c2a Pint gonna pint 2026-04-09 14:03:37 +01:00
snipe 8a08878062 Added language strings 2026-04-09 14:03:18 +01:00
snipe fde6ff1571 Pint 2026-04-09 12:16:46 +01:00
snipe 8c9a48b38a Fixed custom field search nesting 2026-04-09 12:16:40 +01:00
snipe 45fffd74b7 Merge pull request #18846 from grokability/strikethroug-if-component-is-deleted
Strikethrough if component is deleted
2026-04-08 12:47:49 +01:00
snipe a0cf0751de Pint 2026-04-08 12:39:38 +01:00
snipe 7485cb81aa Show strikethrough and unlink if item is deleted 2026-04-08 12:39:29 +01:00
snipe 7faa9a6fdf Fixed #18816 - updated language in acceptance email 2026-04-08 11:24:54 +01:00
snipe f6f7063419 Fixed #18844 - use correct component for bulk editing models on category detail view 2026-04-08 11:17:48 +01:00
snipe 1300fff94c Null safe operator for assets transformer 2026-04-08 11:11:55 +01:00
snipe 5ef9798c68 Pint 2026-04-08 10:09:56 +01:00
snipe db48c18766 Fixed #18840 - added print inventory button back to locations 2026-04-08 10:09:49 +01:00
snipe 880261500b Piiiiiint 2026-04-07 19:14:08 +01:00
snipe e02c257df6 Bumper version 2026-04-07 19:13:58 +01:00
snipe 5898205480 Merge pull request #18834 from grokability/asset-components-display-fix
Asset components display fix
2026-04-07 15:32:16 +01:00
snipe 50676288a1 Pint 2026-04-07 15:29:15 +01:00
snipe db2269092a Moved limit 2026-04-07 15:29:06 +01:00
snipe b3f56900e5 Added created_by to sortable fields 2026-04-07 14:46:23 +01:00
snipe f1820b739f Use sum 2026-04-07 14:45:52 +01:00
snipe 56957e28f9 Standardized transformer 2026-04-07 14:45:42 +01:00
snipe 3c32721791 Use ComponentsAssignment model 2026-04-07 14:44:56 +01:00
snipe 2632433cc6 Load location and company on asset load 2026-04-07 14:44:09 +01:00
snipe 16ea577099 Include created_by in pivot 2026-04-07 12:20:25 +01:00
snipe 7456c9dce5 Check that $image is not empty 2026-04-06 10:58:12 +01:00
snipe f9e16e16d1 Avoid searching by human readable custom field name to avoid collisions with normal attributes 2026-04-06 10:45:10 +01:00
snipe b42094a1be Merge remote-tracking branch 'origin/develop' 2026-04-06 10:17:38 +01:00
snipe 4c343afec7 Pint 2026-04-06 10:17:29 +01:00
snipe 40b3007676 Removed duplicated custom field search 2026-04-06 10:17:23 +01:00
snipe 48395d162a Merge remote-tracking branch 'origin/develop' 2026-04-06 09:54:45 +01:00
snipe 50aaa54c27 Check status_type for list_all 2026-04-06 09:52:43 +01:00
snipe 47737b082b Missed one in the nav 2026-04-06 09:51:50 +01:00
snipe c4a3c71448 Merge remote-tracking branch 'origin/develop' 2026-04-06 09:49:41 +01:00
snipe 9939849e40 pint 2026-04-06 09:49:33 +01:00
snipe d690989b58 Use status_type instead of status for filtering 2026-04-06 09:49:24 +01:00
snipe d9deb0f30c Merge remote-tracking branch 'origin/develop' 2026-04-06 08:49:05 +01:00
snipe 53ce14dddf Switched to AND operator 2026-04-06 08:48:56 +01:00
snipe 1d0be6261b Merge pull request #18823 from grokability/small-permission-tweaks
Added actions for normalizing permissions input
2026-04-06 08:46:03 +01:00
snipe 108c6eda1d Oh, pint 2026-04-06 08:06:28 +01:00
snipe 6e33bfaf8f Don’t check for filled on groups in user save 2026-04-06 08:06:20 +01:00
snipe a7bc9f0ae9 Use fill() for more compact code 2026-04-05 13:03:37 +01:00
snipe 927e0a4e7b Just set the field directly, since it’s a UI edit 2026-04-05 13:03:02 +01:00
snipe 75b2ac9d33 Aaaaand pint 2026-04-05 13:02:22 +01:00
snipe b0d7ae6f04 Removed redundent display_name setting, since it’s already fillable 2026-04-05 13:02:13 +01:00
snipe c764605d07 Implement the new actions in the controllers 2026-04-05 12:40:34 +01:00
snipe 205cf3cf28 MOAR tests 2026-04-05 12:38:02 +01:00
snipe ea274f0df0 Added tests 2026-04-05 12:37:53 +01:00
snipe 31541c4a56 Sigh. Pint 2026-04-05 12:35:19 +01:00
snipe 2a601ae483 Switched to use actions for normalizing payload 2026-04-05 12:35:09 +01:00
snipe 3fe8600a70 Normalize permissions array 2026-04-05 11:55:08 +01:00
snipe dbd7df2b85 Merge remote-tracking branch 'origin/develop' 2026-04-04 17:09:05 +01:00
snipe 717deb544e Merge pull request #18822 from grokability/fixed-history-api-pagination
Fixed #18821- history api pagination
2026-04-04 17:08:26 +01:00
snipe 51446a5fe0 Pint 2026-04-04 16:59:30 +01:00
snipe 4c4ec3eacc Fixed #18821 - pagination on history 2026-04-04 16:59:23 +01:00
snipe 71b72eae10 Merge remote-tracking branch 'origin/develop' 2026-04-03 15:40:30 +01:00
snipe 01eb585e59 Fixed light-dark button in nav dropdown 2026-04-03 15:40:16 +01:00
snipe 2343841aa1 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:57:31 +01:00
snipe b2790d98d0 Removed codacy badge (for now) 2026-04-03 13:57:20 +01:00
snipe b14e925158 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:46:03 +01:00
snipe 18ef770a85 Fixed RB, added withTrashed() 2026-04-03 13:45:53 +01:00
snipe ee831c9361 Merge remote-tracking branch 'origin/develop' 2026-04-03 13:07:47 +01:00
snipe e446dc1cba Fixed [RB-4105] - check for item’s existance before applying withTrashed() 2026-04-03 13:07:37 +01:00
snipe af283c7e01 Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-04-03 12:28:30 +01:00
snipe 4703a8b021 Pint 2026-04-03 12:28:00 +01:00
snipe eb3a608e80 Bumped to pre version 2026-04-03 12:27:50 +01:00
snipe 6fbd189553 Pint 2026-04-03 12:26:49 +01:00
snipe 753e2790ac Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-04-03 12:26:28 +01:00
snipe 2c2de8719b Exclude encrypted custom fields in search 2026-04-03 12:25:09 +01:00
snipe 1e884bf627 Update the alias 2026-04-03 12:19:49 +01:00
snipe 7a001c81ea Merge pull request #18809 from grokability/rename-assetstatus
Rename assetstatus to status (variation of #18808)
2026-04-03 11:38:45 +01:00
snipe 9e82f3ffd9 Merge pull request #18814 from Godmartinz/add-example-logo-to-label-preview
Adds #18663 generic example logo for label preview
2026-04-03 11:37:06 +01:00
snipe e1dc605657 Merge pull request #18820 from grokability/search-one-more-stab
Adds to #18778, fixes for advanced search
2026-04-03 11:36:26 +01:00
snipe bca93b57ec Pint fixes 2026-04-03 11:08:35 +01:00
snipe d929c87bbd Final fixes, tests 2026-04-03 11:08:16 +01:00
snipe 72eb4d6d4d Merge remote-tracking branch 'origin/develop' 2026-04-03 09:52:19 +01:00
snipe 0ccdeed318 Merge pull request #18817 from Godmartinz/notification-language-fix
Fixes #18811  locale for Requestable notifications
2026-04-03 09:41:06 +01:00
snipe 5d6fc9f516 Merge pull request #18818 from marcusmoore/fixes/18798-create-asset-with-scoped-locations
Fixed #18798: creating assets with location for non-super-admins with FMCS
2026-04-03 09:40:31 +01:00
snipe 7f0435e3d6 Merge pull request #18815 from marcusmoore/fixes/18810-acceptance-url-in-mail
Fixed #18810: Display acceptance url in checkout asset email
2026-04-03 09:36:40 +01:00
Marcus Moore 85d7ba73aa Set company_id in request for non-super-admins when fmcs enabled 2026-04-02 15:50:13 -07:00
Godfrey M c6a3afa555 revert change, but still add locale 2026-04-02 15:31:25 -07:00
Godfrey M 6cc8ec63be Correct way to append locale 2026-04-02 15:29:57 -07:00
Godfrey M cf7cb8069b get locale from settings before sending requestable message 2026-04-02 15:22:59 -07:00
Marcus Moore e70519c9c2 Fix test assertion 2026-04-02 13:40:16 -07:00
Marcus Moore 6617cc3e7e Add missing endif 2026-04-02 11:47:27 -07:00
Marcus Moore 3426a427d8 Add more details to failure 2026-04-02 11:30:02 -07:00
Marcus Moore 2e67787d75 Add failing test 2026-04-02 11:28:42 -07:00
snipe 70a30a96fa Try to resolve polymorism for checkout 2026-04-02 19:23:45 +01:00
Godfrey M 8832596aa0 Merge branch 'develop' into add-example-logo-to-label-preview 2026-04-02 11:08:11 -07:00
snipe 0cd013191a Merge pull request #18805 from Godmartinz/fix-company-disabled-css-bug
Fixes #18715 Fixes disabled CSS rules for select2
2026-04-02 19:07:00 +01:00
Marcus Moore 8f70b299cf Merge branch 'develop' into fixes/18798-create-asset-with-scoped-locations 2026-04-02 11:06:42 -07:00
Godfrey M 2c87a469e3 adds generic example logo if no logo present" 2026-04-02 10:55:56 -07:00
snipe 5ca2ec5534 Merge remote-tracking branch 'origin/develop' 2026-04-02 18:12:10 +01:00
snipe 46caf9d9ec Removed phpmd.xml 2026-04-02 18:11:58 +01:00
snipe 7ac0842bc2 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:45:59 +01:00
snipe de1674d001 Removed the display none from bulk asset checkout 2026-04-02 17:45:48 +01:00
Godfrey M 37c30a3079 remove unnecessary css 2026-04-02 09:41:45 -07:00
snipe ce825d1df2 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:39:24 +01:00
snipe b5c01ab820 Fixed #18812 - light/dark on signature page 2026-04-02 17:39:15 +01:00
snipe 8de674c837 Merge remote-tracking branch 'origin/develop' 2026-04-02 17:21:06 +01:00
snipe d289ac7f05 Pint 2026-04-02 11:26:33 +01:00
snipe 7ec60bc6f2 Same as #18808 but renamed assetstatus to status 2026-04-02 11:26:23 +01:00
snipe eb78474d1e Make order number clickable if asset 2026-04-02 10:32:21 +01:00
snipe 6dc5a4a27e Merge remote-tracking branch 'origin/develop' 2026-04-02 09:59:56 +01:00
snipe 10c8045351 Check for file view permissions 2026-04-02 09:59:46 +01:00
snipe 03b6a54fe8 Merge remote-tracking branch 'origin/develop' 2026-04-02 09:36:35 +01:00
snipe 1140795ab8 Fixed #18734 - open markdown link in new window 2026-04-02 09:36:24 +01:00
snipe 0441c07266 Merge remote-tracking branch 'origin/develop' 2026-04-02 09:17:11 +01:00
snipe 64f346a5f0 Aaaand pint 2026-04-02 09:12:03 +01:00
snipe 0ac63a8ac6 Codacy tweaks 2026-04-02 09:11:50 +01:00
snipe f5a3d751da Merge remote-tracking branch 'origin/develop' 2026-04-01 23:32:56 +01:00
snipe 985e7d0c7c Merge pull request #18806 from marcusmoore/fixes/rb-4103manufacturer-url
Fixed RB-4103: Allow more models to access dynamic url presenter method
2026-04-01 23:32:09 +01:00
snipe 97a024b3ec Merge remote-tracking branch 'origin/develop' 2026-04-01 23:29:09 +01:00
snipe c501999676 Merge pull request #18807 from grokability/adds-model-number-to-info-panel-if-asset
Added model number on info-panel if the object is an asset
2026-04-01 23:28:22 +01:00
snipe 80e7cf0b46 Added model number on info-panel if the object is an asset 2026-04-01 23:21:30 +01:00
Marcus Moore b7d2bea3ea Fix dynamic url 2026-04-01 15:15:31 -07:00
Marcus Moore 2acb38a6a5 Move dynamic method to base presenter and adjust for other model types 2026-04-01 15:05:42 -07:00
Godfrey M 8ea7242c38 remove input[type=*]:disabled 2026-04-01 14:45:23 -07:00
Godfrey M 616a93cb52 removed override changes 2026-04-01 14:33:28 -07:00
Godfrey M 7170ea0303 adjust disabled look for select 2 2026-04-01 14:28:32 -07:00
snipe 10eb14776b Merge remote-tracking branch 'origin/develop' 2026-04-01 22:09:03 +01:00
snipe a7b43d1879 Merge pull request #18800 from Godmartinz/acceptance-double-send-bug
Fix #18595 checkout acceptance url bug
2026-04-01 22:08:03 +01:00
snipe 439f3c9c91 Merge remote-tracking branch 'origin/develop' 2026-04-01 21:49:49 +01:00
snipe e2b8368f40 Removed dupe category in info panel 2026-04-01 21:49:38 +01:00
Godfrey M 1243f690c4 changed disabled=true to disabled, adjust css" 2026-04-01 13:48:56 -07:00
snipe e2c8d41a58 Merge pull request #18803 from marcusmoore/fixes/18802-support-url
Fixed #18802: Display dynamic support url for manufacturers properly
2026-04-01 21:46:34 +01:00
snipe 591bba71d5 Pint 2026-04-01 21:46:23 +01:00
snipe 7aef0f78b0 Fixed weird component linking 2026-04-01 21:46:13 +01:00
Marcus Moore b6dc0e2a08 Display dynamic support url properly 2026-04-01 13:37:06 -07:00
Godfrey M 1ed7dd0e1e fix markdown to send messages with Eulas 2026-04-01 13:17:02 -07:00
snipe 5ecfa0b8d8 Merge remote-tracking branch 'origin/develop' 2026-04-01 20:41:09 +01:00
snipe 3578580956 Fix variable 2026-04-01 20:41:00 +01:00
snipe 67457d324c Merge remote-tracking branch 'origin/develop' 2026-04-01 16:25:54 +01:00
snipe 8b4e4aff27 Merge pull request #18801 from grokability/small-improvements-to-activity-report
Small improvements to activity report
2026-04-01 16:25:40 +01:00
snipe eb11c4640b Pint :-/ 2026-04-01 16:16:36 +01:00
snipe 5806fced78 Add optional hide for history 2026-04-01 16:16:25 +01:00
snipe ee0e036354 Removed unused routes 2026-04-01 15:00:16 +01:00
snipe af0ec10e78 Addd notes tab 2026-04-01 15:00:01 +01:00
snipe ccbd73259b Fixed license tabs 2026-04-01 14:58:33 +01:00
snipe 1a44a11b62 Added journal permission 2026-04-01 14:51:57 +01:00
snipe 8502a2291b Allow non-report users to view assets, etc if they have permission 2026-04-01 14:51:42 +01:00
snipe 67ccb5e6d9 Make a generic formatter for class names 2026-04-01 14:51:10 +01:00
snipe 520a70d2ea Added tests 2026-04-01 14:50:56 +01:00
snipe 3dee30c48e Changed button color 2026-04-01 14:50:47 +01:00
snipe f314e12685 Merge remote-tracking branch 'origin/develop' 2026-04-01 10:13:50 +01:00
snipe e3b53c8fa2 Use newer files permission 2026-04-01 10:13:39 +01:00
snipe 4bb5020e0a Added assets obj to asset tab check 2026-04-01 10:13:32 +01:00
snipe 4f1fa95cf9 Check for valid category beofre chekcing for tag color 2026-04-01 10:01:17 +01:00
snipe baeeb8e609 Use shorter auth check 2026-04-01 09:56:41 +01:00
snipe f109ca6f1f Merge remote-tracking branch 'origin/develop' 2026-04-01 09:14:20 +01:00
Marcus Moore 1714d62762 Add failing test 2026-03-31 17:20:40 -07:00
Godfrey M f19ac4d5bb send checkout mail without link or acceptance reference 2026-03-31 16:38:35 -07:00
snipe c6dbccb463 Merge pull request #18799 from marcusmoore/fixes/54576-component-link
Fixed #18797: Fix link to components in asset view
2026-03-31 23:42:24 +01:00
Marcus Moore 80ca2a6d21 Fix link to component 2026-03-31 15:36:24 -07:00
snipe 90c1c8cddd Merge remote-tracking branch 'origin/develop' 2026-03-31 15:28:14 +01:00
snipe 0b6593bdc8 Merge pull request #18783 from ubc-cpsc/fix/aws-sdk-php-PKSA-4t1p-xpk2-nsss
Fixes PKSA-4t1p-xpk2-nsss for aws/aws-sdk-php
2026-03-31 15:26:24 +01:00
snipe 6e8c0e5a14 Merge pull request #18758 from Godmartinz/extends-field-value-if-no-label
Fixes FD-54467 TZe_24mm_E Field value to extend full width
2026-03-31 15:25:53 +01:00
snipe 78c300ea1b Merge pull request #18788 from Godmartinz/fix-bulk-edit-breadcrumb
Fixes bulk edit breadcrumb translation
2026-03-31 15:25:28 +01:00
snipe a3fb492e37 Merge pull request #18790 from spencerrlongg/bug/rm-dead-license-route-rb
Removes Unused License Route
2026-03-31 15:24:21 +01:00
snipe 667f50497c Merge pull request #18791 from spencerrlongg/bug/better-error-reporting-in-custom-rules
Better Error Reporting in Custom Rules
2026-03-31 15:23:53 +01:00
snipe cb93eda4e2 Merge pull request #18792 from spencerrlongg/bug/nest-error-callback-errors-properly
Wrap importer errors in array properly
2026-03-31 15:23:15 +01:00
snipe 613b536f97 Merge remote-tracking branch 'origin/develop' 2026-03-31 13:58:59 +01:00
snipe 1ffaa077e6 Use maintenance buttons on asset view 2026-03-31 13:58:49 +01:00
snipe dbc850550f Merge remote-tracking branch 'origin/develop' 2026-03-31 13:14:30 +01:00
snipe 1efe65e6ba Pint :( 2026-03-31 13:13:43 +01:00
snipe 6a39db7e47 Fixed history return 2026-03-31 13:13:35 +01:00
snipe 43841b8b3c Fixed return type 2026-03-31 10:12:24 +01:00
spencerrlongg c33ab9c924 wrap in array properly 2026-03-30 20:13:52 -05:00
spencerrlongg 135118de65 rm ->getMessage(), report full exception 2026-03-30 19:53:26 -05:00
spencerrlongg b42b9e354f rm dead route for freecheckout endpoint 2026-03-30 19:28:47 -05:00
snipe b59b51b2aa Merge remote-tracking branch 'origin/develop' 2026-03-30 20:17:53 +01:00
snipe 7677b3916d Removed parens 2026-03-30 20:17:43 +01:00
snipe b4debacd1a Merge remote-tracking branch 'origin/develop' 2026-03-30 20:06:48 +01:00
Godfrey M d91c26e718 fix bulk edit breadcrumb translation 2026-03-30 10:38:30 -07:00
snipe 8e64083f06 Removed “in active” class 2026-03-30 14:52:57 +01:00
snipe 56580f117a Fixed audit view tab on assets 2026-03-30 14:52:43 +01:00
snipe 29e994dfd0 Added manufacturer and category relationships 2026-03-30 13:05:57 +01:00
snipe b833daf943 Merge remote-tracking branch 'origin/develop' 2026-03-30 12:15:35 +01:00
snipe 537e09a0a6 Fixed #18779 - added audits tab back in 2026-03-30 12:13:56 +01:00
Joël Pittet f53f55b283 Fixes PKSA-4t1p-xpk2-nsss for aws/aws-sdk-php 2026-03-29 10:03:02 -07:00
snipe 943903d8d6 Merge remote-tracking branch 'origin/develop' 2026-03-28 10:36:48 +00:00
snipe 523920d6d6 Pint 2026-03-28 10:36:37 +00:00
snipe e39a242a76 Ignnore counts 2026-03-28 10:36:29 +00:00
snipe e3b57b0c2f Merge remote-tracking branch 'origin/develop' 2026-03-27 21:10:15 +00:00
snipe 125a9e4031 Merge pull request #18778 from grokability/advanced-search-for-licenses
Advanced search for licenses
2026-03-27 21:09:59 +00:00
snipe 9576871ff9 Pint. Sigh. 2026-03-27 21:02:37 +00:00
snipe 968724f369 Mark test as incomplete in SQLite 2026-03-27 21:02:26 +00:00
snipe 4444a63b92 Added created_at and searchableCounts 2026-03-27 20:39:27 +00:00
snipe 9924112d08 Use Filter request on categories API 2026-03-27 20:38:16 +00:00
snipe 368796c40e Added created_at to category search 2026-03-27 20:37:48 +00:00
snipe b9f6b2bbb8 Made user counts searchable 2026-03-27 20:36:45 +00:00
snipe 86afa9d201 Added advanced search to categories 2026-03-27 20:36:27 +00:00
snipe 294d320aa0 Added test 2026-03-27 20:18:13 +00:00
snipe bdd44061f3 Added ability to support aliased count/sum fields in search 2026-03-27 20:18:05 +00:00
snipe 8545d2d703 Made % remaining sortable 2026-03-27 19:21:55 +00:00
snipe 61f3180d74 Small fixes to Searchable trait 2026-03-27 19:21:41 +00:00
snipe 9efcb09836 Moved adminuser into SnipeModel 2026-03-27 19:21:27 +00:00
snipe 80b7ebd508 Moved adminuser method to the SnipeModel 2026-03-27 19:20:54 +00:00
snipe 4545cf8989 Removed broken(?) use statement 2026-03-27 19:20:11 +00:00
snipe 4dc5e8bbdb Use filter check 2026-03-27 19:19:48 +00:00
snipe 0261776778 Added FilterRequest and added refactorerd search check 2026-03-27 19:19:25 +00:00
snipe 1dfce30a32 Broke out the use statements for readaibility 2026-03-27 19:17:55 +00:00
snipe d7f44fdda4 Added license filter tests 2026-03-27 18:34:54 +00:00
snipe 8facdcd55c Added advanced search back to licenses 2026-03-27 18:34:36 +00:00
snipe 582b8858bc Pint 2026-03-27 18:34:15 +00:00
snipe 6d4264bc58 Refactor Searchable Trait to allow for filters 2026-03-27 18:34:05 +00:00
snipe 340433f418 Merge remote-tracking branch 'origin/develop' 2026-03-27 16:14:39 +00:00
snipe 107576eb01 Merge pull request #18777 from grokability/small-s3-fixes
Fixed #18573 - download URLs for S3, actually force the download
2026-03-27 15:53:48 +00:00
snipe ede406c904 Fixed #18573 - Removed extra slash in files controllers 2026-03-27 15:44:27 +00:00
snipe 3b875ce6ec Actually force the download in S3 2026-03-27 15:43:28 +00:00
snipe c89e14ae52 Removed unused showOrDownloadFile() method 2026-03-27 15:21:49 +00:00
snipe cff2fc0f16 Fixed typo 2026-03-27 13:39:39 +00:00
snipe e8b637b900 Allow qty parameter in partial 2026-03-27 12:59:24 +00:00
snipe 84bb484761 Merge remote-tracking branch 'origin/develop' 2026-03-26 17:50:06 +00:00
snipe 25c8fdd5d6 Fixed typo 2026-03-26 17:49:27 +00:00
snipe 6beaea8be9 Merge remote-tracking branch 'origin/develop' 2026-03-26 17:41:14 +00:00
snipe 7952bdefa8 Pint formatting 2026-03-26 17:40:56 +00:00
snipe 280d16637a Added file-specific policies 2026-03-26 17:40:49 +00:00
snipe cc397f6846 Merge remote-tracking branch 'origin/develop' 2026-03-26 16:27:40 +00:00
snipe bec443ce97 Tweaked checkin/checkout button statuses 2026-03-26 16:27:04 +00:00
snipe 8417007eb8 Fixed #18725 - scope by assetsForShow() 2026-03-26 16:26:33 +00:00
snipe 3db77f05e9 Merge remote-tracking branch 'origin/develop' 2026-03-26 16:05:51 +00:00
snipe 3c1eb27ce1 Merge pull request #18770 from grokability/#18767-added-uploads-for-companies
#18767 added uploads for companies
2026-03-26 16:05:20 +00:00
snipe 614a2cd5de Pint cleanup 2026-03-26 16:02:24 +00:00
snipe 616d0f00f9 Added #18767 - uploads for companies and departments 2026-03-26 16:02:07 +00:00
snipe ef22fb256b Fixed #18768 - people tab on locations 2026-03-26 14:52:43 +00:00
snipe 6e0dbc94d7 Merge remote-tracking branch 'origin/develop' 2026-03-26 13:01:33 +00:00
snipe 328a724920 Fixed #18764 - check for model category in info-panel 2026-03-26 13:01:23 +00:00
snipe f9e620a77f Merge remote-tracking branch 'origin/develop' 2026-03-26 12:57:18 +00:00
snipe 334f27424e Fixed #18765 - viewKeys hiding serial for non-licenses 2026-03-26 12:57:06 +00:00
snipe 45b7df15c3 Merge remote-tracking branch 'origin/develop' 2026-03-26 12:14:34 +00:00
snipe 316f1be3d0 Fixed typo and spacing 2026-03-26 12:14:20 +00:00
snipe a500dd4e9e Add generic history method and component blade for loggables 2026-03-26 12:13:59 +00:00
snipe 4fc35e30c4 Change permissions for maintenances tab 2026-03-26 11:21:31 +00:00
snipe 920676fbd7 Merge remote-tracking branch 'origin/develop' 2026-03-25 14:46:12 +00:00
snipe c2c90dd614 Fixed history count 2026-03-25 14:45:53 +00:00
snipe c69b83da3f Make user tab more flexible 2026-03-25 14:45:53 +00:00
snipe 3d43de0763 Added icons 2026-03-25 14:45:53 +00:00
snipe 413b571ce8 Merge pull request #18737 from guyguy333/public-s3-proxy
Add S3 proxy option
2026-03-25 14:35:56 +00:00
snipe e777d3a54c Merge pull request #18762 from vmikhnevych/debian13installer
Added #18761: Debian 13 support in snipeit.sh installer script
2026-03-25 14:32:17 +00:00
snipe 1981c7daef Merge remote-tracking branch 'origin/develop' 2026-03-25 14:10:22 +00:00
snipe 6a802f9c3c Added padding to pane 2026-03-25 14:09:19 +00:00
snipe f64912e461 Nicer padding in infopanel 2026-03-25 13:57:35 +00:00
snipe 6e3567f0bf Fixed weird BS tables search text local storage issue 2026-03-25 13:56:57 +00:00
snipe 9406b600f9 Formatting 2026-03-25 12:10:07 +00:00
snipe 1398b4cbd6 Small cleanup on the views, added comments to detail view blades 2026-03-25 12:09:56 +00:00
snipe bde097a827 Merge remote-tracking branch 'origin/develop' 2026-03-25 10:39:28 +00:00
snipe a4ad7a0baf Small tweaks to locations API 2026-03-25 10:39:12 +00:00
snipe a3927f25ce Use shorter buttons for opening in maps 2026-03-25 09:52:32 +00:00
snipe d5d8084f95 Remove unused translations 2026-03-25 09:52:19 +00:00
snipe b48fe19617 Added apple and google icon types 2026-03-25 09:51:56 +00:00
snipe f802ea4d38 Fixed tests 2026-03-25 09:20:51 +00:00
vmikhnevych 8107588576 Added #18761: Debian 13 support in snipeit.sh installer script 2026-03-25 10:01:08 +02:00
snipe 531dce4305 Merge remote-tracking branch 'origin/develop' 2026-03-24 23:12:13 +00:00
snipe 44e81dfb8a Fixed typo 2026-03-24 23:11:58 +00:00
snipe b4753e369c Fixed #18732 - use newer datepicker and wire up the today button for today’s date 2026-03-24 23:10:11 +00:00
snipe 7a5842712b Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/css/build/app.css
#	public/css/build/app.css.map
#	public/css/build/overrides.css
#	public/css/build/overrides.css.map
#	public/css/dist/all.css
#	public/mix-manifest.json
2026-03-24 21:41:59 +00:00
snipe b479cdf358 Allow zero or null for accessory qty 2026-03-24 21:40:27 +00:00
snipe 222277de9a Used correct phone string for mobile 2026-03-24 21:38:10 +00:00
snipe e285ee2931 Small fixes in formatting 2026-03-24 21:33:46 +00:00
Godfrey M c4b20a16ce use full width if no field label present on TZe_24mm_E 2026-03-24 10:30:58 -07:00
snipe ae7d967227 Merge pull request #18754 from grokability/modernize-user-view
Modernize user view
2026-03-24 13:30:39 +00:00
snipe 2135efe8a9 Small fixes 2026-03-24 13:25:47 +00:00
snipe 00c617b2b8 Fixed typo 2026-03-24 13:06:09 +00:00
snipe 8dd105a31a Added missing path info 2026-03-24 12:51:18 +00:00
snipe 66aeaaefdb More tab tweaks 2026-03-24 12:48:23 +00:00
snipe eb68789327 Added cost well 2026-03-24 12:41:56 +00:00
snipe d73ab0daa0 Tweaked styles 2026-03-24 12:41:43 +00:00
snipe bce9a91408 Responsive tabs 2026-03-24 12:41:32 +00:00
snipe 43808b00ad Tweaked styles 2026-03-24 12:41:16 +00:00
snipe 17e8ef8e30 Added role to tab nav 2026-03-24 12:40:19 +00:00
snipe d02278930d Pass an alignment to the dl 2026-03-24 12:39:16 +00:00
snipe c2597a788b Hide the info panel toggle on smaller devices 2026-03-24 12:38:59 +00:00
snipe 227afd3965 Changed icon 2026-03-24 12:36:19 +00:00
snipe fc97b68503 Added API key count 2026-03-24 10:41:11 +00:00
snipe d83ec4ea05 Merge pull request #18755 from marcusmoore/fixes/18495-bulk-audit-date
Fixed #18495: properly handle null audit date during bulk audit
2026-03-23 23:13:27 +00:00
Marcus Moore 420bf9162d Populate test cases 2026-03-23 15:54:45 -07:00
Marcus Moore 8b1ec3d54b Improve test names 2026-03-23 15:39:56 -07:00
Marcus Moore ea3d970743 Add a couple sanity tests 2026-03-23 15:39:43 -07:00
Marcus Moore a0c905de33 Handle null next_audit_date 2026-03-23 15:38:57 -07:00
Marcus Moore 3517e040c4 Organization 2026-03-23 14:22:17 -07:00
Marcus Moore cf1fb87b63 Improve assertions 2026-03-23 14:16:59 -07:00
Marcus Moore b2389fb67c Scaffold test cases 2026-03-23 14:16:55 -07:00
Marcus Moore f9f57fb161 Merge branch 'develop' into fixes/18495-bulk-audit-date
# Conflicts:
#	tests/Feature/Assets/Api/AuditAssetTest.php
#	tests/Feature/Assets/Ui/AuditAssetTest.php
#	tests/Feature/Assets/Ui/CloneAssetTest.php
#	tests/Support/Settings.php
2026-03-23 11:49:34 -07:00
Marcus Moore a9f7d42d77 Formatting 2026-03-23 10:51:54 -07:00
Marcus Moore 537861c232 Add auditing group tag to tests 2026-03-23 10:50:12 -07:00
snipe 4c493efb24 Bulk edit not working on locations now? 2026-03-23 15:00:03 +00:00
snipe 323d308c73 Overhaul user view 2026-03-23 14:59:52 +00:00
snipe 07ddc0e574 Fixed typo 2026-03-23 14:59:41 +00:00
snipe d159f6a3db More copy to clipboard 2026-03-23 14:59:15 +00:00
snipe 1bcfe94818 More icon stuff 2026-03-23 14:58:57 +00:00
snipe f7db8ef03d Fixed typo 2026-03-23 14:58:51 +00:00
snipe 1b2a46d7a0 Made name optional 2026-03-23 14:58:42 +00:00
snipe 0ff2a971a4 Moved new styles into default layout 2026-03-23 14:05:08 +00:00
snipe dce076157b Added new tabs 2026-03-23 14:04:44 +00:00
snipe 7cc0aa336b Added more copy links 2026-03-23 14:04:37 +00:00
snipe 5b193f7a7a Added helper function 2026-03-23 14:04:06 +00:00
snipe e290c70732 Added helper function 2026-03-23 14:03:54 +00:00
snipe a73e68fa1a Added icons 2026-03-23 14:03:45 +00:00
snipe b0578757d2 Merge pull request #18749 from grokability/added-percent-bars
Added percent bars to accessories, etc list views
2026-03-23 10:57:17 +00:00
snipe bf8082f0b9 Formatted tests via pint 2026-03-23 10:48:53 +00:00
snipe 04bebca323 Added unit tests 2026-03-23 10:47:16 +00:00
snipe 0fe753b7da Added % bars to accessories, licenses, etc 2026-03-23 10:41:48 +00:00
snipe 24c3c01851 Merge remote-tracking branch 'origin/develop' 2026-03-23 09:46:13 +00:00
snipe a6a211f386 Check for manufacturer model 2026-03-23 09:46:04 +00:00
snipe 603aa39e3f Merge remote-tracking branch 'origin/develop' 2026-03-23 09:41:07 +00:00
snipe 66607069fe Merge pull request #18740 from grokability/modern-ui-for-assets-view
Modern UI for assets view
2026-03-23 09:40:46 +00:00
snipe 54badc5545 Merge pull request #18748 from grokability/copilot/sub-pr-18740-another-one
[WIP] [WIP] Address feedback on Modern UI for assets view PR
2026-03-23 09:37:05 +00:00
snipe 64c07aa7b6 Merge pull request #18747 from grokability/copilot/sub-pr-18740-again
[WIP] [WIP] Address feedback on Modern UI for assets view PR
2026-03-23 09:36:34 +00:00
copilot-swe-agent[bot] 9d40df179d Guard last_checkout against null before calling diffForHumans
Co-authored-by: snipe <197404+snipe@users.noreply.github.com>
Agent-Logs-Url: https://github.com/grokability/snipe-it/sessions/dfb459ae-6819-47c9-8db5-67d70a8e9e2d
2026-03-23 09:35:35 +00:00
copilot-swe-agent[bot] e9d7189e16 Initial plan 2026-03-23 09:34:43 +00:00
snipe d3fd535605 Merge remote-tracking branch 'origin/develop' 2026-03-23 08:59:36 +00:00
snipe 5992525d8e Merge pull request #18744 from grokability/added-percent-remaining-and-add-asset-button
Added % remaining and create asset button to models view and list
2026-03-23 08:59:23 +00:00
snipe 03b0e24289 Added % remaining and create asset button to models view and list 2026-03-23 08:54:13 +00:00
snipe f7f58ba12d Merge remote-tracking branch 'origin/develop' 2026-03-21 10:59:37 +00:00
snipe 595b5c865f Merge pull request #18742 from grokability/added-armenian-updated-languages
Added Armenian updated languages
2026-03-21 10:59:20 +00:00
snipe 2b2a015f51 Updated language strings 2026-03-21 10:57:56 +00:00
snipe 8160ebf854 Added Amenian 2026-03-21 10:57:28 +00:00
snipe dc27169129 Added Armenian as a language option 2026-03-21 10:47:03 +00:00
snipe 8d7cf50089 Merge remote-tracking branch 'origin/develop' 2026-03-21 10:20:26 +00:00
snipe 8498b9b6bd Use route model binding for department 2026-03-21 10:20:18 +00:00
snipe aead8f6c56 Merge remote-tracking branch 'origin/develop' 2026-03-21 10:03:19 +00:00
snipe d58fda626e Import company model 2026-03-21 10:02:18 +00:00
snipe 0f753ae5b5 Merge pull request #18739 from ubc-cpsc/fix/commonmark-PKSA-21fb-n1x5-5nf7
Fix CVE-2026-33347 and CVE-2026-30838 in league/commonmark
2026-03-21 09:51:01 +00:00
Joël Pittet b91882f5dd Fix commonmark security update 2026-03-20 10:26:26 -07:00
Guillaume Delbergue 1482abc8b9 feat: add PUBLIC_S3_PROXY option to serve public uploads through the app
When PUBLIC_S3_PROXY=true, public uploads (images, logos, avatars) are
served through a proxy controller instead of directly from S3. This
allows using a single fully private S3 bucket for all storage, with no
public ACLs or direct S3 URLs exposed to the browser.

The proxy streams files from the configured public disk with proper
cache headers (ETag, Last-Modified, Cache-Control). Disabled by default
for full backward compatibility.
2026-03-19 19:41:05 +01:00
snipe 1aef412b13 Merge pull request #18735 from marcusmoore/fixes/54250-assigned-to-in-expiring-assets
Fixed FD-54250: Display assigned entity in expiring assets mail
2026-03-19 16:55:09 +00:00
Marcus Moore f9956cf617 Display assigned to in expiring assets mail 2026-03-19 09:30:47 -07:00
Guillaume Delbergue bc7473d863 feat: use Storage::disk('public')->url() instead of hardcoded upload URLs
Several presenters, models, transformers, and Blade views were building
upload URLs by concatenating config('app.url') with hardcoded '/uploads/'
paths. This only works with local storage and breaks when using S3 or
any non-local public disk. Replaced with Storage::disk('public')->url()
which respects the configured filesystem driver.

Made-with: Cursor
2026-03-19 13:52:36 +01:00
snipe 84c42999e4 Merge remote-tracking branch 'origin/develop' 2026-03-18 20:04:59 +00:00
snipe 218190d989 Merge remote-tracking branch 'origin/develop' 2026-03-17 14:01:35 +00:00
snipe 33402f5e0c Merge remote-tracking branch 'origin/develop' 2026-03-17 13:29:13 +00:00
snipe 0ebd103e21 Merge remote-tracking branch 'origin/develop' 2026-03-17 13:11:20 +00:00
snipe 67f5fb72c3 Merge remote-tracking branch 'origin/develop' 2026-03-17 12:22:34 +00:00
snipe 4568180e85 Merge remote-tracking branch 'origin/develop' 2026-03-17 09:07:00 +00:00
snipe 324c937cc4 Merge remote-tracking branch 'origin/develop' 2026-03-16 21:06:38 +00:00
snipe 93ae07cc89 Merge remote-tracking branch 'origin/develop' 2026-03-16 20:08:56 +00:00
snipe 52a9993b0d Merge remote-tracking branch 'origin/develop' 2026-03-16 10:36:13 +00:00
ArturoSirvent 6145c6cc5a Fix backup disk driver configuration for S3 support
- Fix the backup disk in config/filesystems.php to use a dedicated BACKUP_FILESYSTEM_DRIVER env var instead of PRIVATE_FILESYSTEM_DISK
- Add AWS credential fields to the backup disk config so S3 backups work
- Use BACKUP_FILESYSTEM_ROOT with safe default (storage_path('app')) for local driver
- Document BACKUP_FILESYSTEM_DRIVER and BACKUP_FILESYSTEM_ROOT in .env.example

Fixes #14057
2026-03-14 23:24:58 +01:00
snipe 97854ad02d Bumped hash 2026-03-13 18:27:23 +00:00
snipe 500d6e1f2d Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	config/version.php
2026-03-13 18:25:48 +00:00
snipe 20382ea5bf Merge remote-tracking branch 'origin/develop' 2026-03-12 14:54:28 +00:00
snipe f853d25d4f Merge remote-tracking branch 'origin/develop' 2026-03-12 14:46:48 +00:00
snipe 46e7e12cb2 Merge remote-tracking branch 'origin/develop' 2026-03-12 13:41:06 +00:00
snipe ce3a7bb687 Merge remote-tracking branch 'origin/develop' 2026-03-11 17:57:10 +00:00
snipe 17584e4799 Merge remote-tracking branch 'origin/develop' 2026-03-11 09:33:14 +00:00
snipe 1503f90394 Merge remote-tracking branch 'origin/develop' 2026-03-10 20:26:06 +00:00
snipe d2834fcdb9 Merge remote-tracking branch 'origin/develop' 2026-03-10 20:13:10 +00:00
snipe 69d7d6aae2 Fixed #18661 - return true/false in JSON 2026-03-10 15:43:13 +00:00
snipe 982766dd77 Merge remote-tracking branch 'origin/develop' 2026-03-10 11:40:14 +00:00
snipe 81cbad52f7 Merge remote-tracking branch 'origin/develop' 2026-03-10 10:25:21 +00:00
snipe f1ef1bc38a Merge remote-tracking branch 'origin/develop' 2026-03-10 10:22:57 +00:00
snipe b696642993 Merge remote-tracking branch 'origin/develop' 2026-03-10 10:14:47 +00:00
snipe e7bb7d3656 Merge remote-tracking branch 'origin/develop' 2026-03-10 09:54:08 +00:00
snipe ffc9e882d7 Merge remote-tracking branch 'origin/develop' 2026-03-08 11:37:38 +00:00
snipe 6e7ff15e78 Merge remote-tracking branch 'origin/develop' 2026-03-07 20:50:31 +00:00
snipe b0c45c7179 Merge remote-tracking branch 'origin/develop' 2026-03-07 11:05:48 +00:00
snipe 0fabc5d88d Merge remote-tracking branch 'origin/develop' 2026-03-06 21:55:59 +00:00
snipe 33ae9f1d5b Merge remote-tracking branch 'origin/develop' 2026-03-06 20:15:11 +00:00
snipe f27aae5e31 Merge remote-tracking branch 'origin/develop' 2026-03-06 14:03:50 +00:00
snipe ff6a6407f5 Merge remote-tracking branch 'origin/develop' 2026-03-06 13:40:16 +00:00
snipe 32a6c8edbe Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	public/js/dist/all.js
#	public/js/dist/all.js.map
#	public/mix-manifest.json
2026-03-06 10:48:34 +00:00
snipe f0d1697108 Merge remote-tracking branch 'origin/develop' 2026-03-06 06:46:08 +00:00
snipe 90cb53566c Merge remote-tracking branch 'origin/develop' 2026-03-06 06:24:38 +00:00
snipe 64982d01cf Merge remote-tracking branch 'origin/develop' 2026-03-06 05:59:23 +00:00
snipe b6cad58917 Merge remote-tracking branch 'origin/develop' 2026-03-06 05:39:20 +00:00
snipe 4f9c952dbe Merge remote-tracking branch 'origin/develop' 2026-03-06 05:22:09 +00:00
snipe b9c3c8954f Merge remote-tracking branch 'origin/develop' 2026-03-06 05:17:51 +00:00
snipe 82b6159475 Merge remote-tracking branch 'origin/develop' 2026-03-06 04:46:33 +00:00
Marcus Moore a026ca92ff Populate tests 2026-03-03 14:46:10 -08:00
Marcus Moore 1b7fe4f728 Scaffold test cases 2026-03-03 11:07:28 -08:00
Marcus Moore 2bf2d55c6e Fix test case 2026-03-03 11:07:18 -08:00
snipe dbe998d9cf Merge remote-tracking branch 'origin/develop' 2026-03-02 18:36:13 +01:00
snipe 79907a2770 Merge remote-tracking branch 'origin/develop' 2026-02-26 16:04:07 +00:00
snipe 6f60ef9ec2 Merge remote-tracking branch 'origin/develop' 2026-02-25 19:46:20 +00:00
snipe 581867eefc Merge remote-tracking branch 'origin/develop' 2026-02-25 19:05:48 +00:00
snipe 234855f225 Merge remote-tracking branch 'origin/develop' 2026-02-25 18:42:04 +00:00
snipe 0b8176a730 Merge remote-tracking branch 'origin/develop' 2026-02-25 17:41:38 +00:00
snipe d1be571d4d Merge remote-tracking branch 'origin/develop' 2026-02-25 16:41:47 +00:00
snipe d392439f82 Merge remote-tracking branch 'origin/develop' 2026-02-25 14:23:13 +00:00
snipe f423b88b16 Merge remote-tracking branch 'origin/develop' 2026-02-25 12:39:39 +00:00
snipe 853aed5954 Merge remote-tracking branch 'origin/develop' 2026-02-25 12:03:32 +00:00
snipe 947a149d08 Merge remote-tracking branch 'origin/develop' 2026-02-25 11:51:03 +00:00
snipe 3aec52eab0 Merge remote-tracking branch 'origin/develop' 2026-02-24 12:00:15 +00:00
snipe 8d0fda88b7 Tagged 8.4.0 release
# Conflicts:
#	config/version.php
2026-02-23 20:41:11 +00:00
snipe 91a95dbc66 Merge remote-tracking branch 'origin/develop' 2026-02-23 14:44:08 +00:00
snipe a15adc806b Merge remote-tracking branch 'origin/develop' 2026-02-23 14:30:54 +00:00
snipe f328da37bc Merge remote-tracking branch 'origin/develop' 2026-02-23 11:41:54 +00:00
snipe 3adc8f279b Merge remote-tracking branch 'origin/develop' 2026-02-21 13:17:52 +00:00
snipe 41c75022a9 Merge remote-tracking branch 'origin/develop' 2026-02-21 12:53:42 +00:00
snipe 84924a68b7 Merge remote-tracking branch 'origin/develop' 2026-02-20 15:01:15 +00:00
snipe 5a3a63e0a4 Merge remote-tracking branch 'origin/develop' 2026-02-20 13:10:55 +00:00
snipe 980cc5704f Switched branch name to master 2026-02-20 13:08:23 +00:00
snipe 28054a9112 Merge remote-tracking branch 'origin/develop' 2026-02-20 13:07:33 +00:00
snipe 7a312f5868 Merge remote-tracking branch 'origin/develop' 2026-02-20 12:12:04 +00:00
snipe 5ce493180d Merge remote-tracking branch 'origin/develop' 2026-02-20 11:56:33 +00:00
snipe bbdc78a13c Merge remote-tracking branch 'origin/develop' 2026-02-20 09:36:48 +00:00
snipe 43971b9625 Merge remote-tracking branch 'origin/develop' 2026-02-19 19:15:59 +00:00
snipe f27a3a2c61 Build prod JS assets 2026-02-19 15:13:19 +00:00
snipe b96d0d55c9 Merge remote-tracking branch 'origin/develop' 2026-02-19 15:12:52 +00:00
snipe f699935f5f Merge remote-tracking branch 'origin/develop' 2026-02-19 12:05:17 +00:00
snipe 8336cf5baa Merge remote-tracking branch 'origin/develop' 2026-02-19 11:42:54 +00:00
snipe d3d90abba7 Merge remote-tracking branch 'origin/develop' 2026-02-19 11:31:05 +00:00
snipe bdaf13da4c Merge remote-tracking branch 'origin/develop'
# Conflicts:
#	resources/views/maintenances/view.blade.php
2026-02-19 11:11:18 +00:00
snipe e92e550e9c Null operator for maintenances 2026-02-18 16:32:40 +00:00
1860 changed files with 31585 additions and 136815 deletions
+9
View File
@@ -4262,6 +4262,15 @@
"contributions": [
"code"
]
},
{
"login": "Husky-Devel",
"name": "Peter Gallwas",
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
"profile": "https://www.husky.nz",
"contributions": [
"code"
]
}
]
}
+18 -1
View File
@@ -90,7 +90,16 @@ IMAGE_LIB=gd
# --------------------------------------------
# OPTIONAL: BACKUP SETTINGS
# --------------------------------------------
# Backup filesystem configuration
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
# Default: local (backward compatible)
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
# For local driver: leave commented for default to storage_path("app")
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
#--------------------------------------------
BACKUP_FILESYSTEM_DRIVER=local
#BACKUP_FILESYSTEM_ROOT=
MAIL_BACKUP_NOTIFICATION_DRIVER=null
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
BACKUP_ENV=true
@@ -149,6 +158,14 @@ REDIS_PORT=null
MEMCACHED_HOST=null
MEMCACHED_PORT=null
# --------------------------------------------
# OPTIONAL: S3 PROXY
# When enabled, public uploads (images, logos, avatars) are served through
# the application instead of directly from S3. This allows using a single
# fully private S3 bucket for all storage.
# --------------------------------------------
PUBLIC_S3_PROXY=false
# --------------------------------------------
# OPTIONAL: PUBLIC S3 Settings
# --------------------------------------------
+1 -1
View File
@@ -69,7 +69,7 @@ Thanks goes to all of these wonderful people ([emoji key](https://github.com/ken
| [<img src="https://avatars.githubusercontent.com/u/10965027?v=4" width="110px;"/><br /><sub>Ellie</sub>](https://leafedfox.xyz/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=LeafedFox "Code") | [<img src="https://avatars.githubusercontent.com/u/20960555?v=4" width="110px;"/><br /><sub>GA Stamper</sub>](https://github.com/gastamper)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gastamper "Code") | [<img src="https://avatars.githubusercontent.com/u/206553556?v=4" width="110px;"/><br /><sub>Guillaume Lefranc</sub>](https://github.com/gl-pup)<br />[💻](https://github.com/snipe/snipe-it/commits?author=gl-pup "Code") | [<img src="https://avatars.githubusercontent.com/u/733892?v=4" width="110px;"/><br /><sub>Hajo Möller</sub>](https://github.com/dasjoe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=dasjoe "Code") | [<img src="https://avatars.githubusercontent.com/u/3420063?v=4" width="110px;"/><br /><sub>Istvan Basa</sub>](https://github.com/pottom)<br />[💻](https://github.com/snipe/snipe-it/commits?author=pottom "Code") | [<img src="https://avatars.githubusercontent.com/u/810824?v=4" width="110px;"/><br /><sub>JJ Asghar</sub>](https://jjasghar.github.io/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jjasghar "Code") | [<img src="https://avatars.githubusercontent.com/u/40404495?v=4" width="110px;"/><br /><sub>James E. Msenga</sub>](https://github.com/JemCdo)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JemCdo "Code") |
| [<img src="https://avatars.githubusercontent.com/u/6865786?v=4" width="110px;"/><br /><sub>Jan Felix Wiebe</sub>](https://github.com/jfwiebe)<br />[💻](https://github.com/snipe/snipe-it/commits?author=jfwiebe "Code") | [<img src="https://avatars.githubusercontent.com/u/43412008?v=4" width="110px;"/><br /><sub>Jo Drexl</sub>](https://www.nfon.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=drexljo "Code") | [<img src="https://avatars.githubusercontent.com/u/4807843?v=4" width="110px;"/><br /><sub>Austin Sasko</sub>](https://github.com/austinsasko)<br />[💻](https://github.com/snipe/snipe-it/commits?author=austinsasko "Code") | [<img src="https://avatars.githubusercontent.com/u/4875039?v=4" width="110px;"/><br /><sub>Jasson</sub>](http://jassoncordones.github.io)<br />[💻](https://github.com/snipe/snipe-it/commits?author=JassonCordones "Code") | [<img src="https://avatars.githubusercontent.com/u/76069640?v=4" width="110px;"/><br /><sub>Okean</sub>](https://github.com/Tinyblargon)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Tinyblargon "Code") | [<img src="https://avatars.githubusercontent.com/u/6515064?v=4" width="110px;"/><br /><sub>Alejandro Medrano</sub>](https://www.lst.tfo.upm.es/alejandro-medrano/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=amedranogil "Code") | [<img src="https://avatars.githubusercontent.com/u/58696401?v=4" width="110px;"/><br /><sub>Lukas Kraic</sub>](https://github.com/lukaskraic)<br />[💻](https://github.com/snipe/snipe-it/commits?author=lukaskraic "Code") |
| [<img src="https://avatars.githubusercontent.com/u/1571724?v=4" width="110px;"/><br /><sub>Герхард PICCORO Lenz McKAY </sub>](https://github-readme-stats.vercel.app/api?username=mckaygerhard)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mckaygerhard "Code") | [<img src="https://avatars.githubusercontent.com/u/15015119?v=4" width="110px;"/><br /><sub>Johannes Pollitt</sub>](https://github.com/FlorestanII)<br />[💻](https://github.com/snipe/snipe-it/commits?author=FlorestanII "Code") | [<img src="https://avatars.githubusercontent.com/u/14185442?v=4" width="110px;"/><br /><sub>Michael Strobel</sub>](https://strobelm.de)<br />[💻](https://github.com/snipe/snipe-it/commits?author=strobelm "Code") | [<img src="https://avatars.githubusercontent.com/u/634790?v=4" width="110px;"/><br /><sub>Nicky West</sub>](http://nickwest.me)<br />[💻](https://github.com/snipe/snipe-it/commits?author=nickwest "Code") | [<img src="https://avatars.githubusercontent.com/u/1347327?v=4" width="110px;"/><br /><sub>akaspeh1</sub>](https://github.com/akaspeh1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=akaspeh1 "Code") | [<img src="https://avatars.githubusercontent.com/u/2880129?v=4" width="110px;"/><br /><sub>Sebastian Marsching</sub>](http://sebastian.marsching.com/)<br />[💻](https://github.com/snipe/snipe-it/commits?author=smarsching "Code") | [<img src="https://avatars.githubusercontent.com/u/40658372?v=4" width="110px;"/><br /><sub>Mo</sub>](https://github.com/mohammad-ahmadi1)<br />[💻](https://github.com/snipe/snipe-it/commits?author=mohammad-ahmadi1 "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") |
| [<img src="https://avatars.githubusercontent.com/u/20994684?v=4" width="110px;"/><br /><sub>Owen V. Hayes</sub>](https://github.com/MarvelousAnything)<br />[💻](https://github.com/snipe/snipe-it/commits?author=MarvelousAnything "Code") | [<img src="https://avatars.githubusercontent.com/u/75509373?v=4" width="110px;"/><br /><sub>Peter Gallwas</sub>](https://www.husky.nz)<br />[💻](https://github.com/snipe/snipe-it/commits?author=Husky-Devel "Code") |
<!-- ALL-CONTRIBUTORS-LIST:END -->
This project follows the [all-contributors](https://github.com/kentcdodds/all-contributors) specification. Contributions of any kind welcome!
+1 -1
View File
@@ -1,6 +1,6 @@
![snipe-it-by-grok](https://github.com/grokability/snipe-it/assets/197404/b515673b-c7c8-4d9a-80f5-9fa58829a602)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/804dd1beb14a41f38810ab77d64fc4fc)](https://app.codacy.com/gh/grokability/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [![Tests in MySQL](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml/badge.svg)](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
[![Crowdin](https://d322cqt584bo4o.cloudfront.net/snipe-it/localized.svg)](https://crowdin.com/project/snipe-it) [![Docker Pulls](https://img.shields.io/docker/pulls/snipe/snipe-it.svg)](https://hub.docker.com/r/snipe/snipe-it/) [![Tests in MySQL](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml/badge.svg)](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
[![All Contributors](https://img.shields.io/badge/all_contributors-331-orange.svg?style=flat-square)](#contributing) [![Discord](https://badgen.net/badge/icon/discord?icon=discord&label)](https://discord.gg/yZFtShAcKk)
## Snipe-IT - Open Source Asset Management System
@@ -0,0 +1,109 @@
<?php
namespace App\Actions\Breadcrumbs;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
use Tabuna\Breadcrumbs\Trail;
final class BuildAcceptanceBreadcrumbs
{
public static function forAcceptance(Trail $trail, CheckoutAcceptance|int|string $acceptance): void
{
$acceptance = self::resolveAcceptance($acceptance);
$trail->parent('home');
if (! $acceptance instanceof CheckoutAcceptance) {
self::appendProfileContext($trail);
return;
}
if (! self::isSignInPlaceFlow($acceptance)) {
self::appendProfileContext($trail);
$trail->push(trans('general.accept_item'), route('account.accept.item', $acceptance));
return;
}
self::appendCheckoutFlowContext($trail, $acceptance);
$trail->push(self::buildSignInPlaceLabel($acceptance));
}
private static function resolveAcceptance(CheckoutAcceptance|int|string $acceptance): ?CheckoutAcceptance
{
if ($acceptance instanceof CheckoutAcceptance) {
return $acceptance;
}
if (is_numeric($acceptance)) {
return CheckoutAcceptance::find((int) $acceptance);
}
return null;
}
private static function isSignInPlaceFlow(CheckoutAcceptance $acceptance): bool
{
return (int) session('sign_in_place_acceptance_id') === (int) $acceptance->id;
}
private static function appendProfileContext(Trail $trail): void
{
$trail->push(trans('general.profile'), route('account'));
$trail->push(trans('general.accept_items'), route('account.accept'));
}
private static function appendCheckoutFlowContext(Trail $trail, CheckoutAcceptance $acceptance): void
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
$trail->push(trans('general.assets'), route('hardware.index'));
$trail->push($checkoutable->display_name ?? trans('general.asset'), route('hardware.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof LicenseSeat) {
$license = $checkoutable->license;
if ($license instanceof License) {
$trail->push(trans('general.licenses'), route('licenses.index'));
$trail->push($license->display_name ?? trans('general.license'), route('licenses.show', $license));
$trail->push(trans('general.checkout'));
}
return;
}
if ($checkoutable instanceof Consumable) {
$trail->push(trans('general.consumables'), route('consumables.index'));
$trail->push($checkoutable->display_name ?? trans('general.consumable'), route('consumables.show', $checkoutable));
$trail->push(trans('general.checkout'));
return;
}
if ($checkoutable instanceof Accessory) {
$trail->push(trans('general.accessories'), route('accessories.index'));
$trail->push($checkoutable->display_name ?? trans('general.accessory'), route('accessories.show', $checkoutable));
$trail->push(trans('general.checkout'));
}
}
private static function buildSignInPlaceLabel(CheckoutAcceptance $acceptance): string
{
if ($acceptance->assignedTo instanceof User) {
return sprintf('%s for %s', trans('general.sign_in_place'), $acceptance->assignedTo->display_name);
}
return trans('general.sign_in_place');
}
}
@@ -44,7 +44,7 @@ class CreateCheckoutRequestAction
$asset->request();
$asset->increment('requests_counter', 1);
try {
$settings->notify(new RequestAssetNotification($data));
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
} catch (\Exception $e) {
Log::warning($e);
}
@@ -0,0 +1,30 @@
<?php
namespace App\Actions\Permissions;
final class NormalizePermissionsPayloadAction
{
/**
* Normalize permissions payloads from request/model to a consistent associative array.
*
* @return array<string, mixed>
*/
public static function run(mixed $permissions): array
{
if (is_string($permissions)) {
$decoded = json_decode($permissions, true);
return is_array($decoded) ? $decoded : [];
}
if (is_array($permissions)) {
return $permissions;
}
if ($permissions instanceof \stdClass) {
return (array) $permissions;
}
return [];
}
}
@@ -0,0 +1,36 @@
<?php
namespace App\Actions\Permissions;
use App\Models\User;
final class PreserveUnauthorizedPrivilegedPermissionsAction
{
/**
* Preserve privileged permission keys unless the authenticated user may manage them.
*
* @param array<string, mixed> $requestedPermissions
* @param array<string, mixed> $originalPermissions
* @return array<string, mixed>
*/
public static function run(array $requestedPermissions, User $authenticatedUser, array $originalPermissions = []): array
{
if (! $authenticatedUser->isSuperUser()) {
if (array_key_exists('superuser', $originalPermissions)) {
$requestedPermissions['superuser'] = $originalPermissions['superuser'];
} else {
unset($requestedPermissions['superuser']);
}
}
if ((! $authenticatedUser->isAdmin()) && (! $authenticatedUser->isSuperUser())) {
if (array_key_exists('admin', $originalPermissions)) {
$requestedPermissions['admin'] = $originalPermissions['admin'];
} else {
unset($requestedPermissions['admin']);
}
}
return $requestedPermissions;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
namespace App\Console\Commands;
use App\Models\Asset;
use Illuminate\Console\Command;
use Illuminate\Support\MessageBag;
class ValidateAssets extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'snipeit:validate-assets {--all : Display the valid assets in your table output as well} ';
/**
* The console command description.
*
* @var string
*/
protected $description = 'This runs through the list of assets and checks for any validation errors that would prevent it from being updated or checked in or out. ';
/**
* Execute the console command.
*/
public function handle()
{
$showAll = (bool) $this->option('all');
$assets = Asset::query()
->whereNull('deleted_at')
->with('model')
->orderBy('assets.created_at', 'desc')
->get();
if (! $showAll) {
$this->info('Run this command with the --all option to see the full list in the console.');
}
$rows = $assets
->filter(fn (Asset $asset) => $showAll || ! $asset->isValid())
->map(fn (Asset $asset) => [
trans('general.id') => $asset->id,
trans('admin/hardware/form.tag') => $asset->asset_tag,
trans('admin/hardware/form.serial') => $asset->serial ?? '',
trans('admin/hardware/form.model') => $asset->model?->name ?? '',
trans('general.model_no') => $asset->model?->model_number ?? '',
trans('general.error') => $asset->isValid() ? '√ valid' : $this->formatValidationErrors($asset),
])
->values()
->all();
$this->table(
[
trans('general.id'),
trans('admin/hardware/form.tag'),
trans('admin/hardware/form.serial'),
trans('admin/hardware/form.model'),
trans('general.model_no'),
trans('general.error'),
],
$rows
);
return self::SUCCESS;
}
private function formatValidationErrors(Asset $asset): string
{
$errors = $asset->getErrors();
$messages = [];
if ($errors instanceof MessageBag) {
$messages = $errors->all();
} elseif (is_array($errors)) {
$messages = $errors;
} else {
$messages = [(string) $errors];
}
$prefixedMessages = collect($messages)
->map(fn ($message) => trim((string) $message))
->filter()
->map(fn (string $message) => str_starts_with($message, '✘') ? $message : '✘ '.$message)
->values()
->all();
return implode(PHP_EOL, $prefixedMessages);
}
}
+3
View File
@@ -13,6 +13,7 @@ enum ActionType: string
// Assets/Accessories/Components/Licenses/Consumables
case Checkout = 'checkout';
case CheckinFrom = 'checkin from';
case ForceCheckin = 'force checkin';
case Requested = 'requested';
case RequestCanceled = 'request canceled';
case Accepted = 'accepted';
@@ -23,6 +24,8 @@ enum ActionType: string
// Users
case TwoFactorReset = '2FA reset';
case Merged = 'merged';
case TokenRevoked = 'token revoked';
case TokenUnrevoked = 'token unrevoked';
// Licenses
case DeleteSeats = 'delete seats';
+4 -1
View File
@@ -22,12 +22,14 @@ class CheckoutableCheckedOut
public int $quantity;
public bool $signInPlace;
/**
* Create a new event instance.
*
* @return void
*/
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1)
public function __construct($checkoutable, $checkedOutTo, User $checkedOutBy, $note, $originalValues = [], $quantity = 1, bool $signInPlace = false)
{
$this->checkoutable = $checkoutable;
$this->checkedOutTo = $checkedOutTo;
@@ -35,5 +37,6 @@ class CheckoutableCheckedOut
$this->note = $note;
$this->originalValues = $originalValues;
$this->quantity = $quantity;
$this->signInPlace = $signInPlace;
}
}
+1 -1
View File
@@ -201,7 +201,7 @@ class Handler extends ExceptionHandler
protected function unauthenticated($request, AuthenticationException $exception)
{
if ($request->expectsJson()) {
return response()->json(['error' => 'Unauthorized or unauthenticated.'], 401);
return response()->json(['error' => trans('general.unauthorized')], 401);
}
return redirect()->guest('login');
+29 -5
View File
@@ -1511,7 +1511,7 @@ class Helper
case 'pt':
return (1 / 72) * static::getUnitConversionFactor('in');
default:
throw new \InvalidArgumentException('Unit: \''.$unit.'\' is not supported');
throw new \InvalidArgumentException('Unit: '.e($unit).' is not supported');
return false;
}
@@ -1629,10 +1629,20 @@ class Helper
// return to assignment target
if ($redirect_option == 'target') {
$userId = $request->assigned_user ?? $checkedInFrom;
$locationId = $request->assigned_location ?? $checkedInFrom;
$assetId = $request->assigned_asset ?? $checkedInFrom;
return match ($checkout_to_type) {
'user' => redirect()->route('users.show', $request->assigned_user ?? $checkedInFrom),
'location' => redirect()->route('locations.show', $request->assigned_location ?? $checkedInFrom),
'asset' => redirect()->route('hardware.show', $request->assigned_asset ?? $checkedInFrom),
'user' => $userId
? redirect()->route('users.show', $userId)
: redirect()->route('users.index'),
'location' => $locationId
? redirect()->route('locations.show', $locationId)
: redirect()->route('locations.index'),
'asset' => $assetId
? redirect()->route('hardware.show', $assetId)
: redirect()->route('hardware.index'),
};
}
@@ -1816,6 +1826,8 @@ class Helper
$labelWidth = ($maxLabelWidthPerUnit * $labelSize) + $labelPadding;
$valueX = $currentX + $labelWidth + $gap;
$valueWidth = $usableWidth - $labelWidth - $gap;
$fullValueX = $currentX;
$fullValueWidth = $usableWidth;
return compact(
'scale',
@@ -1829,7 +1841,19 @@ class Helper
'rowAdvance',
'labelWidth',
'valueX',
'valueWidth'
'valueWidth',
'fullValueX',
'fullValueWidth',
);
}
public static function normalizeFullModelName($model): string
{
if (str_contains($model, 'App\\Models\\')) {
return $model;
}
return 'App\\Models\\'.ucwords($model);
}
}
+20 -4
View File
@@ -7,6 +7,10 @@ class IconHelper
public static function icon($type)
{
switch ($type) {
case 'apple':
return 'fa-brands fa-apple';
case 'google':
return 'fa-brands fa-google';
case 'checkout':
return 'fa-solid fa-rotate-left';
case 'checkin':
@@ -116,7 +120,7 @@ class IconHelper
case 'password':
return 'fa-solid fa-key';
case 'api-key':
return 'fa-solid fa-user-secret';
return 'fas fa-user-secret';
case 'nav-toggle':
return 'fas fa-bars';
case 'dashboard':
@@ -139,10 +143,18 @@ class IconHelper
return 'fa-regular fa-clipboard';
case 'paperclip':
return 'fas fa-paperclip';
case 'files':
return 'fa-solid fa-file-contract';
case 'contact-card':
return 'fa-regular fa-id-card';
case 'files':
return 'fa-solid fa-file-contract fa-fw';
case 'eula':
case 'eulas':
return 'fa-regular fa-handshake';
case 'star':
case 'vip':
return 'fa-solid fa-star';
case 'remote':
return 'fa-solid fa-house-laptop';
case 'more-info':
case 'support':
return 'far fa-life-ring';
@@ -213,7 +225,7 @@ class IconHelper
case 'highlight':
return 'fa-solid fa-highlighter';
case 'manager':
return 'fa-solid fa-building-user';
return 'fa-solid fa-user-tie';
case 'company':
return 'fa-regular fa-building';
case 'parent':
@@ -229,8 +241,10 @@ class IconHelper
case 'terminates':
return 'fa-regular fa-calendar-xmark';
case 'deleted-date':
case 'end_date':
return 'fa-solid fa-calendar-xmark';
case 'expected_checkin':
case 'start_date':
return 'fa-solid fa-calendar-check';
case 'eol':
return 'fa-regular fa-calendar-days';
@@ -252,6 +266,8 @@ class IconHelper
return 'fa-solid fa-file-invoice';
case 'checkout-all':
return 'fa-solid fa-arrows-down-to-people';
case 'checkin-all':
return 'fa-solid fa-arrows-turn-right';
case 'square-right':
return 'fa-regular fa-square-caret-right';
case 'square-left':
+8 -31
View File
@@ -2,7 +2,6 @@
namespace App\Helpers;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -20,7 +19,14 @@ class StorageHelper
return response()->download(Storage::disk($disk)->path($filename)); // works for PRIVATE or public?!
case 's3':
return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); // works for private or public, I guess?
Storage::disk($disk)->temporaryUrl(
$filename,
now()->addMinutes(5),
[
'ResponseContentType' => 'application/octet-stream',
'ResponseContentDisposition' => 'attachment; filename=download-file',
]
);
default:
return Storage::disk($disk)->download($filename);
@@ -119,33 +125,4 @@ class StorageHelper
return null;
}
/**
* Decide whether to show the file inline or download it.
*/
public static function showOrDownloadFile($file, $filename)
{
$headers = [];
if (request('inline') == 'true') {
$headers = [
'Content-Disposition' => 'inline',
];
// This is NOT allowed as inline - force it to be displayed as text in the browser
if (self::allowSafeInline($file) != true) {
$headers = array_merge($headers, ['Content-Type' => 'text/plain']);
}
}
// Everything else seems okay, but the file doesn't exist on the server.
if (Storage::missing($file)) {
throw new FileNotFoundException;
}
return Storage::download($file, $filename, $headers);
}
}
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\AccessoryCheckoutRequest;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Contracts\View\View;
@@ -88,12 +89,53 @@ class AccessoryCheckoutController extends Controller
$request->input('note'),
[],
$accessory->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
$request->request->add(['assigned_to' => $target->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested for a user checkout, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
$targetUser = User::find($target->id);
if (! $targetUser instanceof User) {
return redirect()->route('accessories.checkout.show', $accessory)
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
}
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
->where('checkoutable_id', $accessory->id)
->where('assigned_to_id', $targetUser->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($accessory);
$acceptance->assignedTo()->associate($targetUser);
$acceptance->qty = $accessory->checkout_qty;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $accessory->id,
'sign_in_place_resource_type' => 'Accessories',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/accessories/message.checkout.success'));
}
// Redirect to the new accessory page
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
@@ -6,9 +6,16 @@ use App\Events\CheckoutAccepted;
use App\Events\CheckoutDeclined;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\AcceptSignatureRequest;
use App\Mail\CheckoutAcceptanceResponseMail;
use App\Models\Accessory;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Consumable;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
use App\Models\User;
use App\Notifications\AcceptanceItemAcceptedNotification;
@@ -30,7 +37,8 @@ class AcceptanceController extends Controller
*/
public function index(): View
{
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get();
$user = auth()->user();
$acceptances = CheckoutAcceptance::forUser($user)->pending()->get();
return view('account/accept.index', compact('acceptances'));
}
@@ -40,27 +48,43 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Request $request, $id): View|RedirectResponse
{
$currentUser = auth()->user();
if (! $currentUser instanceof User) {
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$acceptance = CheckoutAcceptance::find($id);
if (is_null($acceptance)) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
if (! $acceptance) {
return redirect()->route('account.accept')->with('error', trans('general.generic_model_not_found', ['model' => trans('general.accept_eula')]));
}
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
if (! Company::isCurrentUserHasAccess($acceptance->checkoutable)) {
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
return view('account/accept.create', compact('acceptance'));
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
}
/**
@@ -68,22 +92,32 @@ class AcceptanceController extends Controller
*
* @param int $id
*/
public function store(Request $request, $id): RedirectResponse
public function store(AcceptSignatureRequest $request, CheckoutAcceptance $acceptance): RedirectResponse
{
$currentUser = auth()->user();
if (! $acceptance = CheckoutAcceptance::find($id)) {
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
if (! $currentUser instanceof User) {
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
}
$assigned_user = User::find($acceptance->assigned_to_id);
$assignedUser = User::find($acceptance->assigned_to_id);
$settings = Setting::getSettings();
$requiresSignature = (string) $settings->require_accept_signature === '1';
$sig_filename = '';
$encodedSignatureImage = null;
if (! $acceptance->isPending()) {
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
}
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
}
if (! $acceptance->isCheckedOutTo(auth()->user())) {
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
}
@@ -112,14 +146,25 @@ class AcceptanceController extends Controller
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
// If signatures are required, make sure we have one
if (Setting::getSettings()->require_accept_signature == '1') {
if ($requiresSignature) {
// The item was accepted, check for a signature
if ($request->filled('signature_output')) {
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
$data_uri = $request->input('signature_output');
$encoded_image = explode(',', $data_uri);
$decoded_image = base64_decode($encoded_image[1]);
$dataUri = (string) $request->input('signature_output');
$encodedSignatureImage = Str::contains($dataUri, ',')
? Str::after($dataUri, ',')
: $dataUri;
$decoded_image = base64_decode($encodedSignatureImage, true);
if ($decoded_image === false) {
return redirect()->back()->with('error', trans('general.shitty_browser'));
}
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
$encodedSignatureImage = base64_encode($decoded_image);
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
// No image data is present, kick them back.
@@ -133,7 +178,34 @@ class AcceptanceController extends Controller
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
$encoded_logo = null;
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
}
if ($isSignInPlaceAdminFlow && (! $acceptance->signed_in_place || (int) $acceptance->signed_in_place_admin !== (int) $currentUser->id)) {
$acceptance->forceFill([
'signed_in_place' => true,
'signed_in_place_admin' => $currentUser->id,
])->save();
}
// Determine signed_in_place and admin for PDF/email output
$signedInPlace = $isSignInPlaceAdminFlow ? true : (bool) $acceptance->signed_in_place;
$signedInPlaceAdmin = null;
if ($isSignInPlaceAdminFlow) {
$signedInPlaceAdmin = [
'name' => $currentUser->display_name,
'username' => $currentUser->username,
'email' => $currentUser->email,
];
} elseif ($acceptance->signed_in_place && $acceptance->signed_in_place_admin) {
$admin = User::find($acceptance->signed_in_place_admin);
if ($admin) {
$signedInPlaceAdmin = [
'name' => $admin->display_name,
'username' => $admin->username,
'email' => $admin->email,
];
}
}
// Get the data array ready for the notifications and PDF generation
@@ -142,41 +214,66 @@ class AcceptanceController extends Controller
'item_name' => $item->display_name, // this handles licenses seats, which don't have a 'name' field
'item_model' => $item->model?->name,
'item_serial' => $item->serial,
'item_status' => $item->assetstatus?->name,
'item_status' => $item->status?->name,
'eula' => $item->getEula(),
'note' => $request->input('note'),
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
'assigned_to' => $assigned_user->display_name,
'email' => $assigned_user->email,
'employee_num' => $assigned_user->employee_num,
'assigned_to' => $assignedUser->display_name,
'email' => $assignedUser->email,
'employee_num' => $assignedUser->employee_num,
'site_name' => $settings->site_name,
'company_name' => $item->company?->name ?? $settings->site_name,
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
'logo' => ($encoded_logo) ?? null,
'date_settings' => $settings->date_display_format,
'qty' => $acceptance->qty ?? 1,
'signed_in_place' => $signedInPlace,
];
if ($signedInPlaceAdmin) {
$data['signed_in_place_admin'] = $signedInPlaceAdmin;
}
if ($request->input('asset_acceptance') == 'accepted') {
// Add custom fields for asset (show_in_email = 1, field_encrypted = 0)
$customFields = [];
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
$fields = $item->model->fieldset->fields->where('show_in_email', true)->where('field_encrypted', false);
foreach ($fields as $field) {
$label = $field->name;
$dbColumn = $field->db_column;
$value = $item->$dbColumn;
if (! is_null($value) && $value !== '') {
$customFields[] = [
'label' => $label,
'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';
// Generate the PDF content
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
Storage::put('private_uploads/eula-pdfs/'.$pdf_filename, $pdf_content);
// Log the acceptance
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
$accept_qty = $request->input('accept_qty', $acceptance->qty ?? 1);
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'), $accept_qty);
$alwaysSendAcceptanceCopy = (bool) (config('app.always_send_email') || config('app.always_send_eula'));
// Send the PDF to the signing user
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
if (($alwaysSendAcceptanceCopy || ($request->input('send_copy') === '1')) && ($assignedUser->email !== '')) {
// Add the attachment for the signing user into the $data array
$data['file'] = $pdf_filename;
try {
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
} catch (Exception $e) {
Log::warning($e);
}
@@ -215,7 +312,7 @@ class AcceptanceController extends Controller
$recipient,
$request->input('asset_acceptance') === 'accepted',
));
Log::debug('Send email notification sucess on checkout acceptance response.');
Log::debug('Send email notification success on checkout acceptance response.');
}
} catch (Exception $e) {
Log::error($e->getMessage());
@@ -223,7 +320,163 @@ class AcceptanceController extends Controller
}
}
if ($isSignInPlaceAdminFlow) {
$request->request->add(['assigned_user' => $assignedUser?->id]);
$redirect = Helper::getRedirectOption(
$request,
session('sign_in_place_item_id'),
session('sign_in_place_resource_type'),
);
session()->forget([
'sign_in_place_acceptance_id',
'sign_in_place_item_id',
'sign_in_place_resource_type',
]);
return $redirect->with('success', $return_msg);
}
return redirect()->to('account/accept')->with('success', $return_msg);
}
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
{
$currentUser = auth()->user();
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
}
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
{
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
$checkoutLog = Actionlog::query()
->where('action_type', 'checkout')
->where('item_type', $itemType)
->where('item_id', $itemId)
->where('target_type', User::class)
->where('target_id', $acceptance->assigned_to_id)
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
->latest('id')
->first();
return $checkoutLog?->adminuser?->display_name;
}
/**
* Action logs normalize license seat checkouts to the parent license.
*
* @return array{0: class-string, 1: int}
*/
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof LicenseSeat) {
return [License::class, (int) $checkoutable->license_id];
}
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
}
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
{
$redirectOption = session('redirect_option');
$checkoutToType = session('checkout_to_type');
if (session('sign_in_place') !== true) {
return false;
}
if ($redirectOption === null) {
return false;
}
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
return false;
}
return ! $acceptance->isCheckedOutTo($currentUser)
&& $currentUser->can('checkout', $acceptance->checkoutable)
&& ($checkoutToType === 'user');
}
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
{
if (empty($acceptance->assigned_to_id)) {
return redirect()->route('account.accept');
}
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
return Helper::getRedirectOption($request, $itemId, $resourceType);
}
/**
* @return array{0: int, 1: string}
*/
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
{
$checkoutable = $acceptance->checkoutable;
if ($checkoutable instanceof Asset) {
return [(int) $checkoutable->id, 'Assets'];
}
if ($checkoutable instanceof Accessory) {
return [(int) $checkoutable->id, 'Accessories'];
}
if ($checkoutable instanceof Consumable) {
return [(int) $checkoutable->id, 'Consumables'];
}
if ($checkoutable instanceof LicenseSeat) {
return [(int) $checkoutable->license_id, 'Licenses'];
}
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
}
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
{
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
return $signatureBinary;
}
$source = @imagecreatefromstring($signatureBinary);
if ($source === false) {
return $signatureBinary;
}
$width = imagesx($source);
$height = imagesy($source);
$flattened = imagecreatetruecolor($width, $height);
if ($flattened === false) {
imagedestroy($source);
return $signatureBinary;
}
$white = imagecolorallocate($flattened, 255, 255, 255);
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
ob_start();
imagepng($flattened);
$output = ob_get_clean();
imagedestroy($source);
imagedestroy($flattened);
return is_string($output) ? $output : $signatureBinary;
}
}
@@ -15,6 +15,8 @@ class ActionlogController extends Controller
{
public function displaySig($filename): RedirectResponse|Response|bool
{
$filename = basename((string) $filename);
// PHP doesn't let you handle file not found errors well with
// file_get_contents, so we set the error reporting for just this class
error_reporting(0);
@@ -44,6 +46,7 @@ class ActionlogController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
@@ -10,6 +10,7 @@ use App\Http\Requests\AccessoryCheckoutRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAccessoryRequest;
use App\Http\Transformers\AccessoriesTransformer;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
@@ -69,20 +70,9 @@ class AccessoriesController extends Controller
->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser')
->withCount('checkouts as checkouts_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$accessories->ByFilter($filter);
} elseif ($request->filled('search')) {
$accessories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$accessories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('company_id')) {
@@ -411,4 +401,16 @@ class AccessoriesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($accessories);
}
public function history(Request $request, Accessory $accessory): JsonResponse|array
{
$this->authorize('history', $accessory);
$historyQuery = $accessory->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetModelRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetModelsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -92,21 +93,9 @@ class AssetModelsController extends Controller
->withCount('assignedAssets as assets_assigned_count')
->withCount('archivedAssets as assets_archived_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assetmodels->ByFilter($filter);
} elseif ($request->filled('search')) {
$assetmodels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assetmodels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('status') == 'deleted') {
@@ -350,4 +339,16 @@ class AssetModelsController extends Controller
return (new SelectlistTransformer)->transformSelectlist($assetmodels);
}
public function history(Request $request, AssetModel $model): JsonResponse|array
{
$this->authorize('history', $model);
$historyQuery = $model->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
+109 -55
View File
@@ -11,6 +11,7 @@ use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreAssetRequest;
use App\Http\Requests\UpdateAssetRequest;
use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\AccessoryCheckout;
@@ -19,6 +20,7 @@ use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\ComponentAssignment;
use App\Models\CustomField;
use App\Models\License;
use App\Models\LicenseSeat;
@@ -37,6 +39,7 @@ use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* This class controls all actions related to assets for
@@ -126,9 +129,9 @@ class AssetsController extends Controller
'location',
'rtd_location',
'category',
'status_label',
'manufacturer',
'supplier',
'status',
'jobtitle',
'assigned_to',
'created_by',
@@ -141,17 +144,6 @@ class AssetsController extends Controller
$allowed_columns[] = $field->db_column_name();
}
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
$assets = Asset::select('assets.*')
// ->addSelect([
// 'first_checkout_at' => Actionlog::query()
@@ -165,7 +157,7 @@ class AssetsController extends Controller
->with(
'model',
'location',
'assetstatus',
'status',
'company',
'defaultLoc',
'assignedTo',
@@ -183,21 +175,9 @@ class AssetsController extends Controller
$assets->InModelList($non_deprecable_models->toArray());
}
// These are used by the API to query against specific ID numbers.
// They are also used by the individual searches on detail pages like
// locations, etc.
// Search custom fields by column name
foreach ($all_custom_fields as $field) {
if ($request->filled($field->db_column_name()) && $field->db_column_name()) {
$assets->where('assets.'.$field->db_column_name(), '=', $request->input($field->db_column_name()));
}
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$assets->ByFilter($filter);
} elseif ($request->filled('search')) {
$assets->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/**
@@ -240,10 +220,18 @@ class AssetsController extends Controller
// This is used by the sidenav, mostly
// We switched from using query scopes here because of a Laravel bug
// related to fulltext searches on complex queries.
// I am sad. :(
switch ($request->input('status')) {
// This bit here accounts for folks actually using the formerly-known-as status like we previously used in the sidenav
// to return a list of all assets with the status *type* of Deployed, etc. The inuput field used to be "status" (which was consistent
// with the relation rename, but it broke the sidebar. This should handle both use cases in the event that someone didn't update
// their API integration code
$status_type_key = null;
if ($request->filled('status_type')) {
$status_type_key = $request->input('status_type');
} elseif ($request->filled('status')) {
$status_type_key = $request->input('status');
}
switch ($status_type_key) {
case 'Deleted':
$assets->onlyTrashed();
break;
@@ -415,7 +403,7 @@ class AssetsController extends Controller
case 'rtd_location':
$assets->OrderRtdLocation($order);
break;
case 'status_label':
case 'status':
$assets->OrderStatus($order);
break;
case 'supplier':
@@ -487,7 +475,7 @@ class AssetsController extends Controller
public function showByTag(Request $request, $tag): JsonResponse|array
{
$this->authorize('index', Asset::class);
$assets = Asset::where('asset_tag', $tag)->with('assetstatus')->with('assignedTo');
$assets = Asset::where('asset_tag', $tag)->with('status')->with('assignedTo');
// Check if they've passed ?deleted=true
if ($request->input('deleted', 'false') == 'true') {
@@ -527,7 +515,7 @@ class AssetsController extends Controller
{
$this->authorize('index', Asset::class);
$assets = Asset::where('serial', $serial)->with([
'assetstatus',
'status',
'assignedTo',
'company',
'defaultLoc',
@@ -571,7 +559,7 @@ class AssetsController extends Controller
*/
public function show(Request $request, $id): JsonResponse|array
{
if ($asset = Asset::with('assetstatus')
if ($asset = Asset::with('status')
->with('assignedTo')->withTrashed()
->withCount('checkins as checkins_count', 'checkouts as checkouts_count', 'userRequests as user_requests_count')->find($id)
) {
@@ -611,14 +599,14 @@ class AssetsController extends Controller
'assets.assigned_to',
'assets.assigned_type',
'assets.status_id',
])->with('model', 'assetstatus', 'assignedTo')
])->with('model', 'status', 'assignedTo')
->NotArchived();
if ((Setting::getSettings()->full_multiple_companies_support == '1') && ($request->filled('companyId'))) {
$assets->where('assets.company_id', $request->input('companyId'));
}
if ($request->filled('assetStatusType') && $request->input('assetStatusType') === 'RTD') {
if ($request->filled('statusType') && $request->input('statusType') === 'RTD') {
$assets = $assets->RTD();
}
@@ -639,8 +627,8 @@ class AssetsController extends Controller
$asset->use_text .= ' → '.$asset->assigned->display_name;
}
if ($asset->assetstatus->getStatuslabelType() == 'pending') {
$asset->use_text .= '('.$asset->assetstatus->getStatuslabelType().')';
if ($asset->status->getStatuslabelType() == 'pending') {
$asset->use_text .= '('.$asset->status->getStatuslabelType().')';
}
$asset->use_image = ($asset->getImageUrl()) ? $asset->getImageUrl() : null;
@@ -974,6 +962,11 @@ class AssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
// Preserve existing requestable state unless API caller explicitly includes the field.
if ($request->has('requestable')) {
$asset->requestable = $request->boolean('requestable');
}
if (! isset($target)) {
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.'));
}
@@ -1124,13 +1117,29 @@ class AssetsController extends Controller
$this->authorize('audit', Asset::class);
$settings = Setting::getSettings();
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
// Allow the asset tag to be passed in the payload (legacy method)
if ($request->filled('asset_tag')) {
$dt = null;
if (! is_null($settings->audit_interval)) {
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
}
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
$audit_key = $request->input('audit_key', null);
// If they have selected to scan by serial, use that
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
$asset = Asset::where('serial', '=', trim($audit_key))->first();
// If they have selected by asset tag, use that
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
// Allow the asset tag to be passed in the payload (legacy method)
} elseif ($request->filled('asset_tag')) {
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
}
// If none of the above were selected, fall back to the route-model-binding
if ($asset) {
$originalValues = $asset->getRawOriginal();
@@ -1152,10 +1161,12 @@ class AssetsController extends Controller
// Set up the payload for re-display in the API response
$payload = [
'id' => $asset->id,
'asset_tag' => $asset->asset_tag,
'note' => e($request->input('note')),
'status_label' => e($asset->assetstatus?->display_name),
'status_type' => $asset->assetstatus?->getStatuslabelType(),
'asset_tag' => e($asset->asset_tag),
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
'note' => $request->filled('note') ? e($request->input('note')) : null,
'status_label' => e($asset->status?->display_name),
'status_type' => $asset->status?->getStatuslabelType(),
'next_audit_date' => Helper::getFormattedDateObject($asset->next_audit_date),
];
@@ -1194,7 +1205,7 @@ class AssetsController extends Controller
// Validate the rest of the data before we turn off the event dispatcher
if ($asset->isInvalid()) {
return response()->json(Helper::formatStandardApiResponse('error', ['asset_tag' => $asset->asset_tag], $asset->getErrors()));
return response()->json(Helper::formatStandardApiResponse('error', $payload, $asset->getErrors()));
}
/**
@@ -1227,8 +1238,13 @@ class AssetsController extends Controller
}
$fail_payload = [
'audit_by_field' => e(Str::headline($audit_by_field)),
'audit_key' => e($audit_key),
];
// No matching asset for the asset tag that was passed.
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
return response()->json(Helper::formatStandardApiResponse('error', $fail_payload, trans('admin/hardware/message.does_not_exist')), 200);
}
@@ -1262,7 +1278,7 @@ class AssetsController extends Controller
$assets = Asset::select('assets.*')
->with(
'location',
'assetstatus',
'status',
'assetlog',
'company',
'assignedTo',
@@ -1337,7 +1353,6 @@ class AssetsController extends Controller
public function assignedAccessories(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', Asset::class);
$this->authorize('view', $asset);
$accessory_checkouts = AccessoryCheckout::AssetsAssigned()
->where('assigned_to', $asset->id)
@@ -1353,14 +1368,41 @@ class AssetsController extends Controller
return (new AssetsTransformer)->transformCheckedoutAccessories($accessory_checkouts, $total);
}
public function assignedComponents(Asset $asset): JsonResponse|array
public function assignedComponents(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('view', $asset);
$asset->loadCount('components');
$total = $asset->components_count;
$components = $asset->load(['components' => fn ($query) => $query->applyOffsetAndLimit($total)])->components;
return (new AssetsTransformer)->transformCheckedoutComponents($components, $total);
$allowed_columns = [
'created_at',
'assigned_qty',
'note',
];
$component_checkouts = ComponentAssignment::where('asset_id', $asset->id)->with('adminuser')->with('component');
$sort_override = $request->input('sort');
$column_sort = in_array($sort_override, $allowed_columns) ? $sort_override : 'created_at';
$order = $request->input('order') === 'asc' ? 'asc' : 'desc';
switch ($sort_override) {
case 'created_by':
$component_checkouts = $component_checkouts->OrderByCreatedByName($order);
break;
case 'name':
$component_checkouts = $component_checkouts->OrderByComponentName($order);
break;
default:
$component_checkouts = $component_checkouts->orderBy($column_sort, $order);
break;
}
$offset = ($request->input('offset') > $component_checkouts->count()) ? $component_checkouts->count() : app('api_offset_value');
$total = $component_checkouts->count();
$limit = app('api_limit_value');
$component_checkouts = $component_checkouts->skip($offset)->take($limit)->get();
return (new AssetsTransformer)->transformCheckedoutComponents($component_checkouts, $total);
}
/**
@@ -1447,4 +1489,16 @@ class AssetsController extends Controller
], $e->getMessage()), 500);
}
}
public function history(Request $request, Asset $asset): JsonResponse|array
{
$this->authorize('history', $asset);
$historyQuery = $asset->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -6,6 +6,7 @@ use App\Actions\Categories\DestroyCategoryAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CategoriesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -26,62 +27,50 @@ class CategoriesController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Category::class);
$allowed_columns = [
'id',
'name',
'category_type',
'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'checkin_email',
'assets_count',
'accessories_count',
'consumables_count',
'assets_count',
'category_type',
'checkin_email',
'components_count',
'licenses_count',
'consumables_count',
'created_at',
'updated_at',
'eula_text',
'id',
'image',
'tag_color',
'licenses_count',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
];
$categories = Category::select([
'id',
'created_by',
'created_at',
'updated_at',
'name', 'category_type',
'use_default_eula',
'eula_text',
'require_acceptance',
'category_type',
'checkin_email',
'created_at',
'created_by',
'eula_text',
'id',
'image',
'tag_color',
'name',
'notes',
'require_acceptance',
'tag_color',
'updated_at',
'use_default_eula',
])
->with('adminuser')
->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count');
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$categories->ByFilter($filter);
} elseif ($request->filled('search')) {
$categories->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$categories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
/*
@@ -140,6 +129,11 @@ class CategoriesController extends Controller
case 'created_by':
$categories = $categories->OrderByCreatedBy($order);
break;
// This is annoying, since it's not a real relationship, which is what we usually use these switches for, but
// we call the field has_eula, not eula_text, so there won't be a matching field
case 'has_eula':
$categories = $categories->orderBy('eula_text', $order);
break;
default:
$categories = $categories->orderBy($column_sort, $order);
break;
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\CompaniesTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -21,7 +22,7 @@ class CompaniesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Company::class);
@@ -49,8 +50,9 @@ class CompaniesController extends Controller
->with('adminuser')
->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
if ($request->filled('search')) {
$companies->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$companies->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -6,9 +6,11 @@ use App\Events\CheckoutableCheckedIn;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\ComponentsTransformer;
use App\Models\Asset;
use App\Models\Component;
use App\Models\ComponentAssignment;
use Carbon\Carbon;
use Illuminate\Database\Query\Builder;
use Illuminate\Http\JsonResponse;
@@ -72,10 +74,9 @@ class ComponentsController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$components->ByFilter($filter);
} elseif ($request->filled('search')) {
$components->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$components->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -247,17 +248,17 @@ class ComponentsController extends Controller
*
* @param int $id
*/
public function getAssets(Request $request, $id): array
public function getAssets(Component $component, Request $request): array
{
$this->authorize('view', Asset::class);
$component = Component::findOrFail($id);
$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->assets()
$assets = $component_checkouts->assets()
->where(function ($query) use ($request) {
$search_str = '%'.$request->input('search').'%';
$query->where('name', 'like', $search_str)
@@ -271,7 +272,6 @@ class ComponentsController extends Controller
$total = $assets->count();
} else {
$assets = $component->assets();
$total = $assets->count();
$assets = $assets->skip($offset)->take($limit)->get();
}
@@ -387,4 +387,16 @@ class ComponentsController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, 'No matching checkouts for that component join record'));
}
public function history(Request $request, Component $component): JsonResponse|array
{
$this->authorize('history', $component);
$historyQuery = $component->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreConsumableRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\ConsumablesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\Company;
@@ -24,7 +26,7 @@ class ConsumablesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('index', Consumable::class);
@@ -59,21 +61,9 @@ class ConsumablesController extends Controller
'manufacturer',
];
$filter = [];
if ($request->filled('filter')) {
$filter = json_decode($request->input('filter'), true);
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
return in_array($key, $allowed_columns);
}, ARRAY_FILTER_USE_KEY);
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$consumables->ByFilter($filter);
} elseif ($request->filled('search')) {
$consumables->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$consumables->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -367,4 +357,16 @@ class ConsumablesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($consumables);
}
public function history(Request $request, Consumable $consumable): JsonResponse|array
{
$this->authorize('history', $consumable);
$historyQuery = $consumable->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\StoreDepartmentRequest;
use App\Http\Transformers\DepartmentsTransformer;
@@ -22,7 +23,7 @@ class DepartmentsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Department::class);
$allowed_columns = ['id', 'name', 'image', 'users_count', 'notes', 'tag_color'];
@@ -43,8 +44,9 @@ class DepartmentsController extends Controller
'departments.notes',
])->with('location')->with('manager')->with('company')->withCount('users as users_count');
if ($request->filled('search')) {
$departments = $departments->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$departments->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\DepreciationsTransformer;
use App\Models\Depreciation;
use Illuminate\Http\JsonResponse;
@@ -18,7 +19,7 @@ class DepreciationsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Depreciation::class);
$allowed_columns = [
@@ -33,14 +34,15 @@ class DepreciationsController extends Controller
'licenses_count',
];
$depreciations = Depreciation::select('id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by')
$depreciations = Depreciation::select(['id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by'])
->with('adminuser')
->withCount('assets as assets_count')
->withCount('models as models_count')
->withCount('licenses as licenses_count');
if ($request->filled('search')) {
$depreciations = $depreciations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$depreciations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
// Make sure the offset and limit are actually integers and do not exceed system limits
+27 -12
View File
@@ -2,8 +2,10 @@
namespace App\Http\Controllers\Api;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\GroupsTransformer;
use App\Models\Group;
use Illuminate\Http\JsonResponse;
@@ -18,7 +20,7 @@ class GroupsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('superadmin');
@@ -26,8 +28,9 @@ class GroupsController extends Controller
$groups = Group::select(['id', 'name', 'permissions', 'notes', 'created_at', 'updated_at', 'created_by'])->with('adminuser')->withCount('users as users_count');
if ($request->filled('search')) {
$groups = $groups->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$groups->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -75,14 +78,17 @@ class GroupsController extends Controller
{
$this->authorize('superadmin');
$group = new Group;
// Get all the available permissions
$permissions = json_encode(config('permissions'));
$groupPermissions = Helper::selectedPermissionsArray($permissions, $permissions);
$defaultPermissions = Helper::selectedPermissionsArray(config('permissions'), config('permissions'));
$group->name = $request->input('name');
$requestedPermissions = $request->has('permissions')
? NormalizePermissionsPayloadAction::run($request->input('permissions'))
: $defaultPermissions;
$group->fill($request->only(['name', 'notes']));
$group->created_by = auth()->id();
$group->notes = $request->input('notes');
$group->permissions = json_encode($request->input('permissions', $groupPermissions));
$group->permissions = json_encode(
Helper::selectedPermissionsArray(config('permissions'), $requestedPermissions)
);
if ($group->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.create')));
@@ -122,9 +128,18 @@ class GroupsController extends Controller
$this->authorize('superadmin');
$group = Group::findOrFail($id);
$group->name = $request->input('name');
$group->notes = $request->input('notes');
$group->permissions = $request->input('permissions'); // Todo - some JSON validation stuff here
// Fill only the keys present in the request, so PATCH skips absent fields naturally.
$group->fill($request->only(['name', 'notes']));
// Preserve existing permissions when omitted from PATCH/PUT payload.
if ($request->has('permissions')) {
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permissions'))
)
);
}
if ($group->save()) {
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.update')));
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\LicensesTransformer;
use App\Http\Transformers\SelectlistTransformer;
use App\Models\License;
@@ -21,7 +23,7 @@ class LicensesController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', License::class);
@@ -96,8 +98,9 @@ class LicensesController extends Controller
$licenses->whereNull('expiration_date');
}
if ($request->filled('search')) {
$licenses = $licenses->TextSearch($request->input('search'));
// This invokes the Searchable model trait and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$licenses->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->input('deleted') == 'true') {
@@ -275,4 +278,16 @@ class LicensesController extends Controller
return (new SelectlistTransformer)->transformSelectlist($licenses);
}
public function history(Request $request, License $license): JsonResponse|array
{
$this->authorize('history', $license);
$historyQuery = $license->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\LocationsTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -31,7 +33,7 @@ class LocationsController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Location::class);
$allowed_columns = [
@@ -106,8 +108,9 @@ class LocationsController extends Controller
$locations = Company::scopeCompanyables($locations);
}
if ($request->filled('search')) {
$locations = $locations->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$locations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -308,7 +311,7 @@ class LocationsController extends Controller
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
@@ -318,7 +321,7 @@ class LocationsController extends Controller
{
$this->authorize('view', Asset::class);
$this->authorize('view', $location);
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
$assets = $assets->get();
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
@@ -455,4 +458,16 @@ class LocationsController extends Controller
return (new SelectlistTransformer)->transformSelectlist($paginated_results);
}
public function history(Request $request, Location $location): JsonResponse|array
{
$this->authorize('history', $location);
$historyQuery = $location->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Http\Transformers\MaintenancesTransformer;
use App\Models\Asset;
use App\Models\Company;
@@ -31,15 +33,16 @@ class MaintenancesController extends Controller
*
* @since [v1.8]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Asset::class);
$maintenances = Maintenance::select('maintenances.*')
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'adminuser', 'asset.assignedTo',);
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
if ($request->filled('search')) {
$maintenances = $maintenances->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$maintenances->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('asset_id')) {
@@ -129,7 +132,7 @@ class MaintenancesController extends Controller
if (request()->input('format') == 'flat') {
return (new MaintenancesTransformer)->transformMaintenancesFlat($maintenances, $total);
}
return (new MaintenancesTransformer)->transformMaintenances($maintenances, $total);
}
@@ -251,4 +254,18 @@ class MaintenancesController extends Controller
return (new MaintenancesTransformer)->transformMaintenance($maintenance);
}
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
{
$this->authorize('view', Asset::class);
$asset = $maintenance->asset;
$this->authorize('history', $asset);
$historyQuery = $maintenance->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -6,6 +6,7 @@ use App\Actions\Manufacturers\DeleteManufacturerAction;
use App\Exceptions\ItemStillHasChildren;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\ManufacturersTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -28,7 +29,7 @@ class ManufacturersController extends Controller
*
* @return Response
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('view', Manufacturer::class);
$allowed_columns = [
@@ -81,8 +82,9 @@ class ManufacturersController extends Controller
$manufacturers->onlyTrashed();
}
if ($request->filled('search')) {
$manufacturers = $manufacturers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$manufacturers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
+35 -10
View File
@@ -2,11 +2,13 @@
namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\ActionlogsTransformer;
use App\Models\Actionlog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
class ReportsController extends Controller
{
@@ -17,32 +19,54 @@ class ReportsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): JsonResponse|array
public function index(FilterRequest $request): JsonResponse|array
{
$this->authorize('activity.view');
// If the user doesn't have permission to view the item or the target,
// then they shouldn't be able to see the activity log for that item or target,
// but if they have the general activity view permission,
// then they can see all activity logs regardless of the item or target.
if ((! Gate::allows('activity.view')) && (($request->filled('target_type')) && ($request->filled('target_id'))) || (($request->filled('item_type')) && ($request->filled('item_id')))) {
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$target = Helper::normalizeFullModelName(request()->input('target_type'));
$target::find(request()->input('target_id'))?->withTrashed();
$this->authorize('view', $target);
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$item = Helper::normalizeFullModelName(request()->input('item_type'));
$item::find(request()->input('item_id'))?->withTrashed();
$this->authorize('view', $item);
}
} else {
$this->authorize('activity.view');
}
$actionlogs = Actionlog::with('item', 'user', 'adminuser', 'target', 'location');
if ($request->filled('search')) {
$actionlogs = $actionlogs->TextSearch(e($request->input('search')));
}
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
$actionlogs = $actionlogs->where('target_id', '=', $request->input('target_id'))
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('target_type')));
->where('target_type', '=', Helper::normalizeFullModelName($request->input('target_type')));
}
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
$actionlogs = $actionlogs->where(function ($query) use ($request) {
$query->where('item_id', '=', $request->input('item_id'))
->where('item_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')))
->where('item_type', '=', Helper::normalizeFullModelName($request->input('item_type')))
->orWhere(function ($query) use ($request) {
$query->where('target_id', '=', $request->input('item_id'))
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')));
->where('target_type', '=', Helper::normalizeFullModelName($request->input('item_type')));
});
});
}
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$actionlogs->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('action_type')) {
$actionlogs = $actionlogs->where('action_type', '=', $request->input('action_type'));
}
@@ -99,5 +123,6 @@ class ReportsController extends Controller
$actionlogs = $actionlogs->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -162,6 +162,13 @@ class SettingsController extends Controller
public function ajaxTestEmail(): JsonResponse
{
if (! config('app.lock_passwords')) {
if (config('mail.reply_to.address') == '') {
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
}
try {
Notification::send(Setting::first(), new MailTest);
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
@@ -286,6 +293,11 @@ class SettingsController extends Controller
*/
public function downloadBackup($file): JsonResponse|BinaryFileResponse
{
$file = $this->sanitizeBackupFilename($file);
if ($file === null) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
}
$path = storage_path('app/backups');
@@ -329,4 +341,21 @@ class SettingsController extends Controller
}
}
private function sanitizeBackupFilename(mixed $filename): ?string
{
$filename = trim((string) $filename);
if ($filename === '' || str_contains($filename, "\0")) {
return null;
}
$sanitized = basename($filename);
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
return null;
}
return ($sanitized === $filename) ? $sanitized : null;
}
}
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Transformers\AssetsTransformer;
use App\Http\Transformers\PieChartTransformer;
use App\Http\Transformers\SelectlistTransformer;
@@ -23,7 +24,7 @@ class StatuslabelsController extends Controller
*
* @since [v4.0]
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Statuslabel::class);
$allowed_columns = [
@@ -38,8 +39,9 @@ class StatuslabelsController extends Controller
$statuslabels = Statuslabel::with('adminuser')->withCount('assets as assets_count');
if ($request->filled('search')) {
$statuslabels = $statuslabels->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$statuslabels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -11,6 +11,7 @@ use App\Exceptions\ItemStillHasLicenses;
use App\Exceptions\ItemStillHasMaintenances;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\FilterRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Transformers\SelectlistTransformer;
use App\Http\Transformers\SuppliersTransformer;
@@ -31,7 +32,7 @@ class SuppliersController extends Controller
*
* @return Response
*/
public function index(Request $request): array
public function index(FilterRequest $request): array
{
$this->authorize('view', Supplier::class);
$allowed_columns = [
@@ -67,8 +68,9 @@ class SuppliersController extends Controller
->withCount('consumables as consumables_count')
->with('adminuser');
if ($request->filled('search')) {
$suppliers->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$suppliers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('name')) {
@@ -32,7 +32,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -91,7 +91,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -141,7 +141,7 @@ class UploadedFilesController extends Controller
{
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -153,7 +153,7 @@ class UploadedFilesController extends Controller
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_id')), 200);
}
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.file_not_found'), 200));
}
@@ -162,10 +162,10 @@ class UploadedFilesController extends Controller
'Content-Disposition' => 'inline',
];
return Storage::download(self::$map_storage_path[$object_type].'/'.$log->filename, $log->filename, $headers);
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
}
return StorageHelper::downloader(self::$map_storage_path[$object_type].'/'.$log->filename);
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
}
@@ -186,7 +186,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
@@ -202,8 +202,8 @@ class UploadedFilesController extends Controller
if ($log) {
// Check the file actually exists, and delete it
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
}
// Delete the record of the file
if ($log->logUploadDelete($object, $log->filename)) {
+49 -63
View File
@@ -2,6 +2,8 @@
namespace App\Http\Controllers\Api;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Actions\Permissions\PreserveUnauthorizedPrivilegedPermissionsAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
@@ -171,10 +173,9 @@ class UsersController extends Controller
}
if ((! is_null($filter)) && (count($filter)) > 0) {
$users->ByFilter($filter);
} elseif ($request->filled('search')) {
$users->TextSearch($request->input('search'));
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
if ($request->filled('filter') || $request->filled('search')) {
$users->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
}
if ($request->filled('activated')) {
@@ -437,27 +438,17 @@ class UsersController extends Controller
{
$this->authorize('create', User::class);
$authenticatedUser = auth()->user();
$user = new User;
$user->fill($request->all());
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
$user->created_by = auth()->id();
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
if (! auth()->user()->isSuperUser()) {
if ((is_array($permissions_array)) && (array_key_exists('superuser', $permissions_array))) {
unset($permissions_array['superuser']);
}
}
if (! auth()->user()->isAdmin()) {
if ((is_array($permissions_array)) && (array_key_exists('admin', $permissions_array))) {
unset($permissions_array['admin']);
}
}
$user->permissions = $permissions_array;
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
));
}
//
@@ -536,6 +527,8 @@ class UsersController extends Controller
{
$this->authorize('update', $user);
$authenticatedUser = auth()->user();
/**
* This is a janky hack to prevent people from changing admin demo user data on the public demo.
* The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
@@ -571,41 +564,16 @@ class UsersController extends Controller
}
if ($request->has('permissions')) {
$permissions_array = $request->input('permissions');
$orig_permissions_array = $user->decodePermissions();
// Strip out the individual superuser permission if the API user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
if (is_array($orig_permissions_array)) {
if (array_key_exists('superuser', $orig_permissions_array)) {
$permissions_array['superuser'] = $orig_permissions_array['superuser'];
}
}
}
// Strip out the individual admin permission if the API user isn't an admin
if ((! auth()->user()->isAdmin()) && (! auth()->user()->isSuperUser())) {
if (is_array($orig_permissions_array)) {
if (array_key_exists('admin', $orig_permissions_array)) {
$permissions_array['admin'] = $orig_permissions_array['admin'];
}
}
}
// This is going to update the whole thing, not just what was passed
$user->permissions = $permissions_array;
// This is going to update the whole thing, not just what was passed.
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
authenticatedUser: $authenticatedUser,
originalPermissions: NormalizePermissionsPayloadAction::run($user->decodePermissions()),
));
}
}
if ($request->filled('display_name')) {
$user->display_name = $request->input('display_name');
}
if ($request->filled('company_id')) {
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
}
@@ -840,21 +808,27 @@ class UsersController extends Controller
try {
$user = User::find($request->input('id'));
$this->authorize('update', $user);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
$user->two_factor_secret = null;
$user->two_factor_enrolled = 0;
$user->saveQuietly();
// Log the reset
$logaction = new Actionlog;
$logaction->target_type = User::class;
$logaction->target_id = $user->id;
$logaction->item_type = User::class;
$logaction->item_id = $user->id;
$logaction->created_at = date('Y-m-d H:i:s');
$logaction->created_by = auth()->id();
$logaction->logaction('2FA reset');
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
}
return response()->json(['message' => trans('general.unauthorized')], 500);
} catch (\Exception $e) {
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_error')], 500);
}
@@ -967,4 +941,16 @@ class UsersController extends Controller
return response()->json(Helper::formatStandardApiResponse('success', null, $ldap_results['summary']), 200);
}
public function history(Request $request, User $user): JsonResponse|array
{
$this->authorize('history', $user);
$historyQuery = $user->getHistory($request);
$total = (clone $historyQuery)->count();
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
$limit = app('api_limit_value');
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
}
}
@@ -9,7 +9,6 @@ use App\Models\Actionlog;
use App\Models\AssetModel;
use App\Models\CustomField;
use App\Models\SnipeModel;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
@@ -10,6 +10,7 @@ use App\Http\Traits\MigratesLegacyAssetLocations;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\LicenseSeat;
use App\Models\Statuslabel;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\RedirectResponse;
@@ -56,9 +57,16 @@ class AssetCheckinController extends Controller
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
};
$deployableStatusIds = array_map('intval', array_keys(Helper::deployableStatusLabelList()));
$selectedStatusId = old('status_id');
$showRequestableToggle = is_numeric($selectedStatusId)
&& in_array((int) $selectedStatusId, $deployableStatusIds, true);
return view('hardware/checkin', compact('asset', 'target_option'))
->with('item', $asset)
->with('statusLabel_list', Helper::statusLabelList())
->with('deployable_status_ids', $deployableStatusIds)
->with('show_requestable_toggle', $showRequestableToggle)
->with('backto', $backto)
->with('table_name', 'Assets');
}
@@ -107,6 +115,19 @@ class AssetCheckinController extends Controller
$asset->status_id = e($request->input('status_id'));
}
$selectedStatusId = $request->filled('status_id')
? (int) $request->input('status_id')
: (int) $asset->status_id;
$isDeployableStatus = Statuslabel::query()
->whereKey($selectedStatusId)
->where('deployable', 1)
->exists();
if ($request->boolean('set_requestable') && $isDeployableStatus) {
$asset->requestable = true;
}
// Add any custom fields that should be included in the checkout
$asset->customFieldsForCheckinCheckout('display_checkin');
@@ -164,4 +185,34 @@ class AssetCheckinController extends Controller
// Redirect to the asset management page with error
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.checkin.error').$asset->getErrors());
}
/**
* This would only be used if the target is actually hard-deleted
* and literally does not exist in the database anymore. This will null out the assigned_to
* and assigned_type fields, but will not trigger any events or do any of the other things that a
* normal checkin would do, since the target itself is now invalid.
*/
public function forceCheckin(Asset $asset)
{
$this->authorize('checkin', $asset);
if (! $asset->hasOrphanedAssignment()) {
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_not_orphaned'));
}
$asset->assigned_to = null;
$asset->assigned_type = null;
if ($asset->save()) {
$asset->logForceCheckin();
return redirect()->route('hardware.show', $asset->id)
->with('success', trans('admin/hardware/message.checkin.force_checkin_orphaned_success'));
}
return redirect()->route('hardware.show', $asset->id)
->with('error', trans('admin/hardware/message.checkin.force_checkin_error'));
}
}
@@ -8,7 +8,9 @@ use App\Http\Controllers\CheckInOutRequest;
use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Setting;
use App\Models\User;
use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\RedirectResponse;
@@ -101,6 +103,10 @@ class AssetCheckoutController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
if (! empty($asset->licenseseats->all())) {
if (request('checkout_to_type') == 'user') {
foreach ($asset->licenseseats as $seat) {
@@ -122,9 +128,43 @@ class AssetCheckoutController extends Controller
}
}
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person. The signature is
// attributed to the target user, not the admin.
if ($request->boolean('sign_in_place') && $target instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
->where('checkoutable_id', $asset->id)
->where('assigned_to_id', $target->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($asset);
$acceptance->assignedTo()->associate($target);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $asset->id,
'sign_in_place_resource_type' => 'Assets',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/hardware/message.checkout.success'));
}
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
return Helper::getRedirectOption($request, $asset->id, 'Assets')
->with('success', trans('admin/hardware/message.checkout.success'));
}
@@ -360,8 +360,23 @@ class AssetsController extends Controller
'url' => route('qr_code/hardware', $asset),
];
$total_maintenance_cost = $asset->maintenances?->sum('cost');
$total_asset_cost = ($asset->assignedAssets()?->AssetsForShow()) ? $asset->assignedAssets()?->AssetsForShow()?->sum('purchase_cost') : 0;
$total_license_cost = ($asset->licenses) ? $asset->licenses->sum('purchase_cost') : 0;
$total_accessory_cost = ($asset->accessories) ? $asset->accessories()->sum('purchase_cost') : 0;
$total_component_cost = ($asset->components) ? $asset->components->sum('calculated_purchase_cost') : 0;
$total_cost_for_asset = $asset->purchase_cost + $total_maintenance_cost + $total_asset_cost + $total_license_cost + $total_accessory_cost + $total_component_cost;
return view('hardware/view', compact('asset', 'qr_code', 'settings'))
->with('use_currency', $use_currency)->with('audit_log', $audit_log);
->with('total_maintenance_cost', $total_maintenance_cost)
->with('total_asset_cost', $total_asset_cost)
->with('total_license_cost', $total_license_cost)
->with('total_accessory_cost', $total_accessory_cost)
->with('total_component_cost', $total_component_cost)
->with('total_cost_for_asset', $total_cost_for_asset)
->with('use_currency', $use_currency)
->with('audit_log', $audit_log);
}
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
use App\Http\Requests\AssetCheckoutRequest;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\CustomField;
use App\Models\Setting;
use App\Models\Statuslabel;
@@ -371,7 +372,7 @@ class BulkAssetsController extends Controller
}
if ($request->filled('company_id')) {
$this->update_array['company_id'] = $request->input('company_id');
$this->update_array['company_id'] = Company::getIdForCurrentUser($request->input('company_id'));
if ($request->input('company_id') == 'clear') {
$this->update_array['company_id'] = null;
}
@@ -406,7 +407,7 @@ class BulkAssetsController extends Controller
// Otherwise we need to make sure the status type is still a deployable one.
$unassigned = $asset->assigned_to == '';
$deployable = $updated_status->deployable == '1' && $asset->assetstatus?->deployable == '1';
$deployable = $updated_status->deployable == '1' && $asset->status?->deployable == '1';
$pending = $updated_status->pending === 1;
if ($unassigned || $deployable || $pending) {
@@ -715,6 +716,10 @@ class BulkAssetsController extends Controller
$asset->status_id = $request->input('status_id');
}
if ($request->boolean('set_not_requestable')) {
$asset->requestable = false;
}
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
// TODO - I think this logic is duplicated in the checkOut method?
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
use App\Events\CheckoutableCheckedOut;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Models\CheckoutAcceptance;
use App\Models\Consumable;
use App\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
@@ -24,33 +25,27 @@ class ConsumableCheckoutController extends Controller
*
* @param int $id
*/
public function create($id): View|RedirectResponse
public function create(Consumable $consumable): View|RedirectResponse
{
if ($consumable = Consumable::find($id)) {
$this->authorize('checkout', $consumable);
$this->authorize('checkout', $consumable);
// Make sure the category is valid
if ($consumable->category) {
// Make sure the category is valid
if ($consumable->category) {
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
// Make sure there is at least one available to checkout
if ($consumable->numRemaining() <= 0) {
return redirect()->route('consumables.index')
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
}
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
// Return the checkout view
return view('consumables/checkout', compact('consumable'));
}
// Not found
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.does_not_exist'));
// Invalid category
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
}
@@ -67,12 +62,8 @@ class ConsumableCheckoutController extends Controller
*
* @throws AuthorizationException
*/
public function store(Request $request, $consumableId)
public function store(Request $request, Consumable $consumable)
{
if (is_null($consumable = Consumable::with('users')->find($consumableId))) {
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.not_found'));
}
$this->authorize('checkout', $consumable);
// If the quantity is not present in the request or is not a positive integer, set it to 1
@@ -98,14 +89,14 @@ class ConsumableCheckoutController extends Controller
// Update the consumable data
$consumable->assigned_to = e($request->input('assigned_to'));
for ($i = 0; $i < $quantity; $i++) {
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => e($request->input('assigned_to')),
'note' => $request->input('note'),
]);
}
// Attach the consumable to the user ONCE with the correct qty and note
$consumable->users()->attach($consumable->id, [
'consumable_id' => $consumable->id,
'created_by' => $admin_user->id,
'assigned_to' => $assigned_to,
'note' => $request->input('note'),
'qty' => $quantity,
]);
$consumable->checkout_qty = $quantity;
@@ -116,12 +107,46 @@ class ConsumableCheckoutController extends Controller
$request->input('note'),
[],
$consumable->checkout_qty,
$request->boolean('sign_in_place'),
));
$request->request->add(['checkout_to_type' => 'user']);
$request->request->add(['assigned_user' => $user->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => $request->input('checkout_to_type'),
'sign_in_place' => $request->boolean('sign_in_place'),
]);
// When sign_in_place is requested, redirect to the acceptance/signature page
// so the user can sign in person. The signature is attributed to the target user.
if ($request->boolean('sign_in_place')) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
->where('checkoutable_id', $consumable->id)
->where('assigned_to_id', $user->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($consumable);
$acceptance->assignedTo()->associate($user);
$acceptance->qty = $quantity;
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $consumable->id,
'sign_in_place_resource_type' => 'Consumables',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/consumables/message.checkout.success'));
}
// Redirect to the new consumable page
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
+8
View File
@@ -26,8 +26,10 @@ namespace App\Http\Controllers;
use App\Models\Accessory;
use App\Models\Asset;
use App\Models\AssetModel;
use App\Models\Company;
use App\Models\Component;
use App\Models\Consumable;
use App\Models\Department;
use App\Models\License;
use App\Models\Location;
use App\Models\Maintenance;
@@ -46,6 +48,8 @@ abstract class Controller extends BaseController
public static $map_object_type = [
'accessories' => Accessory::class,
'companies' => Company::class,
'departments' => Department::class,
'maintenances' => Maintenance::class,
'assets' => Asset::class,
'audits' => Asset::class,
@@ -64,6 +68,8 @@ abstract class Controller extends BaseController
'maintenances' => 'private_uploads/maintenances/',
'assets' => 'private_uploads/assets/',
'audits' => 'private_uploads/audits/',
'departments' => 'private_uploads/departments/',
'companies' => 'private_uploads/companies/',
'components' => 'private_uploads/components/',
'consumables' => 'private_uploads/consumables/',
'hardware' => 'private_uploads/assets/',
@@ -79,6 +85,8 @@ abstract class Controller extends BaseController
'maintenances' => 'maintenance',
'assets' => 'asset',
'audits' => 'audits',
'companies' => 'company',
'departments' => 'department',
'components' => 'component',
'consumables' => 'consumable',
'hardware' => 'asset',
@@ -54,7 +54,7 @@ class DepartmentsController extends Controller
$department->created_by = auth()->id();
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
$department->tag_color = $request->input('tag_color');
$department->notes = $request->input('notes');
$department = $request->handleImages($department);
@@ -107,12 +107,8 @@ class DepartmentsController extends Controller
*
* @since [v4.0]
*/
public function destroy($id): RedirectResponse
public function destroy(Department $department): RedirectResponse
{
if (is_null($department = Department::find($id))) {
return redirect()->to(route('departments.index'))->with('error', trans('admin/departments/message.not_found'));
}
$this->authorize('delete', $department);
if ($department->users->count() > 0) {
@@ -168,7 +164,7 @@ class DepartmentsController extends Controller
$department->fill($request->all());
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
$department->phone = $request->input('phone');
$department->fax = $request->input('fax');
$department->tag_color = $request->input('tag_color');
+16 -15
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Helpers\Helper;
use App\Models\Group;
use App\Models\User;
@@ -79,14 +80,12 @@ class GroupsController extends Controller
// create a new group instance
$group = new Group;
$group->name = $request->input('name');
if ($request->filled('permission')) {
$group->permissions = json_encode($request->array('permission'));
} else {
$group->permissions = null;
}
$group->permissions = json_encode($request->input('permission'));
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permission'))
)
);
$group->created_by = auth()->id();
$group->notes = $request->input('notes');
@@ -167,15 +166,17 @@ class GroupsController extends Controller
public function update(Request $request, Group $group): RedirectResponse
{
$group->name = $request->input('name');
if ($request->filled('permission')) {
$group->permissions = json_encode($request->array('permission'));
} else {
$group->permissions = null;
}
$group->notes = $request->input('notes');
if ($request->has('permission')) {
$group->permissions = json_encode(
Helper::selectedPermissionsArray(
config('permissions'),
NormalizePermissionsPayloadAction::run($request->input('permission'))
)
);
}
if (! config('app.lock_passwords')) {
if ($group->save()) {
@@ -43,7 +43,9 @@ class LabelsController extends Controller
'name' => trans('admin/labels/table.example_company'),
'phone' => '1-555-555-5555',
'email' => 'company@example.com',
'logo' => 'label-preview-logo.png',
]);
$exampleAsset->is_label_preview = true;
$exampleAsset->setRelation('assignedTo', new User(['first_name' => 'Luke', 'last_name' => 'Skywalker']));
$exampleAsset->defaultLoc = new Location(['name' => trans('admin/labels/table.example_defaultloc'), 'phone' => '1-555-555-5555']);
@@ -7,6 +7,7 @@ use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\LicenseCheckoutRequest;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\User;
@@ -101,17 +102,53 @@ class LicenseCheckoutController extends Controller
session()->put(['checkout_to_type' => 'asset']);
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'asset']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'asset',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
} elseif ($request->filled('assigned_to')) {
session()->put(['checkout_to_type' => 'user']);
$checkoutTarget = $this->checkoutToUser($licenseSeat);
$request->request->add(['assigned_user' => $checkoutTarget->id]);
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'user']);
session()->put([
'redirect_option' => $request->input('redirect_option'),
'checkout_to_type' => 'user',
'sign_in_place' => $request->boolean('sign_in_place'),
]);
}
if ($checkoutTarget) {
// When sign_in_place is requested and the target is a user, redirect to the
// acceptance/signature page so the user can sign in person.
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
->where('checkoutable_id', $licenseSeat->id)
->where('assigned_to_id', $checkoutTarget->id)
->pending()
->latest()
->first();
// If requireAcceptance() is false the listener won't have created one; create it now.
if (! $acceptance) {
$acceptance = new CheckoutAcceptance;
$acceptance->checkoutable()->associate($licenseSeat);
$acceptance->assignedTo()->associate($checkoutTarget);
$acceptance->save();
}
session([
'sign_in_place_acceptance_id' => $acceptance->id,
'sign_in_place_item_id' => $license->id,
'sign_in_place_resource_type' => 'Licenses',
]);
return redirect()->route('account.accept.item', $acceptance->id)
->with('success', trans('admin/licenses/message.checkout.success'));
}
return Helper::getRedirectOption($request, $license->id, 'Licenses')
->with('success', trans('admin/licenses/message.checkout.success'));
}
@@ -150,7 +187,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = $target->assigned_to;
}
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -167,7 +204,7 @@ class LicenseCheckoutController extends Controller
$licenseSeat->assigned_to = request('assigned_to');
if ($licenseSeat->save()) {
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
return $target;
}
@@ -262,7 +262,7 @@ class LicensesController extends Controller
*/
public function show(License $license)
{
$license = License::with('assignedusers')->find($license->id);
$license = License::with('assignedusers')->withCount('freeSeats as free_seats_count')->find($license->id);
$users_count = User::where('autoassign_licenses', '1')->count();
@@ -251,6 +251,7 @@ class ProfileController extends Controller
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
{
$filename = basename((string) $filename);
$logentry = Actionlog::where('filename', $filename)->first();
+29 -14
View File
@@ -32,6 +32,7 @@ use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use League\Csv\EscapeFormula;
@@ -141,7 +142,7 @@ class ReportsController extends Controller
{
$this->authorize('reports.view');
// Grab all the assets
$assets = Asset::with('model', 'assignedTo', 'assetstatus', 'defaultLoc', 'assetlog')
$assets = Asset::with('model', 'assignedTo', 'status', 'defaultLoc', 'assetlog')
->orderBy('created_at', 'DESC')->get();
$csv = Writer::createFromFileObject(new \SplTempFileObject);
@@ -675,7 +676,7 @@ class ReportsController extends Controller
}
$assets = Asset::select('assets.*')->with(
'location', 'assetstatus', 'company', 'defaultLoc', 'assignedTo',
'location', 'status', 'company', 'defaultLoc', 'assignedTo',
'model.category', 'model.manufacturer', 'supplier');
if ($request->filled('by_location_id')) {
@@ -917,7 +918,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
}
@@ -1022,7 +1023,7 @@ class ReportsController extends Controller
}
if ($request->filled('status')) {
$row[] = ($asset->assetstatus) ? $asset->assetstatus->name.' ('.$asset->present()->statusMeta.')' : '';
$row[] = ($asset->status) ? $asset->status->name.' ('.$asset->present()->statusMeta.')' : '';
}
if ($request->filled('checkout_date')) {
@@ -1070,7 +1071,13 @@ class ReportsController extends Controller
foreach ($customfields as $customfield) {
$column_name = $customfield->db_column_name();
if ($request->filled($customfield->db_column_name())) {
$row[] = $asset->$column_name;
$value = $asset->$column_name;
if (($customfield->field_encrypted == '1') && Gate::allows('assets.view.encrypted_custom_fields')) {
$value = Helper::gracefulDecrypt($customfield, $value);
}
$row[] = $value;
}
}
@@ -1211,7 +1218,7 @@ class ReportsController extends Controller
->filter(fn ($unaccepted) => $unaccepted->checkoutable)
->map(fn ($unaccepted) => Checkoutable::fromAcceptance($unaccepted));
return view('reports/unaccepted_assets', compact('itemsForReport', 'showDeleted'));
return view('reports/unaccepted_items', compact('itemsForReport', 'showDeleted'));
}
/**
@@ -1223,6 +1230,10 @@ class ReportsController extends Controller
*/
public function sentAssetAcceptanceReminder(Request $request): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
$id = $request->input('acceptance_id');
$query = CheckoutAcceptance::query()
@@ -1244,7 +1255,7 @@ class ReportsController extends Controller
Log::debug('No pending acceptances');
// Redirect to the unaccepted items report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$item = $acceptance->checkoutable;
$assignee = $acceptance->assignedTo ?? $item->assignedTo ?? null;
@@ -1256,7 +1267,7 @@ class ReportsController extends Controller
if (is_null($acceptance->created_at)) {
Log::debug('No acceptance created_at');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
} else {
if ($item instanceof LicenseSeat) {
$logItem_res = $item->license->checkouts()->with('adminuser')->where('created_at', '=', $acceptance->created_at)->get();
@@ -1266,18 +1277,18 @@ class ReportsController extends Controller
if ($logItem_res->isEmpty()) {
Log::debug('Acceptance date mismatch');
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
$logItem = $logItem_res[0];
}
if (is_null($email) || $email === '') {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.no_email'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.no_email'));
}
$mailable = $this->getCheckoutMailType($acceptance, $logItem);
Mail::to($email)->send($mailable->locale($locale));
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.reminder_sent'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.reminder_sent'));
}
private function getCheckoutMailType(CheckoutAcceptance $acceptance, $logItem): Mailable
@@ -1310,17 +1321,21 @@ class ReportsController extends Controller
*/
public function deleteAssetAcceptance($acceptanceId = null): RedirectResponse
{
$user = auth()->user();
if (! ($user?->isAdmin() || $user?->isSuperUser())) {
abort(403);
}
$this->authorize('reports.view');
if (! $acceptance = CheckoutAcceptance::pending()->find($acceptanceId)) {
// Redirect to the unaccepted assets report page with error
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.bad_data'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.bad_data_or_already_accepted'));
}
if ($acceptance->delete()) {
return redirect()->route('reports/unaccepted_assets')->with('success', trans('admin/reports/general.acceptance_deleted'));
return redirect()->route('reports/unaccepted_items')->with('success', trans('admin/reports/general.acceptance_deleted'));
} else {
return redirect()->route('reports/unaccepted_assets')->with('error', trans('general.deletion_failed'));
return redirect()->route('reports/unaccepted_items')->with('error', trans('general.deletion_failed'));
}
}
+156 -1
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Enums\ActionType;
use App\Helpers\Helper;
use App\Helpers\StorageHelper;
use App\Http\Requests\ImageUploadRequest;
@@ -11,6 +12,7 @@ use App\Http\Requests\StoreLdapSettings;
use App\Http\Requests\StoreLocalizationSettings;
use App\Http\Requests\StoreNotificationSettings;
use App\Http\Requests\StoreSecuritySettings;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CustomField;
use App\Models\Group;
@@ -870,6 +872,11 @@ class SettingsController extends Controller
public function downloadFile($filename = null): RedirectResponse|BinaryFileResponse
{
$path = 'app/backups';
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
if (Storage::exists($path.'/'.$filename)) {
@@ -895,6 +902,12 @@ class SettingsController extends Controller
*/
public function deleteFile($filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (config('app.allow_backup_delete') == 'true') {
if (! config('app.lock_passwords')) {
@@ -969,6 +982,11 @@ class SettingsController extends Controller
*/
public function postRestore(Request $request, $filename = null): RedirectResponse
{
$filename = basename((string) $filename);
if ($this->hasInvalidBackupFilename($filename)) {
return redirect()->route('settings.backups.index')->with('error', trans('admin/settings/message.backup.file_not_found'));
}
if (! config('app.lock_passwords')) {
$path = 'app/backups';
@@ -1118,7 +1136,86 @@ class SettingsController extends Controller
*/
public function api(): View
{
return view('settings.api');
$personalAccessTokenCount = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_clients.personal_access_client', true)
->count();
return view('settings.api', [
'personalAccessTokenCount' => $personalAccessTokenCount,
]);
}
/**
* Revoke a personal access token from the admin OAuth settings page.
*/
public function revokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => true]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenRevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_revoked'));
}
/**
* Unrevoke a personal access token from the admin OAuth settings page.
*/
public function unrevokePersonalAccessToken(string $token): RedirectResponse
{
$tokenRow = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->where('oauth_access_tokens.id', $token)
->where('oauth_clients.personal_access_client', true)
->select(['oauth_access_tokens.id', 'oauth_access_tokens.user_id'])
->first();
if ($tokenRow === null) {
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('error', trans('admin/settings/message.oauth.token_not_found'));
}
DB::table('oauth_access_tokens')
->where('id', $tokenRow->id)
->update(['revoked' => false]);
$logaction = new Actionlog;
$logaction->item_type = User::class;
$logaction->item_id = $tokenRow->user_id;
$logaction->target_type = User::class;
$logaction->target_id = $tokenRow->user_id;
$logaction->created_by = auth()->id();
// $logaction->note = 'Token ID: ' . $tokenRow->id;
$logaction->logaction(ActionType::TokenUnrevoked);
return redirect()
->to(route('settings.oauth.index').'#personal-access-tokens')
->with('success', trans('admin/settings/message.oauth.token_unrevoked'));
}
/**
@@ -1155,4 +1252,62 @@ class SettingsController extends Controller
{
return view('settings.logins');
}
/**
* Revoke an OAuth client from the admin OAuth settings page.
*/
public function revokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => true]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_revoked'));
}
/**
* Unrevoke an OAuth client from the admin OAuth settings page.
*/
public function unrevokeOAuthClient(string $client): RedirectResponse
{
$oauthClient = DB::table('oauth_clients')
->where('id', $client)
->first();
if ($oauthClient === null) {
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('error', trans('admin/settings/message.oauth.client_not_found'));
}
DB::table('oauth_clients')
->where('id', $client)
->update(['revoked' => false]);
return redirect()
->to(route('settings.oauth.index').'#oauth-clients')
->with('success', trans('admin/settings/message.oauth.client_unrevoked'));
}
private function hasInvalidBackupFilename(string $filename): bool
{
if ($filename === '' || $filename === '.' || $filename === '..') {
return true;
}
// Reject path separators in case a crafted value survives route decoding.
return str_contains($filename, '/') || str_contains($filename, '\\');
}
}
@@ -0,0 +1,90 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorageProxyController extends Controller
{
/**
* Proxy files from the "public" storage disk through the application.
*
* When PUBLIC_S3_PROXY is enabled, this serves files that would normally
* be accessed directly from S3 (images, logos, avatars, etc.), allowing
* a fully private S3 bucket setup.
*/
public function show(string $path): Response|StreamedResponse
{
if ($this->hasPathTraversalSegments($path)) {
abort(404);
}
$disk = Storage::disk('public');
// The S3 adapter includes the disk's root prefix in generated URLs,
// but Flysystem also prepends it internally on every operation.
// Strip it here to avoid double-prefixing.
$root = trim(config('filesystems.disks.public.root', ''), '/');
if ($root !== '' && str_starts_with($path, $root.'/')) {
$path = substr($path, strlen($root) + 1);
}
if (! $disk->exists($path)) {
abort(404);
}
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
$lastModified = $disk->lastModified($path);
$etag = md5($path.$lastModified);
$size = $disk->size($path);
if ($this->isNotModified($etag, $lastModified)) {
return response('', 304)
->header('ETag', '"'.$etag.'"')
->header('Cache-Control', 'public, max-age=86400');
}
return new StreamedResponse(function () use ($disk, $path) {
$stream = $disk->readStream($path);
fpassthru($stream);
if (is_resource($stream)) {
fclose($stream);
}
}, 200, [
'Content-Type' => $mimeType,
'Content-Length' => $size,
'ETag' => '"'.$etag.'"',
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
'Cache-Control' => 'public, max-age=86400',
]);
}
private function isNotModified(string $etag, int $lastModified): bool
{
$requestEtag = request()->header('If-None-Match');
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
return true;
}
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
return true;
}
return false;
}
private function hasPathTraversalSegments(string $path): bool
{
$normalizedPath = str_replace('\\', '/', $path);
return str_contains($normalizedPath, "\0")
|| str_starts_with($normalizedPath, '/')
|| str_contains($normalizedPath, '../')
|| str_contains($normalizedPath, '/..')
|| str_ends_with($normalizedPath, '/..')
|| $normalizedPath === '..';
}
}
@@ -37,7 +37,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -85,7 +85,7 @@ class UploadedFilesController extends Controller
{
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('view', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -96,7 +96,7 @@ class UploadedFilesController extends Controller
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_id'));
}
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.file_not_found'));
}
@@ -105,10 +105,10 @@ class UploadedFilesController extends Controller
'Content-Disposition' => 'inline',
];
return Storage::download(self::$map_storage_path[$object_type].'/'.$log->filename, $log->filename, $headers);
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
}
return StorageHelper::downloader(self::$map_storage_path[$object_type].'/'.$log->filename);
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
}
@@ -129,7 +129,7 @@ class UploadedFilesController extends Controller
// Check the permissions to make sure the user can view the object
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
$this->authorize('update', $object);
$this->authorize('files', $object);
if (! $object) {
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
@@ -141,8 +141,8 @@ class UploadedFilesController extends Controller
if ($log) {
// Check the file actually exists, and delete it
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
}
// Delete the record of the file
if ($log->logUploadDelete($object, $log->filename)) {
@@ -362,7 +362,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $item->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->action_date = now();
$logAction->created_at = now();
$logAction->note = 'Bulk checkin items on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
@@ -376,7 +378,9 @@ class BulkUsersController extends Controller
$logAction->target_id = $accessoryUserRow->assigned_to;
$logAction->target_type = User::class;
$logAction->created_by = auth()->id();
$logAction->note = 'Bulk checkin items';
$logAction->created_at = now();
$logAction->action_date = now();
$logAction->note = 'Bulk checkin accessory on user bulk edit/delete';
$logAction->logaction('checkin from');
}
}
+65 -55
View File
@@ -2,13 +2,17 @@
namespace App\Http\Controllers\Users;
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
use App\Actions\Permissions\PreserveUnauthorizedPrivilegedPermissionsAction;
use App\Helpers\Helper;
use App\Http\Controllers\Controller;
use App\Http\Requests\DeleteUserRequest;
use App\Http\Requests\ImageUploadRequest;
use App\Http\Requests\SaveUserRequest;
use App\Mail\UnacceptedAssetReminderMail;
use App\Models\Actionlog;
use App\Models\Asset;
use App\Models\CheckoutAcceptance;
use App\Models\Company;
use App\Models\Group;
use App\Models\Setting;
@@ -20,6 +24,7 @@ use Illuminate\Contracts\View\View;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Password;
use Symfony\Component\HttpFoundation\StreamedResponse;
@@ -97,6 +102,8 @@ class UsersController extends Controller
public function store(SaveUserRequest $request)
{
$this->authorize('create', User::class);
$authenticatedUser = auth()->user();
$user = new User;
// Username, email, and password need to be handled specially because the need to respect config values on an edit.
$user->email = trim($request->input('email'));
@@ -130,26 +137,10 @@ class UsersController extends Controller
$user->end_date = $request->input('end_date', null);
$user->autoassign_licenses = $request->input('autoassign_licenses', 0);
// Strip out the superuser permission if the user isn't a superadmin
$permissions_array = $request->input('permission');
// Strip out the individual superuser permission if the API user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
if ((is_array($permissions_array)) && (array_key_exists('superuser', $permissions_array))) {
unset($permissions_array['superuser']);
}
}
// Strip out the individual admin permission if the API user isn't an admin
if (! auth()->user()->isAdmin()) {
if ((is_array($permissions_array)) && (array_key_exists('admin', $permissions_array))) {
unset($permissions_array['admin']);
}
}
$user->permissions = json_encode($permissions_array);
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
));
// we have to invoke the form request here to handle image uploads
app(ImageUploadRequest::class)->handleImages($user, 600, 'avatar', 'avatars', 'avatar');
@@ -172,12 +163,8 @@ class UsersController extends Controller
}
if ($request->filled('groups')) {
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
} else {
$user->groups()->sync([]);
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
$user->groups()->sync($request->input('groups'));
}
return Helper::getRedirectOption($request, $user->id, 'Users')
@@ -255,6 +242,8 @@ class UsersController extends Controller
{
$this->authorize('update', $user);
$authenticatedUser = auth()->user();
// This is a janky hack to prevent people from changing admin demo user data on the public demo.
// The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
// Thanks, jerks. You are why we can't have nice things. - snipe
@@ -271,21 +260,7 @@ class UsersController extends Controller
$this->authorize('update', $user);
// Figure out of this user was an admin before this edit
$orig_permissions_array = $user->decodePermissions();
$orig_superuser = '0';
$orig_admin = '0';
if (is_array($orig_permissions_array)) {
if (array_key_exists('superuser', $orig_permissions_array)) {
$orig_superuser = $orig_permissions_array['superuser'];
}
}
if (is_array($orig_permissions_array)) {
if (array_key_exists('admin', $orig_permissions_array)) {
$orig_admin = $orig_permissions_array['admin'];
}
}
$orig_permissions_array = NormalizePermissionsPayloadAction::run($user->decodePermissions());
// Update the user fields
@@ -335,20 +310,11 @@ class UsersController extends Controller
$user->password = bcrypt($request->input('password'));
}
$permissions_array = $request->input('permission');
// Strip out the superuser permission if the user isn't a superadmin
if (! auth()->user()->isSuperUser()) {
unset($permissions_array['superuser']);
$permissions_array['superuser'] = $orig_superuser;
}
if ((! auth()->user()->isSuperUser()) && (! auth()->user()->isAdmin())) {
unset($permissions_array['admin']);
$permissions_array['admin'] = $orig_admin;
}
$user->permissions = json_encode($permissions_array);
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permission')),
authenticatedUser: $authenticatedUser,
originalPermissions: $orig_permissions_array,
));
// Only save groups if the user is a superuser
if (auth()->user()->isSuperUser()) {
@@ -473,6 +439,7 @@ class UsersController extends Controller
'accessories',
'licenses',
'userloc',
'groups',
])
->withTrashed()
->find($user->id);
@@ -483,6 +450,7 @@ class UsersController extends Controller
return view('users/view', [
'user' => $user,
'settings' => Setting::getSettings(),
'effectivePermissionsBySection' => $user->getEffectivePermissionsBySection(),
]);
}
@@ -737,6 +705,48 @@ class UsersController extends Controller
}
/**
* Resend pending acceptance reminder email for a specific user.
*/
public function resendAcceptanceReminder(User $user): RedirectResponse
{
$this->authorize('view', $user);
if (empty($user->email)) {
return redirect()->back()->with('error', trans('admin/users/message.user_has_no_email'));
}
if ($user->activated == '0') {
return redirect()->back()->with('error', trans('admin/users/message.not_activated'));
}
$pendingItems = $user->getAssignedItemsWithPendingAcceptance();
if ($pendingItems->isEmpty()) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$firstAcceptance = CheckoutAcceptance::query()
->forUser($user)
->pending()
->with('assignedTo')
->first();
if (! $firstAcceptance) {
return redirect()->back()->with('warning', trans('admin/users/message.error.no_pending_acceptances'));
}
$mailable = new UnacceptedAssetReminderMail($firstAcceptance, $pendingItems->count());
if (! empty($user->locale)) {
$mailable->locale($user->locale);
}
Mail::to($user->email)->send($mailable);
return redirect()->back()->with('success', trans_choice('admin/users/message.success.acceptance_reminder_sent', $pendingItems->count(), ['count' => $pendingItems->count()]));
}
/**
* Send individual password reset email
*
@@ -151,7 +151,7 @@ class ViewAssetsController extends Controller
'requests',
'assets' => function ($q) {
$q->where('requestable', 1)
->whereHas('assetstatus', fn ($s) => $s->where('archived', 0)
->whereHas('status', fn ($s) => $s->where('archived', 0)
->where(fn ($s) => $s->where('deployable', 1)->orWhere('pending', 1)
)
);
@@ -205,7 +205,7 @@ class ViewAssetsController extends Controller
$logaction->logaction(ActionType::RequestCanceled);
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$settings->notify(new RequestAssetCancelation($data));
$settings->notify((new RequestAssetCancelation($data))->locale($settings->locale));
}
return redirect()->back()->with('success')->with('success', trans('admin/hardware/message.requests.canceled'));
@@ -213,7 +213,7 @@ class ViewAssetsController extends Controller
$item->request();
if (($settings->alert_email != '') && ($settings->alerts_enabled == '1') && (! config('app.lock_passwords'))) {
$logaction->logaction('requested');
$settings->notify(new RequestAssetNotification($data));
$settings->notify((new RequestAssetNotification($data))->locale($settings->locale));
}
return redirect()->route('requestable-assets')->with('success')->with('success', trans('admin/hardware/message.requests.success'));
+2
View File
@@ -11,6 +11,7 @@ use App\Http\Middleware\CheckLocale;
use App\Http\Middleware\CheckPermissions;
use App\Http\Middleware\CheckUserIsActivated;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\LogAuthedUserHeader;
use App\Http\Middleware\NoSessionStore;
use App\Http\Middleware\PreventBackHistory;
use App\Http\Middleware\RedirectIfAuthenticated;
@@ -82,6 +83,7 @@ class Kernel extends HttpKernel
'api' => [
'auth:api',
CheckLocale::class,
LogAuthedUserHeader::class,
SubstituteBindings::class,
],
@@ -0,0 +1,29 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class LogAuthedUserHeader
{
/**
* Handle an incoming request.
*
* @param Request $request
* @return mixed
*/
public function handle($request, Closure $next)
{
$response = $next($request);
if ((config('app.authorized_user_header') === true) && ($request->bearerToken() != '')) {
$response->headers->set('X-API-User-ID', auth()?->id());
$response->headers->set('X-API-Token-Name', $request->user()?->token()?->name);
$response->headers->set('X-API-Token-ID', $request->user()?->token()?->id);
}
return $response;
}
}
@@ -0,0 +1,65 @@
<?php
namespace App\Http\Requests;
use App\Models\CheckoutAcceptance;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AcceptSignatureRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
$acceptance = $this->route('acceptance');
$user = Auth::user();
if (! $acceptance || ! $user) {
return false;
}
if (is_string($acceptance)) {
$acceptance = CheckoutAcceptance::find($acceptance);
if (! $acceptance) {
return false;
}
}
if (! $user instanceof User) {
return false;
}
// Only allow if the user is the assigned user or sign-in-place admin
$assignedToId = $acceptance->assigned_to_id ?? null;
$isSignInPlaceAdmin = session('sign_in_place_acceptance_id') === $acceptance->id && $user->can('checkout', $acceptance->checkoutable);
return $user->id === $assignedToId || $isSignInPlaceAdmin;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// ...existing validation rules...
];
}
protected function failedAuthorization()
{
$user = Auth::user();
$acceptance = $this->route('acceptance');
// If user is logged in and acceptance exists, treat as business logic error
if ($user && $acceptance) {
$redirectResponse = redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
throw new ValidationException($this->getValidatorInstance(), $redirectResponse);
}
// Otherwise, use default 403
parent::failedAuthorization();
}
}
+3 -1
View File
@@ -25,7 +25,9 @@ class AssetCheckinRequest extends Request
{
$settings = Setting::getSettings();
$rules = [];
$rules = [
'set_requestable' => 'nullable|boolean',
];
if ($settings->require_checkinout_notes) {
$rules['note'] = 'string|required';
@@ -39,6 +39,8 @@ class AssetCheckoutRequest extends Request
'nullable',
'date',
],
'requestable' => 'nullable|boolean',
'set_not_requestable' => 'nullable|boolean',
];
if ($settings->require_checkinout_notes) {
@@ -22,6 +22,15 @@ class CreateMultipleAssetRequest extends ImageUploadRequest // should I extend f
return true; // TODO - should I do the auth check here?
}
protected function prepareForValidation()
{
parent::prepareForValidation();
if (Setting::getSettings()->full_multiple_companies_support == '1' && ! $this->user()->isSuperUser()) {
$this->mergeIfMissing(['company_id' => $this->user()->company_id]);
}
}
/**
* Get the validation rules that apply to the request.
*
+2 -1
View File
@@ -57,6 +57,7 @@ class ImageUploadRequest extends Request
* had it once to allow encoded image uploads.
*/
return [
'avatar' => 'auto',
'image' => 'auto',
'image_source' => 'auto',
];
@@ -98,7 +99,7 @@ class ImageUploadRequest extends Request
$image = $this->file($form_fieldname);
}
if (isset($image)) {
if ((isset($image)) && ($image != '')) {
$ext = $image->guessExtension();
$file_name = $type.'-'.$form_fieldname.($item->id ?? '-'.$item->id).'-'.str_random(10).'.'.$ext;
+1 -1
View File
@@ -51,7 +51,7 @@ class ItemImportRequest extends FormRequest
if (is_null($fieldValue)) {
$errorMessage = trans('validation.import_field_empty', ['fieldname' => $field]);
$this->errorCallback($import, $field, $errorMessage);
$this->errorCallback($import, $field, [$field => $errorMessage]);
return $this->errors;
}
@@ -54,6 +54,7 @@ class AccessoriesTransformer
] : null,
'notes' => ($accessory->notes) ? Helper::parseEscapedMarkedownInline($accessory->notes) : null,
'qty' => ($accessory->qty) ? (int) $accessory->qty : null,
'percent_remaining' => round($accessory->percentRemaining()),
'purchase_date' => ($accessory->purchase_date) ? Helper::getFormattedDateObject($accessory->purchase_date, 'date') : null,
'purchase_cost' => Helper::formatCurrencyOutput($accessory->purchase_cost),
'total_cost' => Helper::formatCurrencyOutput($accessory->totalCostSum()),
@@ -3,6 +3,7 @@
namespace App\Http\Transformers;
use App\Helpers\Helper;
use App\Models\Asset;
use App\Models\AssetModel;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Gate;
@@ -58,6 +59,7 @@ class AssetModelsTransformer
'assets_assigned_count' => (int) $assetmodel->assets_assigned_count,
'assets_archived_count' => (int) $assetmodel->assets_archived_count,
'remaining' => (int) ($assetmodel->assets_count - (int) $assetmodel->assets_assigned_count) - (int) $assetmodel->assets_archived_count,
'percent_remaining' => round($assetmodel->percentRemaining()),
'category' => ($assetmodel->category) ? [
'id' => (int) $assetmodel->category->id,
'name' => e($assetmodel->category->name),
@@ -83,6 +85,7 @@ class AssetModelsTransformer
];
$permissions_array['available_actions'] = [
'create_asset' => (Gate::allows('create', Asset::class) && ($assetmodel->deleted_at == '')),
'update' => (Gate::allows('update', AssetModel::class) && ($assetmodel->deleted_at == '')),
'delete' => $assetmodel->isDeletable(),
'clone' => (Gate::allows('create', AssetModel::class) && ($assetmodel->deleted_at == '')),
+35 -18
View File
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
use App\Models\Accessory;
use App\Models\AccessoryCheckout;
use App\Models\Asset;
use App\Models\Component;
use App\Models\License;
use App\Models\LicenseSeat;
use App\Models\Setting;
@@ -45,10 +46,16 @@ class AssetsTransformer
'model_number' => (($asset->model) && ($asset->model->model_number)) ? e($asset->model->model_number) : null,
'eol' => (($asset->asset_eol_date != '') && ($asset->purchase_date != '')) ? (int) Carbon::parse($asset->asset_eol_date)->diffInMonths($asset->purchase_date, true).' months' : null,
'asset_eol_date' => ($asset->asset_eol_date != '') ? Helper::getFormattedDateObject($asset->asset_eol_date, 'date') : null,
'status_label' => ($asset->assetstatus) ? [
'id' => (int) $asset->assetstatus->id,
'name' => e($asset->assetstatus->name),
'status_type' => e($asset->assetstatus->getStatuslabelType()),
'status_label' => ($asset->status) ? [
'id' => (int) $asset->status->id,
'name' => e($asset->status->name),
'status_type' => e($asset->status->getStatuslabelType()),
'status_meta' => e($asset->present()->statusMeta),
] : null, // <-- legacy - will be removed
'status' => ($asset->status) ? [
'id' => (int) $asset->status->id,
'name' => e($asset->status->name),
'status_type' => e($asset->status->getStatuslabelType()),
'status_meta' => e($asset->present()->statusMeta),
] : null,
'category' => (($asset->model) && ($asset->model->category)) ? [
@@ -91,8 +98,8 @@ class AssetsTransformer
'tag_color' => ($asset->defaultLoc->tag_color) ? e($asset->defaultLoc->tag_color) : null,
] : null,
'image' => ($asset->getImageUrl()) ? $asset->getImageUrl() : null,
'qr' => ($setting->qr_code == '1') ? config('app.url').'/uploads/barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png' : null,
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? config('app.url').'/uploads/barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png' : null,
'qr' => ($setting->qr_code == '1') ? Storage::disk('public')->url('barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png') : null,
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? Storage::disk('public')->url('barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png') : null,
'assigned_to' => $this->transformAssignedTo($asset),
'warranty_months' => ($asset->warranty_months > 0) ? e($asset->warranty_months.' '.trans('admin/hardware/form.months')) : null,
'warranty_expires' => ($asset->warranty_months > 0) ? Helper::getFormattedDateObject($asset->warranty_expires, 'date') : null,
@@ -185,8 +192,8 @@ class AssetsTransformer
'pivot_id' => $component->pivot->id,
'name' => e($component->name),
'qty' => $component->pivot->assigned_qty,
'price_cost' => $component->purchase_cost,
'purchase_total' => $component->purchase_cost * $component->pivot->assigned_qty,
'purchase_cost' => $component->purchase_cost,
'purchase_total' => $component->calculated_purchase_cost,
'checkout_date' => Helper::getFormattedDateObject($component->pivot->created_at, 'datetime'),
];
@@ -250,7 +257,7 @@ class AssetsTransformer
'model_number' => (($asset->model) && ($asset->model->model_number)) ? e($asset->model->model_number) : null,
'expected_checkin' => Helper::getFormattedDateObject($asset->expected_checkin, 'date'),
'location' => ($asset->location) ? e($asset->location->name) : null,
'status' => ($asset->assetstatus) ? $asset->present()->statusMeta : null,
'status' => ($asset->status) ? $asset->present()->statusMeta : null,
'assigned_to_self' => ($asset->assigned_to == auth()->id()),
];
@@ -392,16 +399,26 @@ class AssetsTransformer
public function transformCheckedoutComponents(Collection $components_assets, $total)
{
$array = [];
foreach ($components_assets as $component) {
foreach ($components_assets as $component_checkout) {
$array[] = [
'assigned_pivot_id' => $component->pivot->id,
'id' => (int) $component->id,
'name' => e($component->display_name),
'qty' => $component->pivot->assigned_qty,
'note' => ($component->pivot->note) ? e($component->pivot->note) : null,
'type' => 'asset',
'created_at' => Helper::getFormattedDateObject($component->pivot->created_at, 'datetime'),
'available_actions' => ['checkin' => true],
'assigned_pivot_id' => $component_checkout->id,
'name' => [
'id' => $component_checkout->component?->id,
'name' => e($component_checkout->component?->display_name),
'type' => 'component',
'deleted_at' => $component_checkout->component?->deleted_at,
],
'assigned_qty' => $component_checkout->assigned_qty,
'note' => ($component_checkout->note) ? e($component_checkout->note) : null,
'created_at' => Helper::getFormattedDateObject($component_checkout->created_at, 'datetime'),
'created_by' => $component_checkout->adminuser ? [
'id' => (int) $component_checkout->adminuser->id,
'name' => e($component_checkout->adminuser->display_name),
] : null,
'available_actions' => [
'checkin' => (($component_checkout->component?->deleted_at == '') && Gate::allows('checkin', Component::class)),
'view' => (($component_checkout->component?->deleted_at == '') && Gate::allows('view', Component::class)),
],
];
}
@@ -51,7 +51,7 @@ class CategoriesTransformer
'name' => e($category->name),
'image' => ($category->image) ? Storage::disk('public')->url('categories/'.e($category->image)) : null,
'category_type' => Helper::categoryTypeList($category->category_type),
'has_eula' => ($category->getEula() ? true : false),
'has_eula' => ($category->eula_text) ? true : false,
'use_default_eula' => ($category->use_default_eula == '1' ? true : false),
'eula' => ($category->getEula()),
'checkin_email' => ($category->checkin_email == '1'),
@@ -55,6 +55,7 @@ class ComponentsTransformer
'purchase_cost' => Helper::formatCurrencyOutput($component->purchase_cost),
'total_cost' => Helper::formatCurrencyOutput($component->totalCostSum()),
'remaining' => (int) $component->numRemaining(),
'percent_remaining' => round($component->percentRemaining()),
'company' => ($component->company) ? [
'id' => (int) $component->company->id,
'name' => e($component->company->name),
@@ -87,12 +88,11 @@ class ComponentsTransformer
$array = [];
foreach ($components_assets as $asset) {
$array[] = [
'assigned_pivot_id' => $asset->pivot->id,
'id' => (int) $asset->id,
'name' => e($asset->display_name),
'qty' => $asset->pivot->assigned_qty,
'assigned_pivot_id' => (int) $asset->pivot->id,
'name' => $this->transformAssignedTo($asset),
'qty' => $asset->pivot->assigned_qty, // legacy?
'assigned_qty' => $asset->pivot->assigned_qty,
'note' => ($asset->pivot->note) ? e($asset->pivot->note) : null,
'type' => 'asset',
'created_at' => Helper::getFormattedDateObject($asset->pivot->created_at, 'datetime'),
'available_actions' => ['checkin' => true],
];
@@ -100,4 +100,9 @@ class ComponentsTransformer
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
public function transformAssignedTo($componentCheckout)
{
return (new AssetsTransformer)->transformAssetCompact($componentCheckout);
}
}
@@ -54,6 +54,7 @@ class ConsumablesTransformer
'min_amt' => (int) $consumable->min_amt,
'model_number' => ($consumable->model_number != '') ? e($consumable->model_number) : null,
'remaining' => $consumable->numRemaining(),
'percent_remaining' => round($consumable->percentRemaining()),
'order_number' => e($consumable->order_number),
'purchase_cost' => Helper::formatCurrencyOutput($consumable->purchase_cost),
'total_cost' => Helper::formatCurrencyOutput($consumable->totalCostSum()),
@@ -88,8 +88,8 @@ class DepreciationReportTransformer
'model' => ($asset->model) ? e($asset->model->name) : null,
'model_number' => (($asset->model) && ($asset->model->model_number)) ? e($asset->model->model_number) : null,
'eol' => ($asset->purchase_date != '') ? Helper::getFormattedDateObject($asset->present()->eol_date(), 'date') : null,
'status_label' => ($asset->assetstatus) ? e($asset->assetstatus->name) : null,
'status' => ($asset->assetstatus) ? e($asset->present()->statusMeta) : null,
'status_label' => ($asset->status) ? e($asset->status->name) : null,
'status' => ($asset->status) ? e($asset->present()->statusMeta) : null,
'category' => (($asset->model) && ($asset->model->category)) ? e($asset->model->category->name) : null,
'manufacturer' => (($asset->model) && ($asset->model->manufacturer)) ? e($asset->model->manufacturer->name) : null,
'supplier' => ($asset->supplier) ? e($asset->supplier->name) : null,
@@ -30,7 +30,7 @@ class LicensesTransformer
'name' => e($license->manufacturer->name),
'tag_color' => ($license->manufacturer->tag_color) ? e($license->manufacturer->tag_color) : null,
] : null,
'product_key' => (Gate::allows('viewKeys', License::class)) ? e($license->serial) : '------------',
'product_key' => (Gate::allows('viewKeys', $license)) ? e($license->serial) : '------------',
'order_number' => ($license->order_number) ? e($license->order_number) : null,
'purchase_order' => ($license->purchase_order) ? e($license->purchase_order) : null,
'purchase_date' => Helper::getFormattedDateObject($license->purchase_date, 'date'),
@@ -43,6 +43,7 @@ class LicensesTransformer
'seats' => (int) $license->seats,
'free_seats_count' => (int) $license->free_seats_count - License::unReassignableCount($license),
'remaining' => (int) $license->free_seats_count,
'percent_remaining' => round($license->percentRemaining()),
'min_amt' => ($license->min_amt) ? (int) ($license->min_amt) : null,
'license_name' => ($license->license_name) ? e($license->license_name) : null,
'license_email' => ($license->license_email) ? e($license->license_email) : null,
@@ -113,7 +113,11 @@ class LocationsTransformer
$array = [
'id' => $accessory_checkout->id,
'assigned_to' => $accessory_checkout->assigned_to,
'assigned_to' => $accessory_checkout->assignedTo ? [
'id' => $accessory_checkout->assignedTo?->id,
'name' => $accessory_checkout->assignedTo?->display_name,
'type' => strtolower($accessory_checkout->assignedType()),
] : null,
'accessory' => $this->transformAccessory($accessory_checkout->accessory),
'image' => ($accessory_checkout?->accessory?->image) ? Storage::disk('public')->url('accessories/'.e($accessory_checkout->accessory->image)) : null,
'note' => $accessory_checkout->note ? e($accessory_checkout->note) : null,
@@ -17,6 +17,7 @@ class MaintenancesTransformer
foreach ($maintenances as $assetmaintenance) {
$array[] = self::transformMaintenance($assetmaintenance);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
@@ -39,10 +40,10 @@ class MaintenancesTransformer
'name' => ($assetmaintenance->asset->model->name) ? e($assetmaintenance->asset->model->name) : null,
'model_number' => ($assetmaintenance->asset->model->model_number) ? e($assetmaintenance->asset->model->model_number) : null,
] : null,
'status_label' => (($assetmaintenance->asset) && ($assetmaintenance->asset->assetstatus)) ? [
'id' => (int) $assetmaintenance->asset->assetstatus->id,
'name' => e($assetmaintenance->asset->assetstatus->name),
'status_type' => e($assetmaintenance->asset->assetstatus->getStatuslabelType()),
'status_label' => (($assetmaintenance->asset) && ($assetmaintenance->asset->status)) ? [
'id' => (int) $assetmaintenance->asset->status->id,
'name' => e($assetmaintenance->asset->status->name),
'status_type' => e($assetmaintenance->asset->status->getStatuslabelType()),
'status_meta' => e($assetmaintenance->asset->present()->statusMeta),
] : null,
'assigned_to' => (new AssetsTransformer)->transformAssignedTo($assetmaintenance->asset),
@@ -103,6 +104,7 @@ class MaintenancesTransformer
foreach ($maintenances as $assetmaintenance) {
$array[] = self::transformMaintenanceForReport($assetmaintenance);
}
return (new DatatablesTransformer)->transformDatatables($array, $total);
}
@@ -113,10 +115,10 @@ class MaintenancesTransformer
'asset_name' => ($assetmaintenance->asset->name) ? e($assetmaintenance->asset->name) : null,
'asset_tag' => ($assetmaintenance->asset->asset_tag) ? e($assetmaintenance->asset->asset_tag) : null,
'serial' => ($assetmaintenance->asset?->serial) ? e($assetmaintenance->asset->serial) : null,
'image' => ($assetmaintenance->image != '') ? Storage::disk('public')->url('maintenances/' . e($assetmaintenance->image)) : null,
'image' => ($assetmaintenance->image != '') ? Storage::disk('public')->url('maintenances/'.e($assetmaintenance->image)) : null,
'model' => ($assetmaintenance->asset?->model?->name) ? e($assetmaintenance->asset?->model?->name) : null,
'model_number' => ($assetmaintenance->asset?->model?->model_number) ? e($assetmaintenance->asset?->model?->model_number) : null,
'status_label' => ($assetmaintenance->asset?->assetstatus) ? e($assetmaintenance->asset?->assetstatus?->display_name) : null,
'status_label' => ($assetmaintenance->asset?->status) ? e($assetmaintenance->asset?->status?->display_name) : null,
'assigned_to' => ($assetmaintenance->asset?->assigned) ? e($assetmaintenance->asset?->assigned?->display_name) : null,
'company' => ($assetmaintenance->asset?->company?->name) ? e($assetmaintenance->asset->company->name) : null,
'name' => ($assetmaintenance->name) ? e($assetmaintenance->name) : null,
@@ -136,7 +138,7 @@ class MaintenancesTransformer
'is_warranty' => (bool) $assetmaintenance->is_warranty,
];
return $array;
}
}
+89 -39
View File
@@ -40,6 +40,7 @@ use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Str;
use Osama\LaravelTeamsNotification\TeamsNotification;
class CheckoutableListener
{
private array $skipNotificationsFor = [
@@ -77,9 +78,14 @@ class CheckoutableListener
$acceptance = $this->getCheckoutAcceptance($event);
$shouldSendEmailToUser = $this->shouldSendCheckoutEmailToUser($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress($acceptance, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if ($this->shouldSkipInitialAcceptanceEmail($event, $acceptance)) {
$shouldSendEmailToUser = false;
$shouldSendEmailToAlertAddress = false;
}
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
}
@@ -169,7 +175,7 @@ class CheckoutableListener
}
$shouldSendEmailToUser = $this->checkoutableCategoryShouldSendEmail($event->checkoutable);
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress();
$shouldSendEmailToAlertAddress = $this->shouldSendEmailToAlertAddress(null, $event->checkoutable);
$shouldSendWebhookNotification = $this->shouldSendWebhookNotification();
if (! $shouldSendEmailToUser && ! $shouldSendEmailToAlertAddress && ! $shouldSendWebhookNotification) {
return;
@@ -192,33 +198,39 @@ class CheckoutableListener
}
$mailable = $this->getCheckinMailType($event);
$notifiable = $this->getNotifiableUser($event);
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
if (! $mailable) {
Log::debug('No checkin mail type available for checkoutable class: '.get_class($event->checkoutable));
} else {
$notifiable = $this->getNotifiableUser($event);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
$notifiableHasEmail = $notifiable instanceof User && $notifiable->email;
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
$shouldSendEmailToUser = $shouldSendEmailToUser && $notifiableHasEmail;
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
[$to, $cc] = $this->generateEmailRecipients($shouldSendEmailToUser, $shouldSendEmailToAlertAddress, $notifiable);
if (! empty($to)) {
try {
$toMail = (clone $mailable)->locale($notifiable->locale);
Mail::to(array_flatten($to))->send($toMail);
Log::info('Checkin Mail sent to checkin target');
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
if (! empty($cc)) {
try {
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
Mail::cc(array_flatten($cc))->send($ccMail);
} catch (ClientException $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
} catch (Exception $e) {
Log::debug('Exception caught during checkin email: '.$e->getMessage());
}
}
}
}
@@ -386,7 +398,14 @@ class CheckoutableListener
LicenseSeat::class => CheckinLicenseMail::class,
Component::class => CheckinComponentMail::class,
];
$mailable = $lookup[get_class($event->checkoutable)];
$checkoutableClass = get_class($event->checkoutable);
if (! array_key_exists($checkoutableClass, $lookup)) {
return null;
}
$mailable = $lookup[$checkoutableClass];
return new $mailable($event->checkoutable, $event->checkedOutTo, $event->checkedInBy, $event->note);
@@ -460,11 +479,16 @@ class CheckoutableListener
* 1. The asset requires acceptance
* 2. The item has a EULA
* 3. The item should send an email at check-in/check-out
* 4. The config('app.always_send_email') is true
*/
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
}
if (config('app.always_send_email')) {
return true;
}
if ($checkoutable->requireAcceptance()) {
return true;
}
@@ -480,7 +504,16 @@ class CheckoutableListener
return false;
}
private function shouldSendEmailToAlertAddress($acceptance = null): bool
private function shouldSkipInitialAcceptanceEmail(CheckoutableCheckedOut $event, ?CheckoutAcceptance $acceptance): bool
{
if (! $event->signInPlace) {
return false;
}
return ($acceptance instanceof CheckoutAcceptance) || ! empty($event->checkoutable->getEula());
}
private function shouldSendEmailToAlertAddress($acceptance = null, ?Model $checkoutable = null): bool
{
if (Context::get('action') === 'bulk_asset_checkout') {
return false;
@@ -492,22 +525,39 @@ class CheckoutableListener
return false;
}
if (is_null($acceptance) && ! $setting->admin_cc_always) {
$alertRecipients = $this->getFormattedAlertAddresses((bool) $setting->admin_cc_always);
if (empty($alertRecipients)) {
return false;
}
return (bool) $setting->admin_cc_email;
}
private function getFormattedAlertAddresses(): array
{
$alertAddresses = Setting::getSettings()->admin_cc_email;
if ($alertAddresses !== '') {
return array_filter(array_map('trim', explode(',', $alertAddresses)));
if (is_null($acceptance) && ! $setting->admin_cc_always) {
if (! $checkoutable || ! $this->checkoutableCategoryShouldSendEmail($checkoutable)) {
return false;
}
}
return [];
return true;
}
private function getFormattedAlertAddresses(bool $allowAlertEmailFallback = false): array
{
$setting = Setting::getSettings();
if (! $setting) {
return [];
}
$adminCcAddresses = $setting->admin_cc_email;
$fallbackAlertAddresses = $allowAlertEmailFallback ? $setting->alert_email : null;
$rawAddresses = $adminCcAddresses ?: $fallbackAlertAddresses;
if ($rawAddresses === null || $rawAddresses === '') {
return [];
}
return array_values(array_unique(array_filter(array_map('trim', explode(',', $rawAddresses)))));
}
private function generateEmailRecipients(
@@ -521,7 +571,7 @@ class CheckoutableListener
// if user && cc: to user, cc admin
if ($shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $notifiable;
$cc[] = $this->getFormattedAlertAddresses();
$cc[] = $this->getFormattedAlertAddresses(true);
}
// if user && no cc: to user
@@ -531,7 +581,7 @@ class CheckoutableListener
// if no user && cc: to admin
if (! $shouldSendEmailToUser && $shouldSendEmailToAlertAddress) {
$to[] = $this->getFormattedAlertAddresses();
$to[] = $this->getFormattedAlertAddresses(true);
}
return [$to, $cc];
+10 -3
View File
@@ -10,6 +10,7 @@ use App\Events\UserMerged;
use App\Models\Actionlog;
use App\Models\LicenseSeat;
use App\Models\User;
use Illuminate\Events\Dispatcher;
use Illuminate\Support\Facades\Log;
class LogListener
@@ -59,7 +60,10 @@ class LogListener
$logaction->action_type = 'accepted';
$logaction->action_date = $event->acceptance->accepted_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -79,7 +83,10 @@ class LogListener
$logaction->action_type = 'declined';
$logaction->action_date = $event->acceptance->declined_at;
$logaction->quantity = $event->acceptance->qty ?? 1;
$logaction->created_by = auth()->user()->id;
$logaction->created_by = auth()->user()?->getAuthIdentifier();
$logaction->remote_ip = request()->ip();
$logaction->user_agent = request()->header('User-Agent');
$logaction->action_source = 'gui';
// TODO: log the actual license seat that was checked out
if ($event->acceptance->checkoutable instanceof LicenseSeat) {
@@ -127,7 +134,7 @@ class LogListener
/**
* Register the listeners for the subscriber.
*
* @param Illuminate\Events\Dispatcher $events
* @param Dispatcher $events
*/
public function subscribe($events)
{
@@ -0,0 +1,40 @@
<?php
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
/**
* Livewire component for the admin-facing User API Tokens (Personal Access Tokens) table.
* Displays all personal access tokens across all users, used on the Settings > OAuth page.
*/
class AdminPersonalAccessTokens extends Component
{
public function render()
{
$tokens = DB::table('oauth_access_tokens')
->join('oauth_clients', 'oauth_access_tokens.client_id', '=', 'oauth_clients.id')
->leftJoin('users', 'oauth_access_tokens.user_id', '=', 'users.id')
->where('oauth_clients.personal_access_client', true)
->select([
'oauth_access_tokens.id',
'oauth_access_tokens.name',
'oauth_access_tokens.revoked',
'oauth_access_tokens.created_at',
'oauth_access_tokens.expires_at',
'oauth_access_tokens.user_id as token_user_id',
'oauth_clients.name as client_name',
'users.id as existing_user_id',
'users.username as username',
'users.display_name as display_name',
'users.deleted_at as user_deleted_at',
])
->orderByDesc('oauth_access_tokens.created_at')
->get();
return view('livewire.admin-personal-access-tokens', [
'tokens' => $tokens,
]);
}
}
+7
View File
@@ -2,6 +2,7 @@
namespace App\Livewire;
use App\Models\Setting;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -45,4 +46,10 @@ class CategoryEditForm extends Component
{
return (bool) $this->useDefaultEula;
}
#[Computed]
public function isGlobalSignatureRequired(): bool
{
return (string) Setting::getSettings()->require_accept_signature === '1';
}
}
+87 -12
View File
@@ -2,14 +2,16 @@
namespace App\Livewire;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Laravel\Passport\Client;
use Laravel\Passport\ClientRepository;
use Laravel\Passport\TokenRepository;
use Livewire\Component;
class OauthClients extends Component
{
public string $section = 'all';
public $name;
public $redirect;
@@ -22,11 +24,77 @@ class OauthClients extends Component
public $authorizationError;
public function mount(?string $section = null): void
{
if ($section !== null) {
$this->section = $section;
}
}
public function showOauthClients(): bool
{
return in_array($this->section, ['all', 'oauth-clients'], true);
}
public function showAuthorizedApplications(): bool
{
return in_array($this->section, ['all', 'authorized-applications'], true);
}
public function render()
{
$clients = collect();
if ($this->showOauthClients()) {
$clients = Client::query()
->orderByDesc('created_at')
->get();
if ($clients->isNotEmpty()) {
$tokenCountsByClientId = DB::table('oauth_access_tokens')
->whereIn('client_id', $clients->pluck('id')->all())
->selectRaw('client_id, COUNT(*) as token_count')
->groupBy('client_id')
->pluck('token_count', 'client_id');
$clients->each(function ($client) use ($tokenCountsByClientId): void {
$client->setAttribute('associated_token_count', (int) ($tokenCountsByClientId[$client->id] ?? 0));
});
}
}
$authorizedApplications = collect();
if ($this->showAuthorizedApplications()) {
$authorizedTokenSummary = DB::table('oauth_access_tokens as tokens')
->where('tokens.revoked', false)
->selectRaw('tokens.client_id')
->selectRaw('MAX(tokens.scopes) as scopes')
->selectRaw('MAX(tokens.created_at) as created_at')
->selectRaw('MAX(tokens.expires_at) as expires_at')
->groupBy('tokens.client_id');
$authorizedApplications = DB::table('oauth_clients as clients')
->joinSub($authorizedTokenSummary, 'token_summary', function ($join) {
$join->on('clients.id', '=', 'token_summary.client_id');
})
->leftJoin('users as creators', 'clients.user_id', '=', 'creators.id')
->select([
'clients.id as client_id',
'clients.name as client_name',
'clients.user_id as client_owner_id',
'creators.display_name as client_owner_display_name',
'creators.username as client_owner_username',
'creators.deleted_at as client_owner_deleted_at',
'token_summary.scopes',
'token_summary.created_at',
'token_summary.expires_at',
])
->orderByDesc('token_summary.created_at')
->get();
}
return view('livewire.oauth-clients', [
'clients' => app(ClientRepository::class)->activeForUser(auth()->id()),
'authorized_tokens' => app(TokenRepository::class)->forUser(auth()->id())->where('revoked', false),
'clients' => $clients,
'authorizedApplications' => $authorizedApplications,
]);
}
@@ -43,6 +111,7 @@ class OauthClients extends Component
$this->redirect,
);
session()->flash('success', trans('admin/settings/message.oauth.client_created'));
$this->dispatch('clientCreated');
}
@@ -50,22 +119,27 @@ class OauthClients extends Component
{
// test for safety
// ->delete must be of type Client - thus the model binding
if ($clientId->user_id == auth()->id()) {
if ((auth()->user()?->isSuperUser()) || ($clientId->user_id == auth()->id())) {
app(ClientRepository::class)->delete($clientId);
session()->flash('success', trans('admin/settings/message.oauth.client_deleted'));
} else {
Log::warning('User '.auth()->id().' attempted to delete client '.$clientId->id.' which belongs to user '.$clientId->created_by);
$this->authorizationError = 'You are not authorized to delete this client.';
$this->authorizationError = trans('admin/settings/message.oauth.client_delete_denied');
}
}
public function deleteToken($tokenId): void
public function deleteAuthorizedApplication(int $clientId): void
{
$token = app(TokenRepository::class)->find($tokenId);
if ($token->created_by == auth()->id()) {
app(TokenRepository::class)->revokeAccessToken($tokenId);
$revokedTokenCount = DB::table('oauth_access_tokens')
->where('client_id', $clientId)
->where('revoked', false)
->update(['revoked' => true]);
if ($revokedTokenCount > 0) {
session()->flash('success', trans('admin/settings/message.oauth.token_deleted'));
} else {
Log::warning('User '.auth()->id().' attempted to delete token '.$tokenId.' which belongs to user '.$token->created_by);
$this->authorizationError = 'You are not authorized to delete this token.';
Log::warning('User '.auth()->id().' attempted to revoke authorized application client '.$clientId.' without matching active tokens.');
$this->authorizationError = trans('admin/settings/message.oauth.token_delete_denied');
}
}
@@ -91,9 +165,10 @@ class OauthClients extends Component
$client->name = $this->editName;
$client->redirect = $this->editRedirect;
$client->save();
session()->flash('success', trans('admin/settings/message.oauth.client_updated'));
} else {
Log::warning('User '.auth()->id().' attempted to edit client '.$editClientId->id.' which belongs to user '.$client->created_by);
$this->authorizationError = 'You are not authorized to edit this client.';
$this->authorizationError = trans('admin/settings/message.oauth.client_edit_denied');
}
$this->dispatch('clientUpdated');
+2 -2
View File
@@ -56,7 +56,7 @@ class CheckinAssetMail extends BaseMailable
*/
public function content(): Content
{
$this->item->load('assetstatus');
$this->item->load('status');
$fields = [];
// Check if the item has custom fields associated with it
@@ -68,7 +68,7 @@ class CheckinAssetMail extends BaseMailable
markdown: 'mail.markdown.checkin-asset',
with: [
'item' => $this->item,
'status' => $this->item->assetstatus?->name,
'status' => $this->item->status?->name,
'admin' => $this->admin,
'note' => $this->note,
'target' => $this->target,
+6 -3
View File
@@ -71,7 +71,7 @@ class CheckoutAssetMail extends BaseMailable
*/
public function content(): Content
{
$this->item->load('assetstatus');
$this->item->load('status');
$eula = method_exists($this->item, 'getEula') ? $this->item->getEula() : '';
$req_accept = $this->requiresAcceptance();
$fields = [];
@@ -97,7 +97,7 @@ class CheckoutAssetMail extends BaseMailable
with: [
'item' => $this->item,
'admin' => $this->admin,
'status' => $this->item->assetstatus?->name,
'status' => $this->item->status?->name,
'note' => $this->note,
'target' => $name,
'fields' => $fields,
@@ -132,6 +132,9 @@ class CheckoutAssetMail extends BaseMailable
private function introductionLine(): string
{
if (is_null($this->acceptance)) {
return trans_choice('mail.new_item_checked', 1);
}
if ($this->firstTimeSending && $this->target instanceof Location) {
return trans_choice('mail.new_item_checked_location', 1, ['location' => $this->target->name]);
}
@@ -149,7 +152,7 @@ class CheckoutAssetMail extends BaseMailable
}
// we shouldn't get here but let's send a default message just in case
return trans('new_item_checked');
return trans('mail.new_item_checked');
}
private function requiresAcceptance(): int|bool
+27 -96
View File
@@ -2,7 +2,6 @@
namespace App\Models;
use App\Http\Controllers\Api\AccessoriesController\checkedout;
use App\Models\Traits\Acceptable;
use App\Models\Traits\CompanyableTrait;
use App\Models\Traits\HasUploads;
@@ -47,7 +46,15 @@ class Accessory extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'model_number', 'order_number', 'purchase_date', 'notes'];
protected $searchableAttributes = [
'created_at',
'model_number',
'name',
'notes',
'order_number',
'purchase_cost',
'purchase_date',
];
/**
* The relations and their attributes that should be included when searching the model.
@@ -57,9 +64,13 @@ class Accessory extends SnipeModel
protected $searchableRelations = [
'category' => ['name'],
'company' => ['name'],
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
'location' => ['name'],
];
protected $searchableCounts = [
'checkouts_count',
];
/**
@@ -67,7 +78,7 @@ class Accessory extends SnipeModel
*/
public $rules = [
'name' => 'required|max:255',
'qty' => 'required|integer|min:1',
'qty' => 'nullable|integer|min:0',
'category_id' => 'required|integer|exists:categories,id',
'company_id' => 'integer|nullable',
'location_id' => 'exists:locations,id|nullable|fmcs_location',
@@ -268,6 +279,18 @@ class Accessory extends SnipeModel
->with('assignedTo');
}
public function percentRemaining()
{
if (($this->qty == '' || $this->qty == 0)) {
return 0;
}
if ($this->checkouts_count == 0) {
return 100;
}
return ($this->qty - $this->checkouts_count) / $this->qty * 100;
}
/**
* Establishes the accessory -> users relationship
*
@@ -283,20 +306,6 @@ class Accessory extends SnipeModel
->with('assignedTo');
}
/**
* Establishes the accessory -> admin user relationship
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v7.0.13]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Checks whether or not the accessory has users
*
@@ -444,84 +453,6 @@ class Accessory extends SnipeModel
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('accessories.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('accessories.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('accessories.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('accessories.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('accessories.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on created_by name
+30 -23
View File
@@ -8,8 +8,8 @@ use App\Models\Traits\Searchable;
use App\Presenters\ActionlogPresenter;
use App\Presenters\Presentable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
@@ -329,6 +329,27 @@ class Actionlog extends SnipeModel
return $this->morphTo('target')->withTrashed();
}
/**
* Eager load history relations used by the API transformer to avoid N+1 queries.
*/
public function scopeForApiHistory($query)
{
return $query->with([
'adminuser',
'location',
'item' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Asset::class => ['model'],
]);
},
'target' => function (MorphTo $morphTo) {
$morphTo->morphWith([
Asset::class => ['model'],
]);
},
]);
}
/**
* Establishes the actionlog -> location relationship
*
@@ -423,39 +444,21 @@ class Actionlog extends SnipeModel
/**
* Calculate the date of the next audit
*
* @author [A. Gianotto] [<snipe@snipe.net>]
* @return Datetime | string
*
* @since [v4.0]
*
* @return \Datetime
* @author [A. Gianotto] [<snipe@snipe.net>]
*/
public function calcNextAuditDate($monthInterval = 12, $asset = null)
{
$last_audit_date = Carbon::parse($this->created_at);
// If there is an asset-specific next date already given,
if (($asset) && ($asset->next_audit_date)) {
return \Carbon::parse($asset->next_audit_date);
return Carbon::parse($asset->next_audit_date);
}
return \Carbon::parse($last_audit_date)->addMonths($monthInterval)->toDateString();
}
/**
* Gets action logs in chronological order, excluding uploads
*
* @author Vincent Sposato <vincent.sposato@gmail.com>
*
* @since v1.0
*
* @return Collection
*/
public function getListingOfActionLogsChronologicalOrder()
{
return $this->all()
->where('action_type', '!=', 'uploaded')
->orderBy('item_id', 'asc')
->orderBy('created_at', 'asc')
->get();
return Carbon::parse($last_audit_date)->addMonths($monthInterval)->toDateString();
}
/**
@@ -553,8 +556,12 @@ class Actionlog extends SnipeModel
return 'private_uploads/assets/'.$this->filename;
case AssetModel::class:
return 'private_uploads/models/'.$this->filename;
case Company::class:
return 'private_uploads/companies/'.$this->filename;
case Consumable::class:
return 'private_uploads/consumables/'.$this->filename;
case Department::class:
return 'private_uploads/departments/'.$this->filename;
case Component::class:
return 'private_uploads/components/'.$this->filename;
case License::class:
+123 -269
View File
@@ -34,11 +34,17 @@ class Asset extends Depreciable
{
protected $presenter = AssetPresenter::class;
protected $with = ['model', 'adminuser'];
protected $with = ['model', 'adminuser', 'location', 'company'];
use CompanyableTrait;
use HasFactory, Loggable, Presentable, Requestable, SoftDeletes, UniqueUndeletedTrait, ValidatingTrait;
use HasFactory;
use HasUploads;
use Loggable;
use Presentable;
use Requestable;
use SoftDeletes;
use UniqueUndeletedTrait;
use ValidatingTrait;
public const LOCATION = 'location';
@@ -73,7 +79,6 @@ class Asset extends Depreciable
* Leaving this commented out, since we need to test further, but this would eager load the model relationship every single
* time the asset model is loaded.
*/
// protected $with = ['model'];
/**
* Whether the model should inject it's identifier to the unique
@@ -200,14 +205,29 @@ class Asset extends Depreciable
* @var array
*/
protected $searchableRelations = [
'assetstatus' => ['name'],
'status' => ['name'],
'supplier' => ['name'],
'company' => ['name'],
'defaultLoc' => ['name'],
'location' => ['name'],
'model' => ['name', 'model_number', 'eol'],
'model.category' => ['name'],
'model.manufacturer' => ['name'],
'category' => ['name'],
'manufacturer' => ['name'],
'assigned_to' => ['name'],
];
/**
* Maps the field names exposed by the API / transformers to the actual
* Eloquent relation names used in $searchableRelations.
*
* This lets callers filter using the same key they see in API responses
* without needing to know the internal relation name.
*
* @var array<string, string> [ api_key => relation_name ]
*/
protected $searchableRelationAliases = [
'status_label' => 'status',
'assigned_to' => 'assignedTo',
];
protected static function booted(): void
@@ -450,12 +470,14 @@ class Asset extends Depreciable
if ((! $this->assigned_to) && (! $this->deleted_at)) {
// The asset status is not archived and is deployable
if (($this->assetstatus) && ($this->assetstatus->archived == '0')
&& ($this->assetstatus->deployable == '1')
if (($this->status) && ($this->status->archived == '0')
&& ($this->status->deployable == '1')
) {
return true;
}
return false;
}
return false;
@@ -465,8 +487,11 @@ class Asset extends Depreciable
{
// This asset is currently assigned to anyone and is not deleted...
if ($this->assigned_to) {
if (($this->assigned_to != '') && ($this->status) && ($this->status->archived == '0')
&& ($this->status->deployable == '1')
) {
return true;
}
return false;
@@ -491,7 +516,7 @@ class Asset extends Depreciable
*
* @return bool
*/
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null)
public function checkOut($target, $admin = null, $checkout_at = null, $expected_checkin = null, $note = null, $name = null, $location = null, bool $signInPlace = false)
{
if (! $target) {
return false;
@@ -535,7 +560,7 @@ class Asset extends Depreciable
} else {
$checkedOutBy = auth()->user();
}
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues));
event(new CheckoutableCheckedOut($this, $target, $checkedOutBy, $note, $originalValues, 1, $signInPlace));
$this->increment('checkout_counter', 1);
@@ -613,6 +638,16 @@ class Asset extends Depreciable
}
public function manufacturer()
{
return $this->hasOneThrough(Manufacturer::class, AssetModel::class, 'id', 'id', 'model_id', 'manufacturer_id');
}
public function category()
{
return $this->hasOneThrough(Category::class, AssetModel::class, 'id', 'id', 'model_id', 'category_id');
}
/**
* Establishes the asset -> depreciation relationship
*
@@ -638,7 +673,8 @@ class Asset extends Depreciable
*/
public function components()
{
return $this->belongsToMany('\App\Models\Component', 'components_assets', 'asset_id', 'component_id')->withPivot('id', 'assigned_qty', 'created_at', 'note');
return $this->belongsToMany('\App\Models\Component', 'components_assets', 'asset_id', 'component_id')
->withPivot('id', 'assigned_qty', 'created_at', 'note', 'created_by');
}
/**
@@ -725,9 +761,25 @@ class Asset extends Depreciable
*/
public function assignedAccessories()
{
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to');
return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->with('accessory');
}
public function accessories()
{
return $this->hasManyThrough(
Accessory::class,
AccessoryCheckout::class,
'assigned_to',
'id',
'id',
'accessory_id'
)->where('assigned_type', self::class);
}
// {
// return $this->morphMany(AccessoryCheckout::class, 'assigned', 'assigned_type', 'assigned_to')->withTrashed();
// }
/**
* Get the asset's location based on the assigned user
*
@@ -747,7 +799,7 @@ class Asset extends Depreciable
$first_asset = $this;
}
if ($iterations > 10) {
throw new \Exception('Asset assignment Loop for Asset ID: '.$first_asset->id);
throw new \Exception('Asset assignment Loop for Asset ID: '.e($first_asset->id));
}
$assigned_to = self::find($this->assigned_to); // have to do this this way because otherwise it errors
if ($assigned_to) {
@@ -948,20 +1000,6 @@ class Asset extends Depreciable
->orderBy('created_at', 'desc');
}
/**
* Get user who created the item
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v1.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Establishes the asset -> status relationship
*
@@ -971,7 +1009,7 @@ class Asset extends Depreciable
*
* @return Relation
*/
public function assetstatus()
public function status()
{
return $this->belongsTo(Statuslabel::class, 'status_id');
}
@@ -1226,12 +1264,44 @@ class Asset extends Depreciable
public function getComponentCost()
{
$cost = 0;
foreach ($this->components as $component) {
$cost += $component->pivot->assigned_qty * $component->purchase_cost;
return (float) $this->components->sum('calculated_purchase_cost');
}
/**
* Return EOL progress percentage (0-100), based on elapsed months since
* purchase date over the configured EOL window.
*/
public function eolProgressPercent(): float
{
if (! $this->purchase_date || ! $this->asset_eol_date) {
return 0.0;
}
return $cost;
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: Carbon::parse($this->asset_eol_date),
);
}
/**
* Return warranty progress percentage (0-100), based on elapsed months
* since purchase date over the warranty window.
*/
public function warrantyProgressPercent(): float
{
if (! $this->purchase_date || ! $this->warranty_expires) {
return 0.0;
}
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: $this->warranty_expires,
);
}
public function getAccessoryCost()
{
return (float) $this->accessories()->sum('purchase_cost');
}
/**
@@ -1321,6 +1391,7 @@ class Asset extends Depreciable
/**
* Run additional, advanced searches.
* This overrides the advancedTextSearch method on the Searchable model trait to add searching of assigned user, location, and assets.
*
* @param array $terms The search terms
* @return Builder
@@ -1408,7 +1479,7 @@ class Asset extends Depreciable
public function scopePending($query)
{
return $query->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 1)
->where('archived', '=', 0);
@@ -1465,7 +1536,7 @@ class Asset extends Depreciable
{
return $query->whereNull('assets.assigned_to')
->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('deployable', '=', 1)
->where('pending', '=', 0)
->where('archived', '=', 0);
@@ -1482,7 +1553,7 @@ class Asset extends Depreciable
public function scopeUndeployable($query)
{
return $query->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 0)
->where('archived', '=', 0);
@@ -1499,7 +1570,7 @@ class Asset extends Depreciable
public function scopeNotArchived($query)
{
return $query->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('archived', '=', 0);
}
);
@@ -1668,7 +1739,7 @@ class Asset extends Depreciable
if (Setting::getSettings()->show_archived_in_list != 1) {
return $query->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('archived', '=', 0);
}
);
@@ -1687,7 +1758,7 @@ class Asset extends Depreciable
public function scopeArchived($query)
{
return $query->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where('deployable', '=', 0)
->where('pending', '=', 0)
->where('archived', '=', 1);
@@ -1718,7 +1789,7 @@ class Asset extends Depreciable
return Company::scopeCompanyables($query->where($table.'.requestable', '=', 1))
->whereHas(
'assetstatus', function ($query) {
'status', function ($query) {
$query->where(
function ($query) {
$query->where('deployable', '=', 1)
@@ -1885,235 +1956,6 @@ class Asset extends Depreciable
)->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug
}
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param \Illuminate\Database\Query\Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return \Illuminate\Database\Query\Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $key => $search_val) {
$fieldname = str_replace('custom_fields.', '', $key);
if ($fieldname == 'asset_tag') {
$query->where('assets.asset_tag', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'name') {
$query->where('assets.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('assets.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_date') {
$query->where('assets.purchase_date', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('assets.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('assets.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('assets.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'status_label') {
$query->whereHas(
'assetstatus', function ($query) use ($search_val) {
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'rtd_location') {
$query->whereHas(
'defaultLoc', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'assigned_to') {
$query->whereHasMorph(
'assignedTo', [User::class], function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('users.first_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.last_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.display_name', 'LIKE', '%'.$search_val.'%')
->orWhere('users.username', 'LIKE', '%'.$search_val.'%');
}
);
}
)->orWhereHasMorph(
'assignedTo', [Location::class], function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
)->orWhereHasMorph(
'assignedTo', [Asset::class], function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
// Don't use the asset table prefix here because it will pull from the original asset,
// not the subselect we're doing here to get the assigned asset
$query->where('name', 'LIKE', '%'.$search_val.'%')
->orWhere('asset_tag', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where(
function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%')
->orWhere('models.name', 'LIKE', '%'.$search_val.'%')
->orWhere('models.model_number', 'LIKE', '%'.$search_val.'%');
}
);
}
);
}
);
}
if ($fieldname == 'model') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'model_number') {
$query->whereHas(
'model', function ($query) use ($search_val) {
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'status_label') {
$query->whereHas(
'assetstatus', function ($query) use ($search_val) {
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'jobtitle') {
$query->where(function ($query) use ($search_val) {
if (is_array($search_val)) {
$query->whereHasMorph(
'assignedTo',
[User::class],
function ($query) use ($search_val) {
$query->whereIn('users.jobtitle', $search_val);
}
);
} else {
$query->whereHasMorph(
'assignedTo',
[User::class],
function ($query) use ($search_val) {
$query->where(function ($query) use ($search_val) {
$query->where('users.jobtitle', 'LIKE', '%'.$search_val.'%');
});
}
);
}
});
}
/**
* THIS CLUNKY BIT IS VERY IMPORTANT
*
* Although inelegant, this section matters a lot when querying against fields that do not
* exist on the asset table. There's probably a better way to do this moving forward, for
* example using the Schema:: methods to determine whether or not a column actually exists,
* or even just using the $searchableRelations variable earlier in this file.
*
* In short, this set of statements tells the query builder to ONLY query against an
* actual field that's being passed if it doesn't meet known relational fields. This
* allows us to query custom fields directly in the assets table
* (regardless of their name) and *skip* any fields that we already know can only be
* searched through relational searches that we do earlier in this method.
*
* For example, we do not store "location" as a field on the assets table, we store
* that relationship through location_id on the assets table, therefore querying
* assets.location would fail, as that field doesn't exist -- plus we're already searching
* against those relationships earlier in this method.
*
* - snipe
*/
if (($fieldname != 'category') && ($fieldname != 'model_number') && ($fieldname != 'rtd_location') && ($fieldname != 'location') && ($fieldname != 'supplier')
&& ($fieldname != 'status_label') && ($fieldname != 'assigned_to') && ($fieldname != 'model') && ($fieldname != 'jobtitle') && ($fieldname != 'company') && ($fieldname != 'manufacturer')
) {
$query->where('assets.'.$fieldname, 'LIKE', '%'.$search_val.'%');
}
}
}
);
}
/**
* Query builder scope to order on model
*
@@ -2324,4 +2166,16 @@ class Asset extends Depreciable
->join('depreciations', 'models.depreciation_id', '=', 'depreciations.id')->where('models.depreciation_id', '=', $search);
}
/**
* Determines if the asset has an orphaned assignment where the assigned target no longer exists.
* This occurs when:
* 1. assigned_to is set but assigned_type is missing/null
* 2. assigned_to and assigned_type are both set, but the relationship cannot be resolved (target was hard-deleted)
*/
public function hasOrphanedAssignment(): bool
{
return ($this->assigned_to && ! $this->assigned_type)
|| ($this->assigned_to && $this->assigned_type && ! $this->assignedTo);
}
}
+28 -64
View File
@@ -85,10 +85,12 @@ class AssetModel extends SnipeModel
* @var array
*/
protected $searchableAttributes = [
'name',
'model_number',
'notes',
'created_at',
'eol',
'min_amt',
'model_number',
'name',
'notes',
];
/**
@@ -100,6 +102,20 @@ class AssetModel extends SnipeModel
'depreciation' => ['name'],
'category' => ['name'],
'manufacturer' => ['name'],
'fieldset' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* Computed aliases (withCount/withSum) that can be searched via TextSearch filters.
*
* @var array
*/
protected $searchableCounts = [
'assets_count',
'remaining',
'assets_assigned_count',
'assets_archived_count',
];
protected static function booted(): void
@@ -142,6 +158,15 @@ class AssetModel extends SnipeModel
return $this->hasMany(Asset::class, 'model_id')->Archived();
}
public function percentRemaining()
{
if ($this->availableAssets()->count() == 0) {
return 0;
}
return $this->availableAssets()->count() / $this->assets()->count() * 100;
}
/**
* Establishes the model -> category relationship
*
@@ -253,73 +278,12 @@ class AssetModel extends SnipeModel
&& ($this->deleted_at == '');
}
/**
* Get user who created the item
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v1.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* -----------------------------------------------
* BEGIN QUERY SCOPES
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('models.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* scopeInCategory
* Get all models that are in the array of category ids
+18 -34
View File
@@ -89,14 +89,30 @@ class Category extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'category_type', 'notes'];
protected $searchableAttributes = [
'name',
'category_type',
'notes',
'eula_text',
'created_at',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
protected $searchableCounts = [
'accessories_count',
'consumables_count',
'components_count',
'licenses_count',
'models_count',
];
/**
* Checks if category can be deleted
@@ -263,11 +279,6 @@ class Category extends SnipeModel
return $this->hasMany(AssetModel::class, 'category_id');
}
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Checks for a category-specific EULA, and if that doesn't exist,
* checks for a settings level EULA
@@ -315,33 +326,6 @@ class Category extends SnipeModel
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'category_type') {
$query->where('categories.category_type', 'LIKE', '%'.$search_val.'%');
}
}
}
);
}
/**
* Query builder scope for whether or not the category requires acceptance
*
+66 -4
View File
@@ -23,6 +23,21 @@ class CheckoutAcceptance extends Model
'alert_on_response_id' => 'integer',
];
protected $fillable = [
'assigned_to_id',
'checkoutable_type',
'checkoutable_id',
'accepted_at',
'declined_at',
'note',
'signature_filename',
'stored_eula',
'stored_eula_file',
'qty',
'signed_in_place',
'signed_in_place_admin',
];
/**
* Get the mail recipient from the config
*
@@ -112,7 +127,7 @@ class CheckoutAcceptance extends Model
*/
public function isCheckedOutTo(User $user)
{
return $this->assignedTo?->is($user);
return $this->assigned_to_id === $user->id;
}
/**
@@ -121,20 +136,27 @@ class CheckoutAcceptance extends Model
* checkout_acceptances table or you'll get an error.
*
* @param string $signature_filename
* @param string|null $eula
* @param string|null $filename
* @param string|null $note
* @param int|null $qty
*/
public function accept($signature_filename, $eula = null, $filename = null, $note = null)
public function accept($signature_filename, $eula = null, $filename = null, $note = null, $qty = null)
{
$this->accepted_at = now();
$this->signature_filename = $signature_filename;
$this->stored_eula = $eula;
$this->stored_eula_file = $filename;
$this->note = $note;
if ($qty !== null) {
$this->qty = $qty;
}
$this->save();
/**
* Update state for the checked out item
*/
$this->checkoutable->acceptedCheckout($this->assignedTo, $signature_filename, $filename);
$this->checkoutable->acceptedCheckout($this->assignedTo, $qty, $note, $signature_filename, $filename);
}
/**
@@ -208,7 +230,7 @@ class CheckoutAcceptance extends Model
$pdf->SetAuthor($data['assigned_to']);
$pdf->SetTitle('Asset Acceptance: '.$data['item_tag']);
$pdf->SetSubject('Asset Acceptance: '.$data['item_tag']);
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos');
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos,'.$data['item_tag'] ?? null.', '.$data['item_name'] ?? null.', '.$data['assigned_to'] ?? null);
$pdf->SetFont('dejavusans', '', 8, '', true);
$pdf->SetPrintHeader(false);
$pdf->SetPrintFooter(false);
@@ -243,6 +265,17 @@ class CheckoutAcceptance extends Model
if ($data['item_serial'] != null) {
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
}
// Render custom fields if present
if (! empty($data['custom_fields']) && is_array($data['custom_fields'])) {
foreach ($data['custom_fields'] as $customField) {
$label = $customField['label'] ?? '';
$value = $customField['value'] ?? '';
if ($label !== '' && $value !== '') {
$pdf->writeHTML(e($label).': '.e($value), true, 0, true, 0, '');
}
}
}
if (($data['qty'] != null) && ($data['qty'] > 1)) {
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
}
@@ -250,6 +283,35 @@ class CheckoutAcceptance extends Model
if ($data['email'] != null) {
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
}
// Add assigning user if present
if (! empty($data['assigning_user'])) {
$assigningUser = $data['assigning_user'];
$assigningUserLine = trans('general.assigned_by').': '.e($assigningUser['name'] ?? $assigningUser['email'] ?? '');
if (! empty($assigningUser['employee_num'])) {
$assigningUserLine .= ' ('.e($assigningUser['employee_num']).')';
}
$pdf->writeHTML($assigningUserLine, true, 0, true, 0, '');
}
// Add signed in place row (always show)
$signedInPlace = ! empty($data['signed_in_place']) && filter_var($data['signed_in_place'], FILTER_VALIDATE_BOOLEAN);
$pdf->writeHTML(trans('general.signed_in_place').': '.($signedInPlace ? trans('general.yes') : trans('general.no')), true, 0, true, 0, '');
// If signed in place, show admin info
if ($signedInPlace && ! empty($data['signed_in_place_admin'])) {
$admin = $data['signed_in_place_admin'];
$adminName = $admin['name'] ?? '';
$adminUsername = $admin['username'] ?? '';
$adminEmail = $admin['email'] ?? '';
$adminDetails = $adminName;
if (! empty($adminUsername)) {
$adminDetails .= ' ('.$adminUsername.')';
}
if (! empty($adminEmail)) {
$adminDetails .= ' <'.$adminEmail.'>';
}
$adminLine = trans('general.signed_in_place_admin', ['admin' => $adminDetails]);
$pdf->writeHTML($adminLine, true, 0, true, 0, '');
}
$pdf->Ln();
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
+18 -7
View File
@@ -3,10 +3,13 @@
namespace App\Models;
use App\Models\Traits\CompanyableTrait;
use App\Models\Traits\HasUploads;
use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\CompanyPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Facades\Log;
@@ -22,6 +25,9 @@ final class Company extends SnipeModel
{
use CompanyableTrait;
use HasFactory;
use HasUploads;
use Loggable;
use SoftDeletes;
protected $table = 'companies';
@@ -54,14 +60,24 @@ final class Company extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'phone', 'fax', 'email', 'created_at', 'updated_at'];
protected $searchableAttributes = [
'name',
'phone',
'fax',
'email',
'created_at',
'updated_at',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* The attributes that are mass assignable.
@@ -311,11 +327,6 @@ final class Company extends SnipeModel
}
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* I legit do not know what this method does, but we can't remove it (yet).
*
+32 -100
View File
@@ -8,6 +8,7 @@ use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\ComponentPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\Relation;
@@ -115,6 +116,7 @@ class Component extends SnipeModel
'location' => ['name'],
'supplier' => ['name'],
'manufacturer' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public static function booted()
@@ -164,20 +166,24 @@ class Component extends SnipeModel
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
}
/**
* Establishes the component -> admin user relationship
*
* @todo this is probably not needed - refactor
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return Relation
*/
public function adminuser()
protected function calculatedPurchaseCost(): Attribute
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
return Attribute::make(
get: function ($value) {
$unitPurchaseCost = $this->getRawOriginal('purchase_cost');
$assignedQty = $this->pivot?->assigned_qty;
if ($unitPurchaseCost === null) {
return $assignedQty !== null ? 0.0 : null;
}
if ($assignedQty !== null) {
return (float) $unitPurchaseCost * (int) $assignedQty;
}
return (float) $unitPurchaseCost;
}
);
}
/**
@@ -317,6 +323,19 @@ class Component extends SnipeModel
}
public function percentRemaining()
{
$totalQuantity = (int) $this->qty;
if ($totalQuantity <= 0) {
return 0;
}
$availableQuantity = max(0, min($this->numRemaining(), $totalQuantity));
return ($availableQuantity / $totalQuantity) * 100;
}
/**
* Determine whether to send a checkin/checkout email based on
* asset model category
@@ -397,93 +416,6 @@ class Component extends SnipeModel
* -----------------------------------------------
**/
/**
* Query builder scope to search on text filters for complex Bootstrap Tables API
*
* @param Builder $query Query builder instance
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('components.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('components.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('components.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('components.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('components.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on company
*
+79
View File
@@ -0,0 +1,79 @@
<?php
namespace App\Models;
use App\Models\Traits\Searchable;
use App\Presenters\ComponentPresenter;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
/**
* Model for Accessories.
*
* @version v1.0
*/
class ComponentAssignment extends Model
{
use HasFactory;
use Searchable;
protected $fillable = [
'accessory_id',
'assigned_to',
'assigned_type',
'note',
];
protected $presenter = ComponentPresenter::class;
protected $table = 'components_assets';
/**
* Establishes the accessory checkout -> accessory relationship
*
* @author [A. Kroeger]
*
* @since [v7.0.9]
*
* @return Relation
*/
public function component()
{
return $this->belongsTo(Component::class)->withTrashed();
}
public function components()
{
return $this->hasMany(Component::class, 'id', 'component_id')->withTrashed();
}
public function assets()
{
return $this->hasMany(Asset::class, 'id', 'asset_id')->withTrashed();
}
/**
* Establishes the accessory checkout -> user relationship
*
* @author [A. Kroeger]
*
* @since [v7.0.9]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
public function scopeOrderByCreatedByName($query, $order)
{
return $query->leftJoin('users as checkout_users_sort', 'components_assets.created_by', '=', 'checkout_users_sort.id')->select('components_assets.*')->orderBy('checkout_users_sort.first_name', $order)->orderBy('checkout_users_sort.last_name', $order);
}
public function scopeOrderByComponentName($query, $order)
{
return $query->leftJoin('components as component_sort', 'components_assets.id', '=', 'component_sort.id')->select('components_assets.*')->orderBy('component_sort.name', $order);
}
}
+102 -94
View File
@@ -96,7 +96,15 @@ class Consumable extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'order_number', 'purchase_cost', 'purchase_date', 'item_no', 'model_number', 'notes'];
protected $searchableAttributes = [
'name',
'order_number',
'purchase_cost',
'purchase_date',
'item_no',
'model_number',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
@@ -109,6 +117,7 @@ class Consumable extends SnipeModel
'location' => ['name'],
'manufacturer' => ['name'],
'supplier' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
@@ -141,20 +150,6 @@ class Consumable extends SnipeModel
&& ($this->deleted_at == '');
}
/**
* Establishes the consumable -> admin user relationship
*
* @author [A. Gianotto] [<snipe@snipe.net>]
*
* @since [v3.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Establishes the component -> assignments relationship
*
@@ -169,6 +164,19 @@ class Consumable extends SnipeModel
return $this->hasMany(ConsumableAssignment::class);
}
public function percentRemaining()
{
if ($this->consumables_users_count == 0) {
return 100;
}
if (($this->qty == '') || ($this->qty == 0)) {
return 0;
}
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
}
/**
* Establishes the component -> company relationship
*
@@ -408,85 +416,6 @@ class Consumable extends SnipeModel
* @param text $filter JSON array of search keys and terms
* @return Builder Modified query builder
*/
public function scopeByFilter($query, $filter)
{
return $query->where(
function ($query) use ($filter) {
foreach ($filter as $fieldname => $search_val) {
if ($fieldname == 'name') {
$query->where('consumables.name', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'notes') {
$query->where('consumables.notes', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'model_number') {
$query->where('consumables.model_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'order_number') {
$query->where('consumables.order_number', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'item_no') {
$query->where('consumables.item_no', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'serial') {
$query->where('consumables.serial', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'purchase_cost') {
$query->where('consumables.purchase_cost', 'LIKE', '%'.$search_val.'%');
}
if ($fieldname == 'location') {
$query->whereHas(
'location', function ($query) use ($search_val) {
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'manufacturer') {
$query->whereHas(
'manufacturer', function ($query) use ($search_val) {
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'supplier') {
$query->whereHas(
'supplier', function ($query) use ($search_val) {
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'category') {
$query->whereHas(
'category', function ($query) use ($search_val) {
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
}
);
}
if ($fieldname == 'company') {
$query->whereHas(
'company', function ($query) use ($search_val) {
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
}
);
}
}
}
);
}
/**
* Query builder scope to order on company
@@ -566,4 +495,83 @@ class Consumable extends SnipeModel
{
return $query->leftJoin('users as users_sort', 'consumables.created_by', '=', 'users_sort.id')->select('consumables.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order);
}
/**
* Handle logic after a consumable checkout is accepted by the user.
*
* @param string|null $signature
* @param string|null $filename
*/
public function acceptedCheckout(User $acceptedBy, ?int $qty = null, ?string $note = null, $signature = null, $filename = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $acceptedBy->getAssignedItemsWithPendingAcceptance()
->where('item_id', $this->id)
->where('qty', $qty)
->where('item_type', self::class)
->whereNull('declined_at')
->sortByDesc('created_at')
->first();
if ($acceptance) {
if ($qty !== null) {
$acceptance->qty = $qty;
}
if ($note !== null) {
$acceptance->note = $note;
}
$acceptance->save();
}
// Attach the consumable to the user if not already attached
$pivot = $acceptedBy->consumables()->where('consumable_id', $this->id)->first();
if (! $pivot) {
$acceptedBy->consumables()->attach($this->id, [
'created_by' => $acceptance?->created_by ?? null,
]);
}
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Handle logic after a consumable checkout is declined by the user.
*
* @param string|null $signature
*/
public function declinedCheckout(User $declinedBy, $signature = null): void
{
// Find the pending acceptance for this user and consumable
$acceptance = $declinedBy->acceptances()
->where('item_id', $this->id)
->where('item_type', self::class)
->whereNull('accepted_at')
->latest('created_at')
->first();
$qty = $acceptance?->qty ?? 1;
$note = $acceptance?->note;
// Detach the consumable from the user (if present)
$declinedBy->consumables()->detach($this->id);
// Logging handled by event listener; do not log here to avoid duplicates.
}
/**
* Log an acceptance or decline action for this consumable.
*/
protected function logActionAcceptance(string $actionType, User $user, int $qty, ?string $note = null): void
{
$this->assetlog()->create([
'action_type' => $actionType,
'target_id' => $user->id,
'target_type' => User::class,
'item_id' => $this->id,
'item_type' => self::class,
'quantity' => $qty,
'note' => $note,
'created_by' => auth()->id() ?? $user->id,
]);
}
}
+25
View File
@@ -57,6 +57,7 @@ class CustomField extends Model
'show_in_listview' => 'boolean',
'show_in_requestable_list' => 'boolean',
'show_in_email' => 'boolean',
'format' => 'nullable|string|max:191',
];
protected $casts = [
@@ -87,6 +88,30 @@ class CustomField extends Model
'show_in_requestable_list',
];
/**
* The attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableAttributes = [
'name',
'format',
'element',
'db_column',
'help_text',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [
'fieldset' => ['name'],
'assetModels' => ['name'],
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
/**
* This is confusing, since it's actually the custom fields table that
* we're usually modifying, but since we alter the assets table, we have to
+15 -2
View File
@@ -4,11 +4,14 @@ namespace App\Models;
use App\Http\Traits\UniqueUndeletedTrait;
use App\Models\Traits\CompanyableTrait;
use App\Models\Traits\HasUploads;
use App\Models\Traits\Loggable;
use App\Models\Traits\Searchable;
use App\Presenters\DepartmentPresenter;
use App\Presenters\Presentable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Database\Query\Builder;
use Illuminate\Support\Facades\Gate;
use Watson\Validating\ValidatingTrait;
@@ -17,6 +20,9 @@ class Department extends SnipeModel
{
use CompanyableTrait;
use HasFactory;
use HasUploads;
use Loggable;
use SoftDeletes;
/**
* Whether the model should inject it's identifier to the unique
@@ -71,14 +77,21 @@ class Department extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'notes', 'phone', 'fax'];
protected $searchableAttributes = [
'name',
'notes',
'phone',
'fax',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
+35
View File
@@ -2,6 +2,8 @@
namespace App\Models;
use Carbon\Carbon;
class Depreciable extends SnipeModel
{
/**
@@ -187,6 +189,39 @@ class Depreciable extends SnipeModel
}
/**
* Return depreciation progress percentage (0-100), based on elapsed months
* since purchase date over the depreciation window.
*/
public function depreciationProgressPercent(): float
{
if (! $this->purchase_date || ! $this->depreciated_date()) {
return 0.0;
}
return $this->calculateProgressPercent(
start: Carbon::parse($this->purchase_date),
end: Carbon::instance($this->depreciated_date()),
);
}
/**
* Calculate elapsed/total month percentage and clamp to 0-100.
*/
protected function calculateProgressPercent(Carbon $start, Carbon $end): float
{
$totalMonths = (float) $start->diffInMonths($end);
if ($totalMonths <= 0) {
return 0.0;
}
$elapsedMonths = (float) $start->diffInMonths(Carbon::now());
$rawPercent = ($elapsedMonths / $totalMonths) * 100;
return (float) min(100, max(0, $rawPercent));
}
// it's necessary for unit tests
protected function getDateTime($time = null)
{
+11 -3
View File
@@ -40,7 +40,10 @@ class Depreciation extends SnipeModel
*
* @var array
*/
protected $fillable = ['name', 'months'];
protected $fillable = [
'name',
'months',
];
use Searchable;
@@ -49,14 +52,19 @@ class Depreciation extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'months'];
protected $searchableAttributes = [
'name',
'months',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
+9 -17
View File
@@ -35,6 +35,7 @@ class Group extends SnipeModel
* @var bool
*/
protected $injectUniqueIdentifier = true;
protected $presenter = GroupPresenter::class;
use Searchable;
@@ -45,15 +46,20 @@ class Group extends SnipeModel
*
* @var array
*/
protected $searchableAttributes = ['name', 'created_at', 'notes'];
protected $searchableAttributes = [
'name',
'created_at',
'notes',
];
/**
* The relations and their attributes that should be included when searching the model.
*
* @var array
*/
protected $searchableRelations = [];
protected $searchableRelations = [
'adminuser' => ['first_name', 'last_name', 'display_name'],
];
public function isDeletable()
{
@@ -75,20 +81,6 @@ class Group extends SnipeModel
return $this->belongsToMany(User::class, 'users_groups');
}
/**
* Get the user that created the group
*
* @author A. Gianotto <snipe@snipe.net>
*
* @since [v6.3.0]
*
* @return Relation
*/
public function adminuser()
{
return $this->belongsTo(User::class, 'created_by')->withTrashed();
}
/**
* Decode JSON permissions into array
*
+15 -11
View File
@@ -68,17 +68,8 @@ class DefaultLabel extends RectangleSheet
$usableWidth = $this->pageWidth - $this->pageMarginLeft - $this->pageMarginRight;
$usableHeight = $this->pageHeight - $this->pageMarginTop - $this->pageMarginBottom;
$this->columns = ($usableWidth + $this->labelSpacingH) / ($this->labelWidth + $this->labelSpacingH);
$this->rows = ($usableHeight + $this->labelSpacingV) / ($this->labelHeight + $this->labelSpacingV);
// Make sure the columns and rows are never zero, since that scenario should never happen
if ($this->columns == 0) {
$this->columns = 1;
}
if ($this->rows == 0) {
$this->rows = 1;
}
$this->columns = $this->calculateGridCount($usableWidth, $this->labelWidth, $this->labelSpacingH);
$this->rows = $this->calculateGridCount($usableHeight, $this->labelHeight, $this->labelSpacingV);
}
@@ -299,4 +290,17 @@ class DefaultLabel extends RectangleSheet
return $labelHeight;
}
private function calculateGridCount(float $usableSize, float $labelSize, float $spacing): int
{
$denominator = $labelSize + $spacing;
if ($denominator <= 0.0) {
return 1;
}
$count = (int) floor(($usableSize + $spacing) / $denominator);
return max(1, $count);
}
}
+11 -8
View File
@@ -133,18 +133,21 @@ class TZe_24mm_E extends TZe_24mm
);
foreach ($fields as $field) {
static::writeText(
$pdf, $field['label'],
$currentX, $currentY,
'freesans', '', $field_layout['labelSize'], 'L',
$field_layout['labelWidth'], $field_layout['rowAdvance'], true, 0
);
$hasLabel = is_string($field['label'] ?? null) && trim($field['label']) !== '';
if ($hasLabel) {
static::writeText(
$pdf, $field['label'],
$currentX, $currentY,
'freesans', '', $field_layout['labelSize'], 'L',
$field_layout['labelWidth'], $field_layout['rowAdvance'], true, 0
);
}
static::writeText(
$pdf, $field['value'],
$field_layout['valueX'], $currentY,
$hasLabel ? $field_layout['valueX'] : $field_layout['fullValueX'], $currentY,
'freemono', 'B', $field_layout['fieldSize'], 'L',
$field_layout['valueWidth'], $field_layout['rowAdvance'], true, 0, 0.01
$hasLabel ? $field_layout['valueWidth'] : $field_layout['fullValueWidth'], $field_layout['rowAdvance'], true, 0, 0.01
);
$currentY += $field_layout['rowAdvance'];
}

Some files were not shown because too many files have changed in this diff Show More