Compare commits
483 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 240196c3a1 | |||
| 409ce69e32 | |||
| 0bdd3034d1 | |||
| f04a41408d | |||
| 15effb1974 | |||
| ccd60eb6d0 | |||
| 2f54f5b051 | |||
| 509584d1ff | |||
| 95ac268046 | |||
| f4b9736862 | |||
| a0bf7a018c | |||
| ac4975e1d1 | |||
| c7c3b04c5f | |||
| 88f87db4fd | |||
| ad0199f662 | |||
| 75a276d9fa | |||
| ead0047629 | |||
| 46f6766bb4 | |||
| 8a470d3ef9 | |||
| 5506245959 | |||
| 0fc175581a | |||
| cd9005f82b | |||
| 24237d4259 | |||
| b2d707aaab | |||
| a432f23692 | |||
| a63b9ec627 | |||
| a35820d612 | |||
| ecf8ce3ec1 | |||
| 1908beb671 | |||
| 1872c6eed9 | |||
| 53199b9737 | |||
| 73861c6a04 | |||
| e2969dd3e2 | |||
| c5296fd76d | |||
| 3cb3284b26 | |||
| d5d0d00ecc | |||
| 5db9d67e65 | |||
| f64dfa7f92 | |||
| 06584d17a6 | |||
| 75cb1041ec | |||
| b61ed66d9d | |||
| 48ebd7faf5 | |||
| d6de3baa6e | |||
| 1be44a4c05 | |||
| f17f34f730 | |||
| da5bb6126a | |||
| a5d04d2e65 | |||
| 22d07214fe | |||
| 8d4523d250 | |||
| 37a3d694d4 | |||
| d21ccdfcbf | |||
| 4eba97d388 | |||
| 613137551a | |||
| 23f941c810 | |||
| e7312801ac | |||
| cd69a7ea53 | |||
| 13ffdda12e | |||
| 372e74aad3 | |||
| 2a32f7d372 | |||
| a2d34cca76 | |||
| c513ed5fc3 | |||
| 34cd5dcf7c | |||
| 16e981d99d | |||
| 16eb899ba7 | |||
| 3367f8e5c7 | |||
| ad635ab95c | |||
| b94e7fd8a0 | |||
| 683fbd7953 | |||
| 246ec9e20b | |||
| 81d669d62a | |||
| 9ff951d379 | |||
| e327303b3c | |||
| 1c5d81cb04 | |||
| d37f43daba | |||
| dbabd1bab3 | |||
| 6b5398139a | |||
| 0ec45a4fd0 | |||
| b99fd237f3 | |||
| 5d7123eb05 | |||
| 7eb6ebb60d | |||
| 5bc273686e | |||
| 37a37318aa | |||
| 74e831c4f0 | |||
| be36390b0f | |||
| cf44119bc6 | |||
| faa2adbde2 | |||
| 7fae60d5c3 | |||
| 3cad34821e | |||
| 1e4353f0db | |||
| 7520a1b2a3 | |||
| cdd91e498a | |||
| 2daf0458a7 | |||
| 6299fc09bf | |||
| 2327cc6866 | |||
| b235df0bbf | |||
| 6d56ab9b63 | |||
| ce5de8fe06 | |||
| ce3f80246e | |||
| 046ef82c65 | |||
| 0f347e8453 | |||
| 1cb0ca84ab | |||
| 7625646c11 | |||
| 324530fb8c | |||
| 68acf7b90a | |||
| 9c610f51af | |||
| 40ec0627c4 | |||
| 645e66b30c | |||
| 2311d56836 | |||
| 1f3481c54b | |||
| 07fa51aa4c | |||
| 0866469cc0 | |||
| 3d9bb29b1b | |||
| 5a67bcaf17 | |||
| 01b18513f1 | |||
| d92ec582fa | |||
| 205eb7fd37 | |||
| 0798e62417 | |||
| 83adcc61bc | |||
| 788e07947f | |||
| 83fec75bc8 | |||
| 53c240f13f | |||
| f142eb7a44 | |||
| 495382c42f | |||
| 029634707b | |||
| fd5736fac4 | |||
| f3ed2d9dd8 | |||
| 676cd66e4b | |||
| 17c73c4017 | |||
| 5983a4530f | |||
| 1cd8395b23 | |||
| ea4374a855 | |||
| 061b913413 | |||
| e79af0163a | |||
| 91e41049bd | |||
| 18f67bcce5 | |||
| 264347e323 | |||
| 18d9f7dbf1 | |||
| 702af91c84 | |||
| e9db3b3861 | |||
| 896922fde5 | |||
| 7c2bb69bc9 | |||
| d2921346a2 | |||
| cc06b2f0eb | |||
| d98c7bddba | |||
| fe308ef2e4 | |||
| 9b43835e2d | |||
| 019f0af282 | |||
| c6619a621c | |||
| 565e3de183 | |||
| 1a852cdacf | |||
| 4ea527d980 | |||
| 392af4f127 | |||
| 1c198500c6 | |||
| 8620f25c0e | |||
| 8a59809937 | |||
| bac8299ea6 | |||
| 500dcdd582 | |||
| cbae494c54 | |||
| 09bec66406 | |||
| 2b2291dc7e | |||
| 1357b45e24 | |||
| 6935cf1dde | |||
| cf384373df | |||
| 48c4f34af3 | |||
| 7b800152ee | |||
| f289691e22 | |||
| 50421494c5 | |||
| 33b8861ae3 | |||
| 31f90d20f8 | |||
| 87e65893d3 | |||
| 405540aea2 | |||
| ccfebee5f1 | |||
| 1d9469a3df | |||
| 51ea1327cf | |||
| a88ad35b68 | |||
| 6e60f59265 | |||
| a866bfafcd | |||
| 97d1677568 | |||
| f4562db0c0 | |||
| a616da3e5c | |||
| a895566b02 | |||
| 5d75765aae | |||
| 3bc34fcd5e | |||
| 99c3ac56e9 | |||
| 371f0b82f6 | |||
| 114b5d3db0 | |||
| 9ab0f60b41 | |||
| 33b8226ebe | |||
| e062062cb3 | |||
| 5f713862fb | |||
| fe013b5ea0 | |||
| 57df2dc2cf | |||
| c86fa4c521 | |||
| c90acf53d5 | |||
| fece4d2fdc | |||
| f49837e5fe | |||
| e6ead7c6fa | |||
| 3e84af83d8 | |||
| aff375c799 | |||
| 6ada1a646f | |||
| 42bfd24a8f | |||
| c7c6b41dab | |||
| 449e6b5f5c | |||
| bbe0c7409f | |||
| 41bb9c378b | |||
| 9a82b890d4 | |||
| 2d2180c9e8 | |||
| 798a590c2a | |||
| 8a08878062 | |||
| fde6ff1571 | |||
| 8c9a48b38a | |||
| 45fffd74b7 | |||
| a0cf0751de | |||
| 7485cb81aa | |||
| 7faa9a6fdf | |||
| f6f7063419 | |||
| 1300fff94c | |||
| 5ef9798c68 | |||
| db48c18766 | |||
| 880261500b | |||
| e02c257df6 | |||
| 5898205480 | |||
| 50676288a1 | |||
| db2269092a | |||
| b3f56900e5 | |||
| f1820b739f | |||
| 56957e28f9 | |||
| 3c32721791 | |||
| 2632433cc6 | |||
| 16ea577099 | |||
| 7456c9dce5 | |||
| f9e16e16d1 | |||
| b42094a1be | |||
| 4c343afec7 | |||
| 40b3007676 | |||
| 48395d162a | |||
| 50aaa54c27 | |||
| 47737b082b | |||
| c4a3c71448 | |||
| 9939849e40 | |||
| d690989b58 | |||
| d9deb0f30c | |||
| 53ce14dddf | |||
| 1d0be6261b | |||
| 108c6eda1d | |||
| 6e33bfaf8f | |||
| a7bc9f0ae9 | |||
| 927e0a4e7b | |||
| 75b2ac9d33 | |||
| b0d7ae6f04 | |||
| c764605d07 | |||
| 205cf3cf28 | |||
| ea274f0df0 | |||
| 31541c4a56 | |||
| 2a601ae483 | |||
| 3fe8600a70 | |||
| dbd7df2b85 | |||
| 717deb544e | |||
| 51446a5fe0 | |||
| 4c4ec3eacc | |||
| 71b72eae10 | |||
| 01eb585e59 | |||
| 2343841aa1 | |||
| b2790d98d0 | |||
| b14e925158 | |||
| 18ef770a85 | |||
| ee831c9361 | |||
| e446dc1cba | |||
| af283c7e01 | |||
| 4703a8b021 | |||
| eb3a608e80 | |||
| 6fbd189553 | |||
| 753e2790ac | |||
| 2c2de8719b | |||
| 1e884bf627 | |||
| 7a001c81ea | |||
| 9e82f3ffd9 | |||
| e1dc605657 | |||
| bca93b57ec | |||
| d929c87bbd | |||
| 72eb4d6d4d | |||
| 0ccdeed318 | |||
| 5d6fc9f516 | |||
| 7f0435e3d6 | |||
| 85d7ba73aa | |||
| c6a3afa555 | |||
| 6cc8ec63be | |||
| cf7cb8069b | |||
| e70519c9c2 | |||
| 6617cc3e7e | |||
| 3426a427d8 | |||
| 2e67787d75 | |||
| 70a30a96fa | |||
| 8832596aa0 | |||
| 0cd013191a | |||
| 8f70b299cf | |||
| 2c87a469e3 | |||
| 5ca2ec5534 | |||
| 46caf9d9ec | |||
| 7ac0842bc2 | |||
| de1674d001 | |||
| 37c30a3079 | |||
| ce825d1df2 | |||
| b5c01ab820 | |||
| 8de674c837 | |||
| d289ac7f05 | |||
| 7ec60bc6f2 | |||
| eb78474d1e | |||
| 6dc5a4a27e | |||
| 10c8045351 | |||
| 03b6a54fe8 | |||
| 1140795ab8 | |||
| 0441c07266 | |||
| 64f346a5f0 | |||
| 0ac63a8ac6 | |||
| f5a3d751da | |||
| 985e7d0c7c | |||
| 97a024b3ec | |||
| c501999676 | |||
| 80e7cf0b46 | |||
| b7d2bea3ea | |||
| 2acb38a6a5 | |||
| 8ea7242c38 | |||
| 616a93cb52 | |||
| 7170ea0303 | |||
| 10eb14776b | |||
| a7b43d1879 | |||
| 439f3c9c91 | |||
| e2b8368f40 | |||
| 1243f690c4 | |||
| e2c8d41a58 | |||
| 591bba71d5 | |||
| 7aef0f78b0 | |||
| b6dc0e2a08 | |||
| 1ed7dd0e1e | |||
| 5ecfa0b8d8 | |||
| 3578580956 | |||
| 67457d324c | |||
| 8b4e4aff27 | |||
| eb11c4640b | |||
| 5806fced78 | |||
| ee0e036354 | |||
| af0ec10e78 | |||
| ccbd73259b | |||
| 1a44a11b62 | |||
| 8502a2291b | |||
| 67ccb5e6d9 | |||
| 520a70d2ea | |||
| 3dee30c48e | |||
| f314e12685 | |||
| e3b53c8fa2 | |||
| 4bb5020e0a | |||
| 4f1fa95cf9 | |||
| baeeb8e609 | |||
| f109ca6f1f | |||
| 1714d62762 | |||
| f19ac4d5bb | |||
| c6dbccb463 | |||
| 80ca2a6d21 | |||
| 90c1c8cddd | |||
| 0b6593bdc8 | |||
| 6e8c0e5a14 | |||
| 78c300ea1b | |||
| a3fb492e37 | |||
| 667f50497c | |||
| cb93eda4e2 | |||
| 613b536f97 | |||
| 1ffaa077e6 | |||
| dbc850550f | |||
| 1efe65e6ba | |||
| 6a39db7e47 | |||
| 43841b8b3c | |||
| c33ab9c924 | |||
| 135118de65 | |||
| b42b9e354f | |||
| b59b51b2aa | |||
| 7677b3916d | |||
| b4debacd1a | |||
| d91c26e718 | |||
| 8e64083f06 | |||
| 56580f117a | |||
| 29e994dfd0 | |||
| b833daf943 | |||
| 537e09a0a6 | |||
| f53f55b283 | |||
| 943903d8d6 | |||
| 523920d6d6 | |||
| e39a242a76 | |||
| e3b57b0c2f | |||
| 125a9e4031 | |||
| 340433f418 | |||
| 84bb484761 | |||
| 6beaea8be9 | |||
| cc397f6846 | |||
| 3db77f05e9 | |||
| 6e0dbc94d7 | |||
| f9e620a77f | |||
| 45b7df15c3 | |||
| 920676fbd7 | |||
| 1981c7daef | |||
| bde097a827 | |||
| 531dce4305 | |||
| 7a5842712b | |||
| c4b20a16ce | |||
| 24c3c01851 | |||
| 603aa39e3f | |||
| d3fd535605 | |||
| f7f58ba12d | |||
| 8d7cf50089 | |||
| aead8f6c56 | |||
| 84c42999e4 | |||
| 218190d989 | |||
| 33402f5e0c | |||
| 0ebd103e21 | |||
| 67f5fb72c3 | |||
| 4568180e85 | |||
| 324c937cc4 | |||
| 93ae07cc89 | |||
| 52a9993b0d | |||
| 6145c6cc5a | |||
| 97854ad02d | |||
| 500d6e1f2d | |||
| 20382ea5bf | |||
| f853d25d4f | |||
| 46e7e12cb2 | |||
| ce3a7bb687 | |||
| 17584e4799 | |||
| 1503f90394 | |||
| d2834fcdb9 | |||
| 69d7d6aae2 | |||
| 982766dd77 | |||
| 81cbad52f7 | |||
| f1ef1bc38a | |||
| b696642993 | |||
| e7bb7d3656 | |||
| ffc9e882d7 | |||
| 6e7ff15e78 | |||
| b0c45c7179 | |||
| 0fabc5d88d | |||
| 33ae9f1d5b | |||
| f27aae5e31 | |||
| ff6a6407f5 | |||
| 32a6c8edbe | |||
| f0d1697108 | |||
| 90cb53566c | |||
| 64982d01cf | |||
| b6cad58917 | |||
| 4f9c952dbe | |||
| b9c3c8954f | |||
| 82b6159475 | |||
| dbe998d9cf | |||
| 79907a2770 | |||
| 6f60ef9ec2 | |||
| 581867eefc | |||
| 234855f225 | |||
| 0b8176a730 | |||
| d1be571d4d | |||
| d392439f82 | |||
| f423b88b16 | |||
| 853aed5954 | |||
| 947a149d08 | |||
| 3aec52eab0 | |||
| 8d0fda88b7 | |||
| 91a95dbc66 | |||
| a15adc806b | |||
| f328da37bc | |||
| 3adc8f279b | |||
| 41c75022a9 | |||
| 84924a68b7 | |||
| 5a3a63e0a4 | |||
| 980cc5704f | |||
| 28054a9112 | |||
| 7a312f5868 | |||
| 5ce493180d | |||
| bbdc78a13c | |||
| 43971b9625 | |||
| f27a3a2c61 | |||
| b96d0d55c9 | |||
| f699935f5f | |||
| 8336cf5baa | |||
| d3d90abba7 | |||
| bdaf13da4c | |||
| e92e550e9c |
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+10
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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,6 +1,6 @@
|
||||

|
||||
|
||||
[](https://crowdin.com/project/snipe-it) [](https://hub.docker.com/r/snipe/snipe-it/) [](https://app.codacy.com/gh/grokability/snipe-it/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) [](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
|
||||
[](https://crowdin.com/project/snipe-it) [](https://hub.docker.com/r/snipe/snipe-it/) [](https://github.com/grokability/snipe-it/actions/workflows/tests-mysql.yml)
|
||||
[](#contributing) [](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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
|
||||
@@ -405,8 +405,12 @@ class AccessoriesController extends Controller
|
||||
public function history(Request $request, Accessory $accessory): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $accessory);
|
||||
$history = $accessory->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,8 +343,12 @@ class AssetModelsController extends Controller
|
||||
public function history(Request $request, AssetModel $model): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $model);
|
||||
$history = $model->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,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;
|
||||
@@ -38,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
|
||||
@@ -127,9 +129,9 @@ class AssetsController extends Controller
|
||||
'location',
|
||||
'rtd_location',
|
||||
'category',
|
||||
'status_label',
|
||||
'manufacturer',
|
||||
'supplier',
|
||||
'status',
|
||||
'jobtitle',
|
||||
'assigned_to',
|
||||
'created_by',
|
||||
@@ -155,7 +157,7 @@ class AssetsController extends Controller
|
||||
->with(
|
||||
'model',
|
||||
'location',
|
||||
'assetstatus',
|
||||
'status',
|
||||
'company',
|
||||
'defaultLoc',
|
||||
'assignedTo',
|
||||
@@ -173,17 +175,6 @@ 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()));
|
||||
}
|
||||
}
|
||||
|
||||
// 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'));
|
||||
@@ -229,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;
|
||||
@@ -404,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':
|
||||
@@ -476,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') {
|
||||
@@ -516,7 +515,7 @@ class AssetsController extends Controller
|
||||
{
|
||||
$this->authorize('index', Asset::class);
|
||||
$assets = Asset::where('serial', $serial)->with([
|
||||
'assetstatus',
|
||||
'status',
|
||||
'assignedTo',
|
||||
'company',
|
||||
'defaultLoc',
|
||||
@@ -560,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)
|
||||
) {
|
||||
@@ -600,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();
|
||||
}
|
||||
|
||||
@@ -628,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;
|
||||
@@ -963,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.'));
|
||||
}
|
||||
@@ -1119,11 +1123,23 @@ class AssetsController extends Controller
|
||||
$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')) {
|
||||
$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();
|
||||
@@ -1145,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),
|
||||
];
|
||||
|
||||
@@ -1187,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()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1220,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);
|
||||
|
||||
}
|
||||
|
||||
@@ -1255,7 +1278,7 @@ class AssetsController extends Controller
|
||||
$assets = Asset::select('assets.*')
|
||||
->with(
|
||||
'location',
|
||||
'assetstatus',
|
||||
'status',
|
||||
'assetlog',
|
||||
'company',
|
||||
'assignedTo',
|
||||
@@ -1330,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)
|
||||
@@ -1346,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1444,8 +1493,12 @@ class AssetsController extends Controller
|
||||
public function history(Request $request, Asset $asset): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $asset);
|
||||
$history = $asset->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,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;
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -391,8 +391,12 @@ class ComponentsController extends Controller
|
||||
public function history(Request $request, Component $component): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $component);
|
||||
$history = $component->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -361,8 +361,12 @@ class ConsumablesController extends Controller
|
||||
public function history(Request $request, Consumable $consumable): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $consumable);
|
||||
$history = $consumable->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\CustomFieldsetsTransformer;
|
||||
use App\Http\Transformers\CustomFieldsTransformer;
|
||||
use App\Models\CustomField;
|
||||
@@ -36,16 +35,10 @@ class CustomFieldsetsController extends Controller
|
||||
*
|
||||
* @since [v1.8]
|
||||
*/
|
||||
public function index(FilterRequest $request): array
|
||||
public function index(): array
|
||||
{
|
||||
$this->authorize('index', CustomField::class);
|
||||
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count');
|
||||
|
||||
// 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')) {
|
||||
$fieldsets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
$fieldsets->get();
|
||||
$fieldsets = CustomFieldset::withCount('fields as fields_count', 'models as models_count')->get();
|
||||
|
||||
return (new CustomFieldsetsTransformer)->transformCustomFieldsets($fieldsets, $fieldsets->count());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
@@ -77,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')));
|
||||
@@ -124,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')));
|
||||
|
||||
@@ -282,8 +282,12 @@ class LicensesController extends Controller
|
||||
public function history(Request $request, License $license): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $license);
|
||||
$history = $license->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,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);
|
||||
@@ -321,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);
|
||||
@@ -462,8 +462,12 @@ class LocationsController extends Controller
|
||||
public function history(Request $request, Location $location): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $location);
|
||||
$history = $location->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ class MaintenancesController extends Controller
|
||||
$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');
|
||||
|
||||
// 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')) {
|
||||
@@ -260,8 +260,12 @@ class MaintenancesController extends Controller
|
||||
$this->authorize('view', Asset::class);
|
||||
$asset = $maintenance->asset;
|
||||
$this->authorize('history', $asset);
|
||||
$history = $maintenance->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\Support\Facades\Gate;
|
||||
|
||||
class ReportsController extends Controller
|
||||
{
|
||||
@@ -19,31 +21,52 @@ class ReportsController extends Controller
|
||||
*/
|
||||
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');
|
||||
|
||||
// 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('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'));
|
||||
}
|
||||
@@ -100,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')));
|
||||
@@ -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')));
|
||||
|
||||
@@ -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;
|
||||
@@ -436,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,
|
||||
));
|
||||
}
|
||||
|
||||
//
|
||||
@@ -535,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.
|
||||
@@ -570,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'));
|
||||
}
|
||||
@@ -839,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);
|
||||
}
|
||||
@@ -970,8 +945,12 @@ class UsersController extends Controller
|
||||
public function history(Request $request, User $user): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $user);
|
||||
$history = $user->getHistory($request);
|
||||
$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, $history->count()), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -407,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) {
|
||||
@@ -716,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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,13 +17,17 @@ class StorageProxyController extends Controller
|
||||
*/
|
||||
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 . '/')) {
|
||||
if ($root !== '' && str_starts_with($path, $root.'/')) {
|
||||
$path = substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
@@ -33,12 +37,12 @@ class StorageProxyController extends Controller
|
||||
|
||||
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
|
||||
$lastModified = $disk->lastModified($path);
|
||||
$etag = md5($path . $lastModified);
|
||||
$etag = md5($path.$lastModified);
|
||||
$size = $disk->size($path);
|
||||
|
||||
if ($this->isNotModified($etag, $lastModified)) {
|
||||
return response('', 304)
|
||||
->header('ETag', '"' . $etag . '"')
|
||||
->header('ETag', '"'.$etag.'"')
|
||||
->header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
@@ -51,8 +55,8 @@ class StorageProxyController extends Controller
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => $size,
|
||||
'ETag' => '"' . $etag . '"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified) . ' GMT',
|
||||
'ETag' => '"'.$etag.'"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
@@ -60,7 +64,7 @@ class StorageProxyController extends Controller
|
||||
private function isNotModified(string $etag, int $lastModified): bool
|
||||
{
|
||||
$requestEtag = request()->header('If-None-Match');
|
||||
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
|
||||
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -71,4 +75,16 @@ class StorageProxyController extends Controller
|
||||
|
||||
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 === '..';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)) ? [
|
||||
@@ -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'),
|
||||
|
||||
@@ -88,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],
|
||||
];
|
||||
@@ -101,4 +100,9 @@ class ComponentsTransformer
|
||||
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
|
||||
public function transformAssignedTo($componentCheckout)
|
||||
{
|
||||
return (new AssetsTransformer)->transformAssetCompact($componentCheckout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Presenters\ActionlogPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Carbon\Carbon;
|
||||
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;
|
||||
@@ -328,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
|
||||
*
|
||||
|
||||
+111
-26
@@ -34,7 +34,7 @@ class Asset extends Depreciable
|
||||
{
|
||||
protected $presenter = AssetPresenter::class;
|
||||
|
||||
protected $with = ['model', 'adminuser'];
|
||||
protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
|
||||
use CompanyableTrait;
|
||||
use HasFactory;
|
||||
@@ -79,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
|
||||
@@ -206,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
|
||||
@@ -456,8 +470,8 @@ 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;
|
||||
|
||||
@@ -473,8 +487,8 @@ class Asset extends Depreciable
|
||||
{
|
||||
|
||||
// This asset is currently assigned to anyone and is not deleted...
|
||||
if (($this->assigned_to != '') && ($this->assetstatus) && ($this->assetstatus->archived == '0')
|
||||
&& ($this->assetstatus->deployable == '1')
|
||||
if (($this->assigned_to != '') && ($this->status) && ($this->status->archived == '0')
|
||||
&& ($this->status->deployable == '1')
|
||||
) {
|
||||
return true;
|
||||
|
||||
@@ -502,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;
|
||||
@@ -546,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);
|
||||
|
||||
@@ -624,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
|
||||
*
|
||||
@@ -649,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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -736,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
|
||||
*
|
||||
@@ -758,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) {
|
||||
@@ -968,7 +1009,7 @@ class Asset extends Depreciable
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function assetstatus()
|
||||
public function status()
|
||||
{
|
||||
return $this->belongsTo(Statuslabel::class, 'status_id');
|
||||
}
|
||||
@@ -1223,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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1406,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);
|
||||
@@ -1463,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);
|
||||
@@ -1480,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);
|
||||
@@ -1497,7 +1570,7 @@ class Asset extends Depreciable
|
||||
public function scopeNotArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'assetstatus', function ($query) {
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
@@ -1666,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);
|
||||
}
|
||||
);
|
||||
@@ -1685,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);
|
||||
@@ -1716,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)
|
||||
@@ -2093,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '');
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -165,6 +166,26 @@ class Component extends SnipeModel
|
||||
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
|
||||
}
|
||||
|
||||
protected function calculatedPurchaseCost(): Attribute
|
||||
{
|
||||
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;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the component -> company relationship
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,10 @@ class Consumable extends SnipeModel
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (($this->qty == '') || ($this->qty == 0)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
|
||||
}
|
||||
|
||||
@@ -491,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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ class Location extends SnipeModel
|
||||
{
|
||||
return $this->hasMany(Asset::class, 'location_id')
|
||||
->whereHas(
|
||||
'assetstatus', function ($query) {
|
||||
'status', function ($query) {
|
||||
$query->where('status_labels.deployable', '=', 1)
|
||||
->orWhere('status_labels.pending', '=', 1)
|
||||
->orWhere('status_labels.archived', '=', 0);
|
||||
@@ -180,6 +180,11 @@ class Location extends SnipeModel
|
||||
);
|
||||
}
|
||||
|
||||
public function countAllTheThings()
|
||||
{
|
||||
return $this->assets()->count() + $this->consumables()->count() + $this->components()->count() + $this->users()->count() + $this->assignedAccessories()->count() + $this->assignedAssets()->count() + $this->accessories()->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the asset -> rtd_location relationship
|
||||
*
|
||||
|
||||
@@ -92,7 +92,7 @@ class Maintenance extends SnipeModel implements ICompanyableChild
|
||||
'asset' => ['name', 'asset_tag', 'serial'],
|
||||
'asset.model' => ['name', 'model_number'],
|
||||
'asset.supplier' => ['name'],
|
||||
'asset.assetstatus' => ['name'],
|
||||
'asset.status' => ['name'],
|
||||
'supplier' => ['name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
@@ -238,7 +238,7 @@ class SnipeModel extends Model
|
||||
|
||||
public function actionlog()
|
||||
{
|
||||
return $this->hasMany(Actionlog::class, 'target_id')->where('target_type', '=', self::class)->orderBy('created_at', 'DESC')->withTrashed();
|
||||
return $this->hasMany(Actionlog::class, 'target_id')->where('target_type', '=', self::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,6 +14,7 @@ use GuzzleHttp\Exception\ClientException;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Exception\RequestException;
|
||||
use GuzzleHttp\Exception\ServerException;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -26,11 +27,11 @@ trait Loggable
|
||||
public ?bool $imported = false;
|
||||
|
||||
/**
|
||||
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
|
||||
* @return MorphMany
|
||||
*
|
||||
* @since [v3.4]
|
||||
*
|
||||
* @return Actionlog
|
||||
* @author Daniel Meltzer <dmeltzer.devel@gmail.com>
|
||||
*/
|
||||
public function log()
|
||||
{
|
||||
@@ -39,8 +40,13 @@ trait Loggable
|
||||
|
||||
public function history()
|
||||
{
|
||||
return $this->hasMany(Actionlog::class, 'item_id')
|
||||
->where('item_type', self::class);
|
||||
|
||||
return $this->morphMany(Actionlog::class, 'item')
|
||||
->orWhere(function ($query) {
|
||||
$query->where('target_type', '=', static::class)
|
||||
->where('target_id', '=', $this->getKey());
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
public function getHistory(Request $request)
|
||||
@@ -61,50 +67,47 @@ trait Loggable
|
||||
'action_date',
|
||||
];
|
||||
|
||||
$history = $this->with('item', 'user', 'adminuser', 'target', 'location', 'history');
|
||||
// Start with the polymorphic history relation so all filters and
|
||||
// ordering are applied to the same query instance.
|
||||
$history = $this->history();
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$history = $this->history()->TextSearch(e($request->input('search')));
|
||||
$history = $history->TextSearch(e($request->input('search')));
|
||||
}
|
||||
|
||||
if ($request->filled('action_type')) {
|
||||
$history = $this->history()->where('action_type', '=', $request->input('action_type'));
|
||||
$history = $history->where('action_type', '=', $request->input('action_type'));
|
||||
}
|
||||
|
||||
if ($request->filled('created_by')) {
|
||||
$history = $this->history()->where('created_by', '=', $request->input('created_by'));
|
||||
$history = $history->where('created_by', '=', $request->input('created_by'));
|
||||
}
|
||||
|
||||
if ($request->filled('action_source')) {
|
||||
$history = $this->history()->where('action_source', '=', $request->input('action_source'));
|
||||
$history = $history->where('action_source', '=', $request->input('action_source'));
|
||||
}
|
||||
|
||||
if ($request->filled('remote_ip')) {
|
||||
$history = $this->history()->where('remote_ip', '=', $request->input('remote_ip'));
|
||||
$history = $history->where('remote_ip', '=', $request->input('remote_ip'));
|
||||
}
|
||||
|
||||
if ($request->filled('uploads')) {
|
||||
$history = $this->history()->whereNotNull('filename');
|
||||
$history = $history->whereNotNull('filename');
|
||||
}
|
||||
|
||||
$total = $this->history()->count();
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
|
||||
$order = ($request->input('order') == 'asc') ? 'asc' : 'desc';
|
||||
|
||||
switch ($request->input('sort')) {
|
||||
case 'created_by':
|
||||
$this->history()->OrderByCreatedBy($order);
|
||||
$history = $history->OrderByCreatedBy($order);
|
||||
break;
|
||||
default:
|
||||
$sort = in_array($request->input('sort'), $allowed_columns) ? e($request->input('sort')) : 'action_logs.created_at';
|
||||
$history = $this->history()->orderBy($sort, $order);
|
||||
$history = $history->orderBy($sort, $order);
|
||||
break;
|
||||
}
|
||||
|
||||
return $history->skip($offset)->take($limit)->get();
|
||||
return $history->forApiHistory();
|
||||
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ trait Loggable
|
||||
|
||||
$changed = [];
|
||||
$array_to_flip = array_keys($fields_array);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
|
||||
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
|
||||
|
||||
foreach ($originalValues as $key => $value) {
|
||||
@@ -276,7 +279,7 @@ trait Loggable
|
||||
$changed = [];
|
||||
|
||||
$array_to_flip = array_keys($fields_array);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin']);
|
||||
$array_to_flip = array_merge($array_to_flip, ['name', 'status_id', 'location_id', 'expected_checkin', 'requestable']);
|
||||
|
||||
$originalValues = array_intersect_key($originalValues, array_flip($array_to_flip));
|
||||
|
||||
@@ -300,6 +303,32 @@ trait Loggable
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs a force checkin action for orphaned assignments.
|
||||
*
|
||||
* Force checkin only records an explicit action log entry and intentionally
|
||||
* skips checkin counters and changed-field metadata.
|
||||
*
|
||||
* @return Actionlog
|
||||
*/
|
||||
public function logForceCheckin($note = null)
|
||||
{
|
||||
$log = new Actionlog;
|
||||
|
||||
$log = $this->determineLogItemType($log);
|
||||
$log->location_id = null;
|
||||
$log->note = $note;
|
||||
$log->action_date = date('Y-m-d H:i:s');
|
||||
|
||||
if (auth()->user()) {
|
||||
$log->created_by = auth()->id();
|
||||
}
|
||||
|
||||
$log->logaction('force checkin');
|
||||
|
||||
return $log;
|
||||
}
|
||||
|
||||
/**
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Models\Traits;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Location;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
@@ -14,7 +16,7 @@ use Illuminate\Support\Facades\DB;
|
||||
* This handles all the out of the box advanced search stuff (using the "advanced search" bootstrap table plugin),
|
||||
* allowing you to just define which attributes and relations should be searched, and then it does the rest.
|
||||
*
|
||||
* You can override these trait methods (for example, advancedSearch) if you need different ebhavior, but this really
|
||||
* You can override these trait methods (for example, advancedSearch) if you need different behavior, but this really
|
||||
* should cover most of the use cases, and allows you to easily add searching to your models without having to
|
||||
* write complex queries.
|
||||
*
|
||||
@@ -27,13 +29,21 @@ use Illuminate\Support\Facades\DB;
|
||||
* if ($request->filled('filter') || $request->filled('search')) {
|
||||
* $whateverModel->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
* }
|
||||
* 4. Set the "data-advanced
|
||||
* 4. Set the "data-advanced-search="true" in the
|
||||
*
|
||||
*
|
||||
* @author Till Deeke <kontakt@tilldeeke.de>
|
||||
*/
|
||||
trait Searchable
|
||||
{
|
||||
/**
|
||||
* Per-class cache for the custom field filter map, keyed by db_column / lowercase name.
|
||||
* Populated lazily; cleared via flushCustomFieldFilterMap().
|
||||
*
|
||||
* @var array<string, string>|null
|
||||
*/
|
||||
private static ?array $customFieldFilterMapCache = null;
|
||||
|
||||
/**
|
||||
* Performs a search on the model, using the provided search terms
|
||||
*
|
||||
@@ -149,12 +159,14 @@ trait Searchable
|
||||
/**
|
||||
* Prepares the search term, splitting and cleaning it up
|
||||
*
|
||||
* @TODO: see if there's a way to tweak the advanced search plugin to split the terms on the frontend, so we don't have to do it here. This is pretty hacky and fragile, since it relies on the user inputting " OR " between search terms, which is not very user-friendly, but we could potentially hack the advanced search extension itself to add an operator. (That extension's UI is pretty terrible, but it's what we have)
|
||||
*
|
||||
* @param string $search The search term
|
||||
* @return array An array of search terms
|
||||
*/
|
||||
private function prepeareSearchTerms($search)
|
||||
{
|
||||
return explode(' OR ', $search);
|
||||
return explode(' AND ', $search);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,14 +194,35 @@ trait Searchable
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! array_key_exists($filterKey, $searchableRelations)) {
|
||||
// Check if this is a custom field (only for Assets - for *now*).
|
||||
// Only db_column keys (e.g. "_snipeit_cpu_4") are accepted to avoid
|
||||
// collisions with standard attributes or relation filter keys.
|
||||
if ($this instanceof Asset) {
|
||||
$dbColumn = $this->resolveCustomFieldDbColumn($filterKey);
|
||||
|
||||
if ($dbColumn !== null) {
|
||||
$query->where($table.'.'.$dbColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$resolvedRelationKey = $this->resolveSearchableRelationKey($filterKey, $searchableRelations);
|
||||
|
||||
if ($resolvedRelationKey === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationColumns = (array) $searchableRelations[$filterKey];
|
||||
if ($this->isAssignedToRelationKey($resolvedRelationKey)) {
|
||||
$query = $this->applyAssignedToRelationFilter($query, $resolvedRelationKey, $filterValue);
|
||||
|
||||
$query->whereHas($filterKey, function (Builder $relationQuery) use ($filterKey, $relationColumns, $filterValue) {
|
||||
$relationTable = $this->getRelationTable($filterKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
$relationColumns = (array) $searchableRelations[$resolvedRelationKey];
|
||||
|
||||
$query->whereHas($resolvedRelationKey, function (Builder $relationQuery) use ($resolvedRelationKey, $relationColumns, $filterValue) {
|
||||
$relationTable = $this->getRelationTable($resolvedRelationKey);
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($relationColumns as $relationColumn) {
|
||||
@@ -203,7 +236,7 @@ trait Searchable
|
||||
$relationQuery->orWhere($relationTable.'.'.$relationColumn, 'LIKE', '%'.$filterValue.'%');
|
||||
}
|
||||
|
||||
if (($filterKey === 'adminuser') || ($filterKey === 'user')) {
|
||||
if (($resolvedRelationKey === 'adminuser') || ($resolvedRelationKey === 'user')) {
|
||||
$relationQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(
|
||||
[
|
||||
@@ -221,6 +254,133 @@ trait Searchable
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve alias keys to configured searchable relation keys.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. Direct match in $searchableRelations (relation name used as-is by the API)
|
||||
* 2. $searchableRelationAliases (API/transformer key → Eloquent relation name)
|
||||
* 3. Built-in assigned_to ↔ assignedTo camel/snake alias
|
||||
*/
|
||||
private function resolveSearchableRelationKey(string $filterKey, array $searchableRelations): ?string
|
||||
{
|
||||
// 1. Direct match — the filter key is already the relation name.
|
||||
if (array_key_exists($filterKey, $searchableRelations)) {
|
||||
return $filterKey;
|
||||
}
|
||||
|
||||
// 2. Model-defined aliases — e.g. 'status_label' => 'status'.
|
||||
$aliases = $this->getSearchableRelationAliases();
|
||||
|
||||
if (array_key_exists($filterKey, $aliases)) {
|
||||
$aliasedRelation = $aliases[$filterKey];
|
||||
|
||||
if (array_key_exists($aliasedRelation, $searchableRelations)) {
|
||||
return $aliasedRelation;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Built-in camel/snake alias for the polymorphic assignee relation.
|
||||
if ($filterKey === 'assigned_to' && array_key_exists('assignedTo', $searchableRelations)) {
|
||||
return 'assignedTo';
|
||||
}
|
||||
|
||||
if ($filterKey === 'assignedTo' && array_key_exists('assigned_to', $searchableRelations)) {
|
||||
return 'assigned_to';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether a relation key represents polymorphic assignee lookups.
|
||||
*/
|
||||
private function isAssignedToRelationKey(string $relationKey): bool
|
||||
{
|
||||
return in_array($relationKey, ['assigned_to', 'assignedTo'], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filters for assignees with type-specific searchable columns.
|
||||
*/
|
||||
private function applyAssignedToRelationFilter(Builder $query, string $relationKey, string $filterValue): Builder
|
||||
{
|
||||
$relationName = $this->resolveAssignedToRelationName();
|
||||
|
||||
if ($relationName === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->whereHasMorph(
|
||||
$relationName,
|
||||
[User::class, Asset::class, Location::class],
|
||||
function (Builder $assigneeQuery, string $assigneeType) use ($filterValue) {
|
||||
$columns = $this->getAssigneeColumnsByType($assigneeType);
|
||||
|
||||
if (empty($columns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = (new $assigneeType)->getTable();
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
if (! $firstConditionAdded) {
|
||||
$assigneeQuery->where($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$assigneeQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$filterValue.'%');
|
||||
}
|
||||
|
||||
if ($assigneeType === User::class) {
|
||||
$assigneeQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
|
||||
["%{$filterValue}%"]
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the searchable columns for a given assignee morph type.
|
||||
*
|
||||
* Users have no "name" column, only first_name/last_name/username/display_name.
|
||||
* Assets use asset_tag as the primary identifier (name is nullable).
|
||||
* Locations use name.
|
||||
*/
|
||||
private function getAssigneeColumnsByType(string $assigneeType): array
|
||||
{
|
||||
return match ($assigneeType) {
|
||||
User::class => ['first_name', 'last_name', 'username', 'display_name'],
|
||||
Asset::class => ['asset_tag', 'name'],
|
||||
Location::class => ['name'],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the actual relation method name for the assignedTo polymorphic relation.
|
||||
*
|
||||
* Models may define it as "assignedTo" (camelCase) or "assigned_to" (snake_case).
|
||||
* We prefer "assignedTo" when both exist.
|
||||
*/
|
||||
private function resolveAssignedToRelationName(): ?string
|
||||
{
|
||||
if (method_exists($this, 'assignedTo')) {
|
||||
return 'assignedTo';
|
||||
}
|
||||
|
||||
if (method_exists($this, 'assigned_to')) {
|
||||
return 'assigned_to';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filtering on computed count aliases (for example withCount aliases).
|
||||
*/
|
||||
@@ -295,15 +455,33 @@ trait Searchable
|
||||
return $query;
|
||||
}
|
||||
|
||||
$customFields = CustomField::all();
|
||||
// Only pull unencrypted fields, since encrypted fields cannot be searched on
|
||||
$customFields = CustomField::query()
|
||||
->whereNotNull('db_column')
|
||||
->where('field_encrypted', 0)
|
||||
->get(['db_column']);
|
||||
|
||||
foreach ($customFields as $field) {
|
||||
foreach ($terms as $term) {
|
||||
$query->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
if ($customFields->isEmpty()) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query;
|
||||
// Group custom-fields so all custom fields behave consistently as OR conditions.
|
||||
return $query->orWhere(function (Builder $customFieldQuery) use ($customFields, $terms): void {
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($customFields as $field) {
|
||||
foreach ($terms as $term) {
|
||||
if (! $firstConditionAdded) {
|
||||
$customFieldQuery->where($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$customFieldQuery->orWhere($this->getTable().'.'.$field->db_column_name(), 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,13 +489,33 @@ trait Searchable
|
||||
*
|
||||
* @param $query Builder
|
||||
* @param $terms array
|
||||
* @return Builder
|
||||
*/
|
||||
private function searchRelations(Builder $query, array $terms)
|
||||
private function searchRelations(Builder $query, array $terms): Builder
|
||||
{
|
||||
foreach ($this->getSearchableRelations() as $relation => $columns) {
|
||||
|
||||
// Polymorphic assignee relations need special per-type column handling
|
||||
// because users, assets, and locations each have different identifier columns.
|
||||
if ($this->isAssignedToRelationKey($relation)) {
|
||||
$query = $this->searchAssignedToRelation($query, $terms);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$isUserRelation = in_array($relation, ['adminuser', 'user'], true);
|
||||
|
||||
// Pre-build the concat SQL outside the closure so $this->buildMultipleColumnSearch()
|
||||
// doesn't need to be called inside a nested closure context.
|
||||
$concatSql = $isUserRelation
|
||||
? $this->buildMultipleColumnSearch(['users.first_name', 'users.last_name'])
|
||||
: null;
|
||||
|
||||
$query = $query->orWhereHas(
|
||||
$relation, function ($query) use ($relation, $columns, $terms) {
|
||||
$relation, function (Builder $relationQuery) use ($relation, $columns, $terms, $isUserRelation, $concatSql) {
|
||||
|
||||
// $table must be resolved inside the closure for self-referential relations
|
||||
// (e.g. User->manager, User->adminuser). getRelationTable relies on the
|
||||
// alias counter that orWhereHas increments before this callback runs.
|
||||
$table = $this->getRelationTable($relation);
|
||||
|
||||
/**
|
||||
@@ -331,26 +529,22 @@ trait Searchable
|
||||
foreach ($columns as $column) {
|
||||
foreach ($terms as $term) {
|
||||
if (! $firstConditionAdded) {
|
||||
$query->where($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
$relationQuery->where($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$query->orWhere($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
$relationQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
}
|
||||
// I put this here because I only want to add the concat one time in the end of the user relation search
|
||||
if (($relation == 'adminuser') || ($relation == 'user')) {
|
||||
$query->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(
|
||||
[
|
||||
'users.first_name',
|
||||
'users.last_name',
|
||||
]
|
||||
),
|
||||
["%{$term}%"]
|
||||
);
|
||||
|
||||
// Also search first+last name concatenated for user relations so that
|
||||
// "John Smith" matches even when the terms are split across columns.
|
||||
if ($isUserRelation && $concatSql !== null) {
|
||||
foreach ($terms as $term) {
|
||||
$relationQuery->orWhereRaw($concatSql, ["%{$term}%"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -359,6 +553,62 @@ trait Searchable
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search across the polymorphic assignee relation (assignedTo / assigned_to).
|
||||
*
|
||||
* Uses whereHasMorph so that each possible assignee type is constrained to the
|
||||
* columns that actually exist on that type:
|
||||
* - User → first_name, last_name, username, display_name
|
||||
* - Asset → asset_tag, name
|
||||
* - Location → name
|
||||
*/
|
||||
private function searchAssignedToRelation(Builder $query, array $terms): Builder
|
||||
{
|
||||
$relationName = $this->resolveAssignedToRelationName();
|
||||
|
||||
if ($relationName === null) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query->orWhereHasMorph(
|
||||
$relationName,
|
||||
[User::class, Asset::class, Location::class],
|
||||
function (Builder $morphQuery, string $morphType) use ($terms) {
|
||||
$columns = $this->getAssigneeColumnsByType($morphType);
|
||||
|
||||
if (empty($columns)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table = (new $morphType)->getTable();
|
||||
$firstConditionAdded = false;
|
||||
|
||||
foreach ($columns as $column) {
|
||||
foreach ($terms as $term) {
|
||||
if (! $firstConditionAdded) {
|
||||
$morphQuery->where($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
$firstConditionAdded = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$morphQuery->orWhere($table.'.'.$column, 'LIKE', '%'.$term.'%');
|
||||
}
|
||||
}
|
||||
|
||||
// Also search first+last concatenated for users.
|
||||
if ($morphType === User::class) {
|
||||
foreach ($terms as $term) {
|
||||
$morphQuery->orWhereRaw(
|
||||
$this->buildMultipleColumnSearch(['users.first_name', 'users.last_name']),
|
||||
["%{$term}%"]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run additional, advanced searches that can't be done using the attributes or relations.
|
||||
*
|
||||
@@ -403,6 +653,25 @@ trait Searchable
|
||||
return $this->searchableCounts ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the relation aliases defined on the model.
|
||||
*
|
||||
* Maps the field names that the API / transformers expose to the actual
|
||||
* Eloquent relation names used in $searchableRelations. For example:
|
||||
*
|
||||
* protected $searchableRelationAliases = [
|
||||
* 'status_label' => 'status',
|
||||
* ];
|
||||
*
|
||||
* Override this method in a model if you need dynamic alias resolution.
|
||||
*
|
||||
* @return array<string, string> [ api_key => relation_name ]
|
||||
*/
|
||||
protected function getSearchableRelationAliases(): array
|
||||
{
|
||||
return $this->searchableRelationAliases ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the table name of a relation.
|
||||
*
|
||||
@@ -483,4 +752,74 @@ trait Searchable
|
||||
{
|
||||
return $query->orWhereRaw($this->buildMultipleColumnSearch($columns), ["%{$term}%"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a filter key to the actual database column name for a custom field.
|
||||
*
|
||||
* Accepts only raw db_column slugs (e.g. "_snipeit_cpu_4") as filter keys.
|
||||
*
|
||||
* Returns null when the key cannot be matched to any known custom field.
|
||||
*
|
||||
* Only applicable to the Asset model.
|
||||
*/
|
||||
private function resolveCustomFieldDbColumn(string $filterKey): ?string
|
||||
{
|
||||
if (! $this instanceof Asset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$map = $this->buildCustomFieldFilterMap();
|
||||
|
||||
// Exact match on db_column (e.g. "_snipeit_cpu_4") only.
|
||||
return $map[$filterKey] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a lookup map for custom field filter resolution.
|
||||
*
|
||||
* The returned array contains db_column entries only:
|
||||
* - db_column (exact) → db_column, e.g. "_snipeit_cpu_4" => "_snipeit_cpu_4"
|
||||
*
|
||||
* Results are cached statically for the duration of the request.
|
||||
* Call flushCustomFieldFilterMap() to reset the cache (useful in tests).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function buildCustomFieldFilterMap(): array
|
||||
{
|
||||
if (isset(static::$customFieldFilterMapCache)) {
|
||||
return static::$customFieldFilterMapCache;
|
||||
}
|
||||
|
||||
$map = [];
|
||||
|
||||
try {
|
||||
CustomField::query()
|
||||
->whereNotNull('db_column')
|
||||
->where('field_encrypted', 0)
|
||||
->get(['db_column'])
|
||||
->each(function (CustomField $field) use (&$map): void {
|
||||
$dbColumn = $field->db_column;
|
||||
|
||||
// Exact db_column key (e.g. "_snipeit_cpu_4")
|
||||
$map[$dbColumn] = $dbColumn;
|
||||
});
|
||||
} catch (\Exception $e) {
|
||||
// Guard against missing table or schema issues during migrations / tests
|
||||
}
|
||||
|
||||
static::$customFieldFilterMapCache = $map;
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the custom field filter map cache.
|
||||
*
|
||||
* Useful in tests or after custom fields are added/modified.
|
||||
*/
|
||||
public static function flushCustomFieldFilterMap(): void
|
||||
{
|
||||
static::$customFieldFilterMapCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\Access\Authorizable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\HasApiTokens;
|
||||
@@ -207,13 +208,57 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
{
|
||||
static::forceDeleted(function (User $user) {
|
||||
CheckoutRequest::where(['user_id' => $user->id])->forceDelete();
|
||||
$user->purgeAssociatedPassportTokens();
|
||||
});
|
||||
|
||||
static::softDeleted(function (User $user) {
|
||||
CheckoutRequest::where(['user_id' => $user->id])->delete();
|
||||
$user->revokeAssociatedPassportTokens();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all Passport access/refresh tokens associated with this user.
|
||||
*/
|
||||
private function revokeAssociatedPassportTokens(): void
|
||||
{
|
||||
$accessTokenIds = DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accessTokenIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->whereIn('id', $accessTokenIds)
|
||||
->update(['revoked' => true]);
|
||||
|
||||
DB::table('oauth_refresh_tokens')
|
||||
->whereIn('access_token_id', $accessTokenIds)
|
||||
->update(['revoked' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hard-delete all Passport access/refresh tokens associated with this user.
|
||||
*/
|
||||
private function purgeAssociatedPassportTokens(): void
|
||||
{
|
||||
$accessTokenIds = DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->pluck('id');
|
||||
|
||||
if ($accessTokenIds->isNotEmpty()) {
|
||||
DB::table('oauth_refresh_tokens')
|
||||
->whereIn('access_token_id', $accessTokenIds)
|
||||
->delete();
|
||||
}
|
||||
|
||||
DB::table('oauth_access_tokens')
|
||||
->where('user_id', $this->id)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* This overrides the SnipeModel displayName accessor to return the full name if display_name is not set
|
||||
*
|
||||
@@ -259,6 +304,120 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a list of effective user permissions grouped by permission section.
|
||||
*
|
||||
* Includes explicit denials from user or group permissions so the UI can
|
||||
* show both allowed and denied entries.
|
||||
*
|
||||
* This is kind of duplicative from the other permission-checking methods, but it allows us to build a
|
||||
* list of permissions for display purposes without having to do a lot of super-confusing and
|
||||
* redundant checks in the UI layer.
|
||||
*
|
||||
* This will likely go away once we refactor the permissions to be in a database table instead of the
|
||||
* stupiud config file.
|
||||
*/
|
||||
public function getEffectivePermissionsBySection(): array
|
||||
{
|
||||
$displayablePermissions = collect(config('permissions'))
|
||||
->map(static fn (array $permissions): array => array_values(array_filter($permissions, static fn (array $permission): bool => ($permission['display'] ?? false) === true)))
|
||||
->all();
|
||||
|
||||
$configuredPermissions = collect($displayablePermissions)
|
||||
->flatMap(static function (array $permissions, string $section) {
|
||||
return collect($permissions)->map(static function (array $permission) use ($section): array {
|
||||
return [
|
||||
'section' => $section,
|
||||
'permission' => $permission['permission'],
|
||||
];
|
||||
});
|
||||
})
|
||||
->unique('permission')
|
||||
->values();
|
||||
|
||||
$directPermissions = $this->decodePermissions();
|
||||
$directPermissions = is_array($directPermissions) ? $directPermissions : [];
|
||||
|
||||
$groupGrantsByPermission = [];
|
||||
$groupDenialsByPermission = [];
|
||||
foreach ($this->groups as $group) {
|
||||
$groupPermissions = $group->decodePermissions();
|
||||
if (! is_array($groupPermissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($groupPermissions as $permissionKey => $permissionValue) {
|
||||
if ((int) $permissionValue === 1) {
|
||||
$groupGrantsByPermission[$permissionKey][] = $group->name;
|
||||
} elseif ((int) $permissionValue === -1) {
|
||||
$groupDenialsByPermission[$permissionKey][] = $group->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effectiveBySection = [];
|
||||
foreach ($configuredPermissions as $permissionConfig) {
|
||||
$permissionKey = $permissionConfig['permission'];
|
||||
$directPermissionValue = (int) ($directPermissions[$permissionKey] ?? 0);
|
||||
$isAllowed = $this->hasAccess($permissionKey);
|
||||
$isDenied = ($directPermissionValue === -1) || ((count($groupDenialsByPermission[$permissionKey] ?? []) > 0) && ! $isAllowed);
|
||||
|
||||
if (! $isAllowed && ! $isDenied) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$status = $isDenied ? 'denied' : 'allowed';
|
||||
$source = 'group';
|
||||
$sourceGroups = $isDenied
|
||||
? ($groupDenialsByPermission[$permissionKey] ?? [])
|
||||
: ($groupGrantsByPermission[$permissionKey] ?? []);
|
||||
|
||||
if ($isDenied && $directPermissionValue === -1) {
|
||||
$source = 'individual';
|
||||
$sourceGroups = [];
|
||||
} elseif ($this->isSuperUser()) {
|
||||
$source = 'superuser';
|
||||
$sourceGroups = [];
|
||||
} elseif (! $isDenied && $directPermissionValue === 1) {
|
||||
$source = 'individual';
|
||||
$sourceGroups = [];
|
||||
}
|
||||
|
||||
$effectiveBySection[$permissionConfig['section']][] = [
|
||||
'permission' => $permissionKey,
|
||||
'status' => $status,
|
||||
'source' => $source,
|
||||
'groups' => array_values(array_unique($sourceGroups)),
|
||||
'source_label' => $this->buildPermissionSourceLabel(
|
||||
status: $status,
|
||||
source: $source,
|
||||
sourceGroups: $sourceGroups
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return $effectiveBySection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a compact source label for a permission entry.
|
||||
*/
|
||||
private function buildPermissionSourceLabel(string $status, string $source, array $sourceGroups = []): string
|
||||
{
|
||||
$statusLabel = $status === 'denied' ? 'Denied' : 'Allowed';
|
||||
$sourceLabel = match ($source) {
|
||||
'individual' => 'Individual',
|
||||
'superuser' => 'Superuser',
|
||||
default => 'Group',
|
||||
};
|
||||
|
||||
if ($sourceGroups === []) {
|
||||
return $statusLabel.' ('.$sourceLabel.')';
|
||||
}
|
||||
|
||||
return $statusLabel.' ('.$sourceLabel.'): '.implode(', ', array_values(array_unique($sourceGroups)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Internally check the user permission for the given section
|
||||
*
|
||||
@@ -697,6 +856,22 @@ class User extends SnipeModel implements AuthenticatableContract, AuthorizableCo
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assigned items that still have a pending acceptance for this user.
|
||||
*/
|
||||
public function getAssignedItemsWithPendingAcceptance(): Collection
|
||||
{
|
||||
return CheckoutAcceptance::query()
|
||||
->forUser($this)
|
||||
->pending()
|
||||
->with('checkoutable')
|
||||
->get()
|
||||
->map(fn (CheckoutAcceptance $acceptance) => $acceptance->checkoutable)
|
||||
->filter()
|
||||
->unique(fn ($item) => $item::class.':'.$item->getKey())
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the user -> eula relationship
|
||||
*
|
||||
|
||||
@@ -33,6 +33,9 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->note = $params['note'] ?? null;
|
||||
$this->signed_in_place = $params['signed_in_place'] ?? false;
|
||||
$this->signed_in_place_admin = $params['signed_in_place_admin'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
|
||||
}
|
||||
|
||||
@@ -76,6 +79,9 @@ class AcceptanceItemAcceptedNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'signed_in_place' => $this->signed_in_place,
|
||||
'signed_in_place_admin' => $this->signed_in_place_admin,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans('mail.acceptance_accepted_greeting', ['user' => $this->assigned_to, 'item' => $this->item_name]),
|
||||
])
|
||||
->subject('✅ '.trans('mail.acceptance_accepted', ['user' => $this->assigned_to, 'item' => $this->item_name]))
|
||||
|
||||
@@ -34,6 +34,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
$this->settings = Setting::getSettings();
|
||||
$this->file = $params['file'] ?? null;
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,6 +73,7 @@ class AcceptanceItemAcceptedToUserNotification extends Notification
|
||||
'assigned_to' => $this->assigned_to,
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'intro_text' => trans_choice('mail.acceptance_asset_accepted_to_user', $this->qty, ['qty' => $this->qty, 'site_name' => $this->settings->site_name]),
|
||||
])
|
||||
->attach($pdf_path)
|
||||
|
||||
@@ -32,6 +32,7 @@ class AcceptanceItemDeclinedNotification extends Notification
|
||||
$this->settings = Setting::getSettings();
|
||||
$this->qty = $params['qty'] ?? null;
|
||||
$this->admin = $params['admin'] ?? null;
|
||||
$this->custom_fields = $params['custom_fields'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,6 +75,7 @@ class AcceptanceItemDeclinedNotification extends Notification
|
||||
'company_name' => $this->company_name,
|
||||
'qty' => $this->qty,
|
||||
'admin' => $this->admin,
|
||||
'custom_fields' => $this->custom_fields,
|
||||
'user' => $this->assigned_to,
|
||||
'intro_text' => trans('mail.acceptance_declined_greeting', ['user' => $this->assigned_to]),
|
||||
])
|
||||
|
||||
@@ -80,7 +80,7 @@ class CheckinAssetNotification extends Notification
|
||||
|
||||
$fields = [
|
||||
trans('general.administrator') => '<'.$admin->present()->viewUrl().'|'.$admin->display_name.'>',
|
||||
trans('general.status') => $item->assetstatus?->name,
|
||||
trans('general.status') => $item->status?->name,
|
||||
trans('general.location') => ($item->location) ? $item->location->name : '',
|
||||
];
|
||||
|
||||
@@ -118,7 +118,7 @@ class CheckinAssetNotification extends Notification
|
||||
->fact(htmlspecialchars_decode($item->display_name), '', 'activityText')
|
||||
->fact(trans('mail.checked_into'), ($item->location) ? $item->location->name : '')
|
||||
->fact(trans('general.administrator'), $admin->display_name)
|
||||
->fact(trans('admin/hardware/form.status'), $item->assetstatus?->name)
|
||||
->fact(trans('admin/hardware/form.status'), $item->status?->name)
|
||||
->fact(trans('mail.notes'), $note ?: '');
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ class CheckinAssetNotification extends Notification
|
||||
trans('mail.asset') => htmlspecialchars_decode($item->display_name),
|
||||
trans('mail.checked_into') => ($item->location) ? $item->location->name : '',
|
||||
trans('general.administrator') => $admin->display_name,
|
||||
trans('admin/hardware/form.status') => $item->assetstatus?->name,
|
||||
trans('admin/hardware/form.status') => $item->status?->name,
|
||||
trans('mail.notes') => $note ?: '',
|
||||
];
|
||||
|
||||
@@ -153,7 +153,7 @@ class CheckinAssetNotification extends Notification
|
||||
KeyValue::create(
|
||||
trans('mail.checked_into') ?: '',
|
||||
($item->location) ? $item->location->name : '',
|
||||
trans('admin/hardware/form.status').': '.$item->assetstatus?->name,
|
||||
trans('admin/hardware/form.status').': '.$item->status?->name,
|
||||
)
|
||||
->onClick(route('hardware.show', $item->id))
|
||||
)
|
||||
|
||||
@@ -13,6 +13,11 @@ class AssetModelPolicy extends SnipePermissionsPolicy
|
||||
|
||||
public function files(User $user, $item = null)
|
||||
{
|
||||
// Set this to true so that users who can see the asset can also see the associated model files
|
||||
if ($user->hasAccess('assets.files')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->hasAccess($this->columnName().'.files');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,6 +97,11 @@ abstract class SnipePermissionsPolicy
|
||||
return Gate::allows('view', $item) || $user->hasAccess('activity.view');
|
||||
}
|
||||
|
||||
public function journal(User $user, $item = null)
|
||||
{
|
||||
return Gate::allows('view', $item) || $user->hasAccess('activity.view');
|
||||
}
|
||||
|
||||
public function files(User $user, $item = null)
|
||||
{
|
||||
return $user->hasAccess($this->columnName().'.files');
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Setting;
|
||||
use Carbon\CarbonImmutable;
|
||||
use DateTime;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@@ -93,13 +92,14 @@ class AssetPresenter extends Presenter
|
||||
'visible' => true,
|
||||
'formatter' => 'categoriesLinkObjFormatter',
|
||||
], [
|
||||
'field' => 'status_label',
|
||||
'field' => 'status',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/hardware/table.status'),
|
||||
'visible' => true,
|
||||
'formatter' => 'statuslabelsLinkObjFormatter',
|
||||
], [
|
||||
],
|
||||
[
|
||||
'field' => 'assigned_to',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
@@ -487,8 +487,8 @@ class AssetPresenter extends Presenter
|
||||
$imageAlt = $this->model->name;
|
||||
}
|
||||
if (! empty($imagePath)) {
|
||||
$url = Storage::disk('public')->url(app('assets_upload_path') . e($imagePath));
|
||||
$imagePath = '<img src="' . $url . '" height="50" width="50" alt="' . e($imageAlt) . '">';
|
||||
$url = Storage::disk('public')->url(app('assets_upload_path').e($imagePath));
|
||||
$imagePath = '<img src="'.$url.'" height="50" width="50" alt="'.e($imageAlt).'">';
|
||||
}
|
||||
|
||||
return $imagePath;
|
||||
@@ -508,7 +508,7 @@ class AssetPresenter extends Presenter
|
||||
$imagePath = $this->model->image;
|
||||
}
|
||||
if (! empty($imagePath)) {
|
||||
return Storage::disk('public')->url(app('assets_upload_path') . e($imagePath));
|
||||
return Storage::disk('public')->url(app('assets_upload_path').e($imagePath));
|
||||
}
|
||||
|
||||
return $imagePath;
|
||||
@@ -597,7 +597,7 @@ class AssetPresenter extends Presenter
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
return $this->model->assetstatus->getStatuslabelType();
|
||||
return $this->model->status->getStatuslabelType();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -611,7 +611,7 @@ class AssetPresenter extends Presenter
|
||||
return trans('general.deployed');
|
||||
}
|
||||
|
||||
return $this->model->assetstatus->name;
|
||||
return $this->model->status->name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -631,14 +631,14 @@ class AssetPresenter extends Presenter
|
||||
public function fullStatusText()
|
||||
{
|
||||
// Make sure the status is valid
|
||||
if ($this->assetstatus) {
|
||||
if ($this->status) {
|
||||
|
||||
// If the status is assigned to someone or something...
|
||||
if ($this->model->assigned) {
|
||||
|
||||
// If it's assigned and not set to the default "ready to deploy" status
|
||||
if ($this->assetstatus->name != trans('general.ready_to_deploy')) {
|
||||
return trans('general.deployed').' ('.$this->model->assetstatus->name.')';
|
||||
if ($this->status->name != trans('general.ready_to_deploy')) {
|
||||
return trans('general.deployed').' ('.$this->model->status->name.')';
|
||||
}
|
||||
|
||||
// If it's assigned to the default "ready to deploy" status, just
|
||||
@@ -648,7 +648,7 @@ class AssetPresenter extends Presenter
|
||||
}
|
||||
|
||||
// Return just the status name
|
||||
return $this->model->assetstatus->name;
|
||||
return $this->model->status->name;
|
||||
}
|
||||
|
||||
// This status doesn't seem valid - either data has been manually edited or
|
||||
@@ -673,21 +673,6 @@ class AssetPresenter extends Presenter
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to take user created URL and dynamically fill in the needed values per asset
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function dynamicUrl($dynamic_url)
|
||||
{
|
||||
$url = (str_replace('{LOCALE}', Setting::getSettings()->locale, $dynamic_url));
|
||||
$url = (str_replace('{SERIAL}', urlencode($this->model->serial), $url));
|
||||
$url = (str_replace('{MODEL_NAME}', urlencode($this->model->model->name), $url));
|
||||
$url = (str_replace('{MODEL_NUMBER}', urlencode($this->model->model->model_number), $url));
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Url to view this item.
|
||||
*
|
||||
|
||||
@@ -59,10 +59,18 @@ class CategoryPresenter extends Presenter
|
||||
], [
|
||||
'field' => 'has_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/categories/table.eula_text'),
|
||||
'visible' => false,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'use_default_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/settings/general.default_eula_text'),
|
||||
'visible' => false,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'checkin_email',
|
||||
'searchable' => false,
|
||||
@@ -78,13 +86,6 @@ class CategoryPresenter extends Presenter
|
||||
'title' => trans('admin/categories/table.require_acceptance'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'use_default_eula',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('admin/categories/general.use_default_eula_column'),
|
||||
'visible' => true,
|
||||
'formatter' => 'trueFalseFormatter',
|
||||
], [
|
||||
'field' => 'tag_color',
|
||||
'searchable' => true,
|
||||
|
||||
@@ -128,7 +128,7 @@ class ComponentPresenter extends Presenter
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'switchable' => true,
|
||||
'title' => '% ' . trans('general.remaining'),
|
||||
'title' => '% '.trans('general.remaining'),
|
||||
'visible' => true,
|
||||
'formatter' => 'progressBarFormatter',
|
||||
], [
|
||||
@@ -205,29 +205,23 @@ class ComponentPresenter extends Presenter
|
||||
public static function checkedOut()
|
||||
{
|
||||
$layout = [
|
||||
[
|
||||
'field' => 'id',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.id'),
|
||||
'visible' => false,
|
||||
],
|
||||
|
||||
[
|
||||
'field' => 'name',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.name'),
|
||||
'visible' => true,
|
||||
'formatter' => 'hardwareLinkFormatter',
|
||||
'formatter' => 'polymorphicItemFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'qty',
|
||||
'field' => 'assigned_qty',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.qty'),
|
||||
'visible' => true,
|
||||
'footerFormatter' => 'qtySumFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'note',
|
||||
@@ -244,12 +238,20 @@ class ComponentPresenter extends Presenter
|
||||
'title' => trans('general.created_at'),
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
],
|
||||
$layout[] = [
|
||||
[
|
||||
'field' => 'created_by',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.created_by'),
|
||||
'visible' => false,
|
||||
'formatter' => 'usersLinkObjFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'available_actions',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'switchable' => false,
|
||||
'title' => trans('general.checkin').'/'.trans('general.checkout'),
|
||||
'title' => trans('table.actions'),
|
||||
'visible' => true,
|
||||
'formatter' => 'componentsInOutFormatter',
|
||||
'printIgnore' => true,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\CustomField;
|
||||
|
||||
/**
|
||||
* Class CustomFieldPresenter
|
||||
* Handles presentation logic for CustomField, including visibility icons.
|
||||
*/
|
||||
final class CustomFieldPresenter extends Presenter
|
||||
{
|
||||
private CustomField $field;
|
||||
|
||||
public function __construct(CustomField $field)
|
||||
{
|
||||
$this->field = $field;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of icon HTML for where the field is visible.
|
||||
*
|
||||
* @return string[] Array of HTML icon strings
|
||||
*/
|
||||
public function visibilityIconsArray(): array
|
||||
{
|
||||
$icons = [];
|
||||
if ($this->field->display_checkout) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkout')).'" data-tooltip="true"><i class="fa-solid fa-rotate-left text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->display_checkin) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_checkin')).'" data-tooltip="true"><i class="fa-solid fa-rotate-right text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->display_audit) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_audit')).'" data-tooltip="true"><i class="fas fa-clipboard-check text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->display_in_user_view) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.display_in_user_view_table')).'" data-tooltip="true"><i class="fas fa-user text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->show_in_listview) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_listview_short')).'" data-tooltip="true"><i class="fas fa-list text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->show_in_email) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_email_short')).'" data-tooltip="true"><i class="fas fa-envelope text-muted"></i></span>';
|
||||
}
|
||||
if ($this->field->show_in_requestable_list) {
|
||||
$icons[] = '<span title="'.e(trans('admin/custom_fields/general.show_in_requestable_list_short')).'" data-tooltip="true"><i class="fa-solid fa-bell-concierge text-muted"></i></span>';
|
||||
}
|
||||
|
||||
return $icons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the icons as a single HTML string (for backward compatibility)
|
||||
*/
|
||||
public function visibilityIcons(): string
|
||||
{
|
||||
return implode(' ', $this->visibilityIconsArray());
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ class DepreciationReportPresenter extends Presenter
|
||||
$imagePath = $this->model->image;
|
||||
}
|
||||
if (! empty($imagePath)) {
|
||||
return Storage::disk('public')->url(app('assets_upload_path') . e($imagePath));
|
||||
return Storage::disk('public')->url(app('assets_upload_path').e($imagePath));
|
||||
}
|
||||
|
||||
return $imagePath;
|
||||
@@ -328,7 +328,7 @@ class DepreciationReportPresenter extends Presenter
|
||||
return 'deployed';
|
||||
}
|
||||
|
||||
return $this->model->assetstatus->getStatuslabelType();
|
||||
return $this->model->status->getStatuslabelType();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +342,7 @@ class DepreciationReportPresenter extends Presenter
|
||||
return trans('general.deployed');
|
||||
}
|
||||
|
||||
return $this->model->assetstatus->name;
|
||||
return $this->model->status->name;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -362,14 +362,14 @@ class DepreciationReportPresenter extends Presenter
|
||||
public function fullStatusText()
|
||||
{
|
||||
// Make sure the status is valid
|
||||
if ($this->assetstatus) {
|
||||
if ($this->status) {
|
||||
|
||||
// If the status is assigned to someone or something...
|
||||
if ($this->model->assigned) {
|
||||
|
||||
// If it's assigned and not set to the default "ready to deploy" status
|
||||
if ($this->assetstatus->name != trans('general.ready_to_deploy')) {
|
||||
return trans('general.deployed').' ('.$this->model->assetstatus->name.')';
|
||||
if ($this->status->name != trans('general.ready_to_deploy')) {
|
||||
return trans('general.deployed').' ('.$this->model->status->name.')';
|
||||
}
|
||||
|
||||
// If it's assigned to the default "ready to deploy" status, just
|
||||
@@ -379,7 +379,7 @@ class DepreciationReportPresenter extends Presenter
|
||||
}
|
||||
|
||||
// Return just the status name
|
||||
return $this->model->assetstatus->name;
|
||||
return $this->model->status->name;
|
||||
}
|
||||
|
||||
// This status doesn't seem valid - either data has been manually edited or
|
||||
|
||||
+212
-156
@@ -2,9 +2,6 @@
|
||||
|
||||
namespace App\Presenters;
|
||||
|
||||
/**
|
||||
* Class AccessoryPresenter
|
||||
*/
|
||||
class HistoryPresenter extends Presenter
|
||||
{
|
||||
/**
|
||||
@@ -12,168 +9,227 @@ class HistoryPresenter extends Presenter
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function dataTableLayout($serial = false)
|
||||
public static function dataTableLayout($hide_fields = [])
|
||||
{
|
||||
$extra = [];
|
||||
$layout_start = [
|
||||
[
|
||||
'id' => 'id',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.id'),
|
||||
'visible' => false,
|
||||
'class' => 'hidden-xs',
|
||||
],
|
||||
[
|
||||
'field' => 'icon',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('admin/hardware/table.icon'),
|
||||
'visible' => true,
|
||||
'class' => 'hidden-xs',
|
||||
'formatter' => 'iconFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'created_at',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.created_at'),
|
||||
'visible' => true,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'created_by',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.created_by'),
|
||||
'visible' => true,
|
||||
'formatter' => 'usersLinkObjFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'action_date',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.action_date'),
|
||||
'visible' => false,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'action_type',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.action'),
|
||||
'visible' => true,
|
||||
],
|
||||
[
|
||||
'field' => 'item',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.item'),
|
||||
'visible' => true,
|
||||
'formatter' => 'polymorphicItemFormatter',
|
||||
],
|
||||
];
|
||||
$layout = [];
|
||||
|
||||
if ($serial) {
|
||||
$extra = [
|
||||
if (! in_array('id', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'id' => 'id',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.id'),
|
||||
'visible' => false,
|
||||
'class' => 'hidden-xs',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('icon', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'icon',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('admin/hardware/table.icon'),
|
||||
'visible' => true,
|
||||
'class' => 'hidden-xs',
|
||||
'formatter' => 'iconFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('created_at', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'created_at',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.created_at'),
|
||||
'visible' => true,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
]);
|
||||
}
|
||||
if (! in_array('created_by', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'created_by',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.created_by'),
|
||||
'visible' => true,
|
||||
'formatter' => 'usersLinkObjFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('action_type', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'action_type',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.action'),
|
||||
'visible' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('action_date', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'action_date',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'title' => trans('general.action_date'),
|
||||
'visible' => false,
|
||||
'formatter' => 'dateDisplayFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('item', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'item',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.item'),
|
||||
'visible' => true,
|
||||
'formatter' => 'polymorphicItemFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('serial', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'item.serial',
|
||||
'title' => trans('admin/hardware/table.serial'),
|
||||
'visible' => false,
|
||||
],
|
||||
];
|
||||
]);
|
||||
}
|
||||
|
||||
$layout_end = [
|
||||
[
|
||||
'field' => 'target',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.target'),
|
||||
'visible' => true,
|
||||
'formatter' => 'polymorphicItemFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'file',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.file_name'),
|
||||
'visible' => true,
|
||||
'formatter' => 'fileNameFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'file_download',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.download'),
|
||||
'visible' => true,
|
||||
'formatter' => 'fileDownloadButtonsFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'quantity',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'title' => trans('general.quantity'),
|
||||
],
|
||||
[
|
||||
'field' => 'note',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'title' => trans('general.notes'),
|
||||
'formatter' => 'notesFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'signature_file',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.signature'),
|
||||
'visible' => false,
|
||||
'formatter' => 'imageFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'log_meta',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'visible' => true,
|
||||
'title' => trans('admin/hardware/table.changed'),
|
||||
'formatter' => 'changeLogFormatter',
|
||||
],
|
||||
[
|
||||
'field' => 'remote_ip',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('admin/settings/general.login_ip'),
|
||||
],
|
||||
[
|
||||
'field' => 'user_agent',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('admin/settings/general.login_user_agent'),
|
||||
],
|
||||
[
|
||||
'field' => 'action_source',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('general.action_source'),
|
||||
],
|
||||
];
|
||||
if (! in_array('target', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'target',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.target'),
|
||||
'visible' => true,
|
||||
'formatter' => 'polymorphicItemFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
$merged = array_merge($layout_start, $extra, $layout_end);
|
||||
if (! in_array('file', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'file',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.file_name'),
|
||||
'visible' => true,
|
||||
'formatter' => 'fileNameFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
return json_encode($merged);
|
||||
if (! in_array('file_download', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'file_download',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.download'),
|
||||
'visible' => true,
|
||||
'formatter' => 'fileDownloadButtonsFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('quantity', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'quantity',
|
||||
'searchable' => false,
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'title' => trans('general.quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('note', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'note',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => true,
|
||||
'title' => trans('general.notes'),
|
||||
'formatter' => 'notesFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('signature_file', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'signature_file',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'switchable' => true,
|
||||
'title' => trans('general.signature'),
|
||||
'visible' => false,
|
||||
'formatter' => 'imageFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('log_meta', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'log_meta',
|
||||
'searchable' => false,
|
||||
'sortable' => false,
|
||||
'visible' => true,
|
||||
'title' => trans('admin/hardware/table.changed'),
|
||||
'formatter' => 'changeLogFormatter',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('remote_ip', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'remote_ip',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('admin/settings/general.login_ip'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('user_agent', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'user_agent',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('admin/settings/general.login_user_agent'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array('action_source', $hide_fields)) {
|
||||
array_push($layout,
|
||||
[
|
||||
'field' => 'action_source',
|
||||
'searchable' => true,
|
||||
'sortable' => true,
|
||||
'visible' => false,
|
||||
'title' => trans('general.action_source'),
|
||||
]);
|
||||
}
|
||||
|
||||
return json_encode($layout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Presenters;
|
||||
|
||||
use App\Models\Asset;
|
||||
use App\Models\Setting;
|
||||
use App\Models\SnipeModel;
|
||||
|
||||
abstract class Presenter
|
||||
@@ -98,6 +100,31 @@ abstract class Presenter
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to take user created URL and dynamically fill in the needed values per item
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function dynamicUrl($dynamic_url)
|
||||
{
|
||||
$url = (str_replace('{LOCALE}', Setting::getSettings()->locale, $dynamic_url));
|
||||
|
||||
if ($this->model instanceof Asset) {
|
||||
$url = (str_replace('{SERIAL}', urlencode($this->model->serial), $url));
|
||||
$url = (str_replace('{MODEL_NAME}', urlencode($this->model->model->name), $url));
|
||||
$url = (str_replace('{MODEL_NUMBER}', urlencode($this->model->model->model_number), $url));
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
$url = (str_replace('{SERIAL}', urlencode($this->serial), $url));
|
||||
$url = (str_replace('{MODEL_NAME}', urlencode($this->model_name), $url));
|
||||
$url = (str_replace('{MODEL_NUMBER}', urlencode($this->model_number), $url));
|
||||
|
||||
return $url;
|
||||
|
||||
}
|
||||
|
||||
public function __get($property)
|
||||
{
|
||||
if (method_exists($this, $property)) {
|
||||
|
||||
@@ -166,24 +166,6 @@ class AuthServiceProvider extends ServiceProvider
|
||||
}
|
||||
});
|
||||
|
||||
// Gate::define('accessories.files', function ($user) {
|
||||
// if ($user->hasAccess('accessories.files')) {
|
||||
// return true;
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// Gate::define('components.files', function ($user) {
|
||||
// if ($user->hasAccess('components.files')) {
|
||||
// return true;
|
||||
// }
|
||||
// });
|
||||
//
|
||||
// Gate::define('consumables.files', function ($user) {
|
||||
// if ($user->hasAccess('consumables.files')) {
|
||||
// return true;
|
||||
// }
|
||||
// });
|
||||
|
||||
// Can the user import CSVs?
|
||||
Gate::define('import', function ($user) {
|
||||
if ($user->hasAccess('import')) {
|
||||
@@ -191,12 +173,6 @@ class AuthServiceProvider extends ServiceProvider
|
||||
}
|
||||
});
|
||||
|
||||
Gate::define('licenses.files', function ($user) {
|
||||
if ($user->hasAccess('licenses.files')) {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
Gate::define('assets.view.encrypted_custom_fields', function ($user) {
|
||||
if ($user->hasAccess('assets.view.encrypted_custom_fields')) {
|
||||
return true;
|
||||
|
||||
@@ -43,10 +43,10 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
/**
|
||||
* Asset Breadcrumbs
|
||||
*/
|
||||
if ((request()->is('hardware*')) && (request()->status != '')) {
|
||||
if ((request()->is('hardware*')) && (request()->status_type != '')) {
|
||||
Breadcrumbs::for('hardware.index', fn (Trail $trail) => $trail->parent('home', route('home'))
|
||||
->push(trans('general.assets'), route('hardware.index'))
|
||||
->push(trans('general.'.strtolower(e(request()->status))), route('hardware.index', ['status' => request()->status]))
|
||||
->push(trans('general.'.strtolower(e(request()->status_type))), route('hardware.index', ['status_type' => request()->status_type]))
|
||||
);
|
||||
|
||||
} else {
|
||||
@@ -342,14 +342,16 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
->push(trans('admin/locations/table.clone'), route('locations.create'))
|
||||
);
|
||||
|
||||
Breadcrumbs::for('locations.show', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
|
||||
->push($location->name, route('locations.show', $location))
|
||||
);
|
||||
Breadcrumbs::for('locations.show', function (Trail $trail, Location $location) {
|
||||
$trail->parent('locations.index', route('locations.index'));
|
||||
$this->pushLocationHierarchy($trail, $location);
|
||||
});
|
||||
|
||||
Breadcrumbs::for('locations.edit', fn (Trail $trail, Location $location) => $trail->parent('locations.index', route('locations.index'))
|
||||
->push($location->display_name, route('locations.show', $location))
|
||||
->push(trans('general.update'))
|
||||
);
|
||||
Breadcrumbs::for('locations.edit', function (Trail $trail, Location $location) {
|
||||
$trail->parent('locations.index', route('locations.index'));
|
||||
$this->pushLocationHierarchy($trail, $location);
|
||||
$trail->push(trans('general.update'));
|
||||
});
|
||||
|
||||
/**
|
||||
* Maintenances Breadcrumbs
|
||||
@@ -510,4 +512,22 @@ class BreadcrumbsServiceProvider extends ServiceProvider
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Append parent -> child location breadcrumbs recursively for a location.
|
||||
*/
|
||||
private function pushLocationHierarchy(Trail $trail, Location $location): void
|
||||
{
|
||||
$ancestorChain = [];
|
||||
$cursor = $location;
|
||||
|
||||
while ($cursor !== null) {
|
||||
array_unshift($ancestorChain, $cursor);
|
||||
$cursor = $cursor->parent;
|
||||
}
|
||||
|
||||
foreach ($ancestorChain as $node) {
|
||||
$trail->push($node->name, route('locations.show', $node));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class NumericEncrypted implements ValidationRule
|
||||
$fail(trans('validation.numeric', ['attribute' => $attributeName]));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
report($e->getMessage());
|
||||
report($e);
|
||||
$fail(trans('general.something_went_wrong'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ class RegexEncrypted implements ValidationRule
|
||||
$fail(trans('validation.regex', ['attribute' => $attributeName]));
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
report($e->getMessage());
|
||||
report($e);
|
||||
$fail(trans('general.something_went_wrong'));
|
||||
}
|
||||
}
|
||||
|
||||
+54
-60
@@ -7,7 +7,6 @@ use App\Models\Labels\Label as LabelModel;
|
||||
use App\Models\Labels\Sheet;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Traits\Macroable;
|
||||
use TCPDF;
|
||||
@@ -25,34 +24,29 @@ class Label implements View
|
||||
*/
|
||||
protected $data;
|
||||
|
||||
|
||||
/**
|
||||
* TCPDF output destination.
|
||||
* "I" - inline by default.
|
||||
* See TCPDF's Output method for details.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private string $destination = 'I';
|
||||
|
||||
public function __construct() {
|
||||
$this->data = new Collection();
|
||||
public function __construct()
|
||||
{
|
||||
$this->data = new Collection;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the PDF label.
|
||||
*
|
||||
* @param callable|null $callback
|
||||
*/
|
||||
public function render(callable $callback = null)
|
||||
public function render(?callable $callback = null)
|
||||
{
|
||||
$settings = $this->data->get('settings');
|
||||
$assets = $this->data->get('assets');
|
||||
$offset = $this->data->get('offset');
|
||||
|
||||
|
||||
// If disabled, pass to legacy view
|
||||
if ((!$settings->label2_enable)) {
|
||||
if ((! $settings->label2_enable)) {
|
||||
return view('hardware/labels')
|
||||
->with('assets', $assets)
|
||||
->with('settings', $settings)
|
||||
@@ -60,7 +54,7 @@ class Label implements View
|
||||
->with('count', $this->data->get('count'));
|
||||
}
|
||||
|
||||
$template = LabelModel::find($settings->label2_template);
|
||||
$template = LabelModel::find($settings->label2_template);
|
||||
|
||||
if ($template === null) {
|
||||
return redirect()->route('settings.labels.index')->with('error', trans('admin/settings/message.labels.null_template'));
|
||||
@@ -77,7 +71,6 @@ class Label implements View
|
||||
// Required for CJK languages, otherwise the embedded font can get too massive
|
||||
$pdf->SetFontSubsetting(true);
|
||||
|
||||
|
||||
// Reset parameters
|
||||
$pdf->SetPrintHeader(false);
|
||||
$pdf->SetPrintFooter(false);
|
||||
@@ -91,20 +84,20 @@ class Label implements View
|
||||
|
||||
// Get fields from settings
|
||||
$fieldDefinitions = collect(explode(';', $settings->label2_fields))
|
||||
->filter(fn($fieldString) => !empty($fieldString))
|
||||
->map(fn($fieldString) => Field::fromString($fieldString));
|
||||
->filter(fn ($fieldString) => ! empty($fieldString))
|
||||
->map(fn ($fieldString) => Field::fromString($fieldString));
|
||||
|
||||
// Prepare data
|
||||
$data = $assets
|
||||
->map(function ($asset) use ($template, $settings, $fieldDefinitions) {
|
||||
|
||||
$assetData = new Collection();
|
||||
$assetData = new Collection;
|
||||
|
||||
$assetData->put('asset', $asset);
|
||||
$assetData->put('id', $asset->id);
|
||||
$assetData->put('tag', $asset->asset_tag);
|
||||
|
||||
if ($template->getSupportTitle() && !empty($settings->label2_title)) {
|
||||
if ($template->getSupportTitle() && ! empty($settings->label2_title)) {
|
||||
$title = str_replace('{COMPANY}', data_get($asset, 'company.name'), $settings->label2_title);
|
||||
$assetData->put('title', $title);
|
||||
}
|
||||
@@ -112,47 +105,46 @@ class Label implements View
|
||||
if ($template->getSupportLogo()) {
|
||||
|
||||
$logo = null;
|
||||
|
||||
// Should we use the assets assigned company logo? (A.K.A. "Is `Labels > Use Asset Logo` enabled?"), and do we have a company logo?
|
||||
if ($settings->label2_asset_logo && $asset->company && $asset->company->image!='') {
|
||||
if ($settings->label2_asset_logo && $asset->company && $asset->company->image != '') {
|
||||
$logo = Storage::disk('public')->path('companies/'.e($asset->company->image));
|
||||
} elseif (!empty($settings->label_logo)) {
|
||||
} elseif (! empty($settings->label_logo)) {
|
||||
// Use the general site label logo, if available
|
||||
$logo = Storage::disk('public')->path('/'.e($settings->label_logo));
|
||||
$logo = Storage::disk('public')->path('/'.e(basename($settings->label_logo)));
|
||||
} elseif (! empty($asset->is_label_preview)) {
|
||||
$logo = public_path('img/label-preview-logo.png');
|
||||
}
|
||||
|
||||
if (!empty($logo)) {
|
||||
if (! empty($logo)) {
|
||||
$assetData->put('logo', $logo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if ($template->getSupport1DBarcode()) {
|
||||
$barcode1DType = $settings->label2_1d_type;
|
||||
if ($barcode1DType != 'none') {
|
||||
$assetData->put('barcode1d', (object)[
|
||||
'type' => $barcode1DType,
|
||||
'content' => $asset->asset_tag,
|
||||
]);
|
||||
}
|
||||
if ($template->getSupport1DBarcode()) {
|
||||
$barcode1DType = $settings->label2_1d_type;
|
||||
if ($barcode1DType != 'none') {
|
||||
$assetData->put('barcode1d', (object) [
|
||||
'type' => $barcode1DType,
|
||||
'content' => $asset->asset_tag,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($template->getSupport2DBarcode()) {
|
||||
$barcode2DType = $settings->label2_2d_type;
|
||||
if (($barcode2DType != 'none') && (!is_null($barcode2DType))) {
|
||||
}
|
||||
|
||||
$label2_2d_prefix = $settings->label2_2d_prefix ? e($settings->label2_2d_prefix) : '';
|
||||
if ($template->getSupport2DBarcode()) {
|
||||
$barcode2DType = $settings->label2_2d_type;
|
||||
if (($barcode2DType != 'none') && (! is_null($barcode2DType))) {
|
||||
|
||||
$label2_2d_prefix = $settings->label2_2d_prefix ? e($settings->label2_2d_prefix) : '';
|
||||
switch ($settings->label2_2d_target) {
|
||||
case 'ht_tag':
|
||||
$barcode2DTarget = route('ht/assetTag', $asset->asset_tag);
|
||||
case 'ht_tag':
|
||||
$barcode2DTarget = route('ht/assetTag', $asset->asset_tag);
|
||||
break;
|
||||
case 'plain_asset_id':
|
||||
case 'plain_asset_id':
|
||||
$barcode2DTarget = $label2_2d_prefix.(string) $asset->id;
|
||||
break;
|
||||
case 'plain_asset_tag':
|
||||
case 'plain_asset_tag':
|
||||
$barcode2DTarget = $label2_2d_prefix.$asset->asset_tag;
|
||||
break;
|
||||
case 'plain_serial_number':
|
||||
case 'plain_serial_number':
|
||||
$barcode2DTarget = $label2_2d_prefix.$asset->serial;
|
||||
break;
|
||||
case 'plain_model_number':
|
||||
@@ -176,21 +168,21 @@ class Label implements View
|
||||
default:
|
||||
$barcode2DTarget = route('hardware.show', $asset);
|
||||
break;
|
||||
}
|
||||
$assetData->put('barcode2d', (object)[
|
||||
'type' => $barcode2DType,
|
||||
'content' => $barcode2DTarget,
|
||||
]);
|
||||
}
|
||||
$assetData->put('barcode2d', (object) [
|
||||
'type' => $barcode2DType,
|
||||
'content' => $barcode2DTarget,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$fields = $fieldDefinitions
|
||||
->map(fn($field) => $field->toArray($asset))
|
||||
->filter(fn($field) => $field != null)
|
||||
->reduce(function($myFields, $field) {
|
||||
->map(fn ($field) => $field->toArray($asset))
|
||||
->filter(fn ($field) => $field != null)
|
||||
->reduce(function ($myFields, $field) {
|
||||
// Remove Duplicates
|
||||
$toAdd = $field
|
||||
->filter(fn($o) => !$myFields->contains('dataSource', $o['dataSource']))
|
||||
->filter(fn ($o) => ! $myFields->contains('dataSource', $o['dataSource']))
|
||||
// For fields that have multiple options, we need to combine them
|
||||
// into a single field so all values are displayed.
|
||||
->reduce(function ($previous, $current) {
|
||||
@@ -214,14 +206,15 @@ class Label implements View
|
||||
// We'll set the label to an empty string since we
|
||||
// injected the label into the value field above.
|
||||
$previous['label'] = '';
|
||||
|
||||
return $previous;
|
||||
});
|
||||
|
||||
return $toAdd ? $myFields->push($toAdd) : $myFields;
|
||||
}, new Collection());
|
||||
}, new Collection);
|
||||
|
||||
$emptyRowsCount = $settings->label2_empty_row_count;
|
||||
if($emptyRowsCount) {
|
||||
if ($emptyRowsCount) {
|
||||
// Create empty rows
|
||||
$emptyRows = collect(range(1, $emptyRowsCount))->map(function () {
|
||||
return [
|
||||
@@ -235,15 +228,16 @@ class Label implements View
|
||||
$fieldsWithEmpty = $emptyRows->merge($fields);
|
||||
|
||||
$assetData->put('fields', $fieldsWithEmpty->take($template->getSupportFields()));
|
||||
|
||||
return $assetData;
|
||||
} else {
|
||||
$assetData->put('fields', $fields->take($template->getSupportFields()));
|
||||
|
||||
return $assetData;
|
||||
}
|
||||
else{
|
||||
$assetData->put('fields', $fields->take($template->getSupportFields()));
|
||||
return $assetData;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
if ($template instanceof Sheet) {
|
||||
$template->setLabelIndexOffset($offset ?? 0);
|
||||
}
|
||||
@@ -263,9 +257,10 @@ class Label implements View
|
||||
public function with($key, $value = null)
|
||||
{
|
||||
$this->data->put($key, $value);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get the array of view data.
|
||||
*
|
||||
@@ -295,5 +290,4 @@ class Label implements View
|
||||
{
|
||||
return self::NAME;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user