Compare commits
722 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 | |||
| 9576871ff9 | |||
| 968724f369 | |||
| 4444a63b92 | |||
| 9924112d08 | |||
| 368796c40e | |||
| b9f6b2bbb8 | |||
| 86afa9d201 | |||
| 294d320aa0 | |||
| bdd44061f3 | |||
| 8545d2d703 | |||
| 61f3180d74 | |||
| 9efcb09836 | |||
| 80b7ebd508 | |||
| 4545cf8989 | |||
| 4dc5e8bbdb | |||
| 0261776778 | |||
| 1dfce30a32 | |||
| d7f44fdda4 | |||
| 8facdcd55c | |||
| 582b8858bc | |||
| 6d4264bc58 | |||
| 340433f418 | |||
| 107576eb01 | |||
| ede406c904 | |||
| 3b875ce6ec | |||
| c89e14ae52 | |||
| cff2fc0f16 | |||
| e8b637b900 | |||
| 84bb484761 | |||
| 25c8fdd5d6 | |||
| 6beaea8be9 | |||
| 7952bdefa8 | |||
| 280d16637a | |||
| cc397f6846 | |||
| bec443ce97 | |||
| 8417007eb8 | |||
| 3db77f05e9 | |||
| 3c1eb27ce1 | |||
| 614a2cd5de | |||
| 616d0f00f9 | |||
| ef22fb256b | |||
| 6e0dbc94d7 | |||
| 328a724920 | |||
| f9e620a77f | |||
| 334f27424e | |||
| 45b7df15c3 | |||
| 316f1be3d0 | |||
| a500dd4e9e | |||
| 4fc35e30c4 | |||
| 920676fbd7 | |||
| c2c90dd614 | |||
| c69b83da3f | |||
| 3d43de0763 | |||
| 413b571ce8 | |||
| e777d3a54c | |||
| 1981c7daef | |||
| 6a802f9c3c | |||
| f64912e461 | |||
| 6e3567f0bf | |||
| 9406b600f9 | |||
| 1398b4cbd6 | |||
| bde097a827 | |||
| a4ad7a0baf | |||
| a3927f25ce | |||
| d5d8084f95 | |||
| b48fe19617 | |||
| f802ea4d38 | |||
| 8107588576 | |||
| 531dce4305 | |||
| 44e81dfb8a | |||
| b4753e369c | |||
| 7a5842712b | |||
| b479cdf358 | |||
| 222277de9a | |||
| e285ee2931 | |||
| c4b20a16ce | |||
| ae7d967227 | |||
| 2135efe8a9 | |||
| 00c617b2b8 | |||
| 8dd105a31a | |||
| 66aeaaefdb | |||
| eb68789327 | |||
| d73ab0daa0 | |||
| bce9a91408 | |||
| 43808b00ad | |||
| 17e8ef8e30 | |||
| d02278930d | |||
| c2597a788b | |||
| 227afd3965 | |||
| fc97b68503 | |||
| d83ec4ea05 | |||
| 420bf9162d | |||
| 8b1ec3d54b | |||
| ea3d970743 | |||
| a0c905de33 | |||
| 3517e040c4 | |||
| cf1fb87b63 | |||
| b2389fb67c | |||
| f9f57fb161 | |||
| a9f7d42d77 | |||
| 537861c232 | |||
| 4c493efb24 | |||
| 323d308c73 | |||
| 07ddc0e574 | |||
| d159f6a3db | |||
| 1bcfe94818 | |||
| f7db8ef03d | |||
| 1b2a46d7a0 | |||
| 0ff2a971a4 | |||
| dce076157b | |||
| 7cc0aa336b | |||
| 5b193f7a7a | |||
| e290c70732 | |||
| a73e68fa1a | |||
| b0578757d2 | |||
| bf8082f0b9 | |||
| 04bebca323 | |||
| 0fe753b7da | |||
| 24c3c01851 | |||
| a6a211f386 | |||
| 603aa39e3f | |||
| 66607069fe | |||
| 54badc5545 | |||
| 64c07aa7b6 | |||
| 9d40df179d | |||
| af5e87970e | |||
| e9d7189e16 | |||
| 1cc15c3931 | |||
| dfb4cae5ed | |||
| c4c8750b26 | |||
| aa25a68af8 | |||
| ad5c5e27bd | |||
| 141e28d627 | |||
| d0b28b6e65 | |||
| 068e6c0e92 | |||
| 95a342fc25 | |||
| b21efb91b5 | |||
| d363793c56 | |||
| 7e687e91c2 | |||
| 255c7e323f | |||
| d3fd535605 | |||
| 5992525d8e | |||
| 03b0e24289 | |||
| f7f58ba12d | |||
| 595b5c865f | |||
| 2b2a015f51 | |||
| 8160ebf854 | |||
| dc27169129 | |||
| 8d7cf50089 | |||
| 8498b9b6bd | |||
| aead8f6c56 | |||
| d58fda626e | |||
| 0f753ae5b5 | |||
| 3371ac9552 | |||
| 1351df90f9 | |||
| 02b6a928b4 | |||
| a1c72d8972 | |||
| 62596c875e | |||
| 282388fb33 | |||
| afbd31ca01 | |||
| f1d32b8cae | |||
| fa7033f4e3 | |||
| 46bcc7ede8 | |||
| 2adc10ed6e | |||
| ab8a88350f | |||
| e1809f1c19 | |||
| b8cc9ac788 | |||
| 40c2a49e98 | |||
| 1894f55474 | |||
| 18a4c88ead | |||
| 210fd6399a | |||
| b91882f5dd | |||
| 6af827f595 | |||
| 200d92186c | |||
| cc971dba4c | |||
| 35c0b3b8b1 | |||
| cf637f8f65 | |||
| c86226c13b | |||
| eaed40966a | |||
| 5083207477 | |||
| 5f57402903 | |||
| 4c9ffe4eb9 | |||
| f705a2e24d | |||
| 61a8567cc3 | |||
| 8e33a644cf | |||
| 1482abc8b9 | |||
| af31dfd19c | |||
| 53c090fd6c | |||
| 1aef412b13 | |||
| f9956cf617 | |||
| bc7473d863 | |||
| 84c42999e4 | |||
| 5f1566d6ab | |||
| 87c5e3e01e | |||
| 27a554bebd | |||
| eec2773c88 | |||
| c1c3c6724f | |||
| 0223304ec7 | |||
| 08f9879ada | |||
| 29559485e2 | |||
| 6e100177da | |||
| d36e17eaae | |||
| 51e60d2d6b | |||
| 995276643c | |||
| a3c792619d | |||
| 946161a514 | |||
| 075be02f96 | |||
| 3664bbf423 | |||
| 820b41927d | |||
| 7ff5927409 | |||
| f93b40f402 | |||
| 7d226a6dca | |||
| 7b2edd0762 | |||
| 5dc0401f23 | |||
| b5164e4c92 | |||
| e1a06fa188 | |||
| b2ec69ce7f | |||
| a9706f6d5e | |||
| cee2b77fbc | |||
| 3425599461 | |||
| 104123495d | |||
| c2735fce90 | |||
| d4ff9dce24 | |||
| 5c8a62d9b7 | |||
| 87fa71c599 | |||
| 0b08b8007b | |||
| 3e93684451 | |||
| 218190d989 | |||
| 0a4ec84875 | |||
| 33402f5e0c | |||
| d0e57cfab6 | |||
| 0ebd103e21 | |||
| 95ba562021 | |||
| 67f5fb72c3 | |||
| 62cadfdc8c | |||
| 27e8995cce | |||
| e2406e33d8 | |||
| da2dd79765 | |||
| 4568180e85 | |||
| cc8f59d9e0 | |||
| 446f5f3cef | |||
| d84eb43278 | |||
| bb72dab877 | |||
| b06762ea4f | |||
| 33d75cdd2d | |||
| 3372b7a647 | |||
| 4e0d34b826 | |||
| 324c937cc4 | |||
| 908d04e98a | |||
| 79fd541498 | |||
| 8c360a26e5 | |||
| 93ae07cc89 | |||
| 713a726bde | |||
| 100f1683be | |||
| ed201b24d6 | |||
| 0b7ebcaeb0 | |||
| 52a9993b0d | |||
| 4e733fab02 | |||
| 3257c6e709 | |||
| ece4ed4caf | |||
| fa04891ddb | |||
| 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 | |||
| a026ca92ff | |||
| 1b7fe4f728 | |||
| 2bf2d55c6e | |||
| 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 | |||
| 4d9bc04d58 | |||
| 3527b86b6d | |||
| 30f4e1eb08 | |||
| 8142ab64f6 |
@@ -4262,6 +4262,15 @@
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "Husky-Devel",
|
||||
"name": "Peter Gallwas",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/75509373?v=4",
|
||||
"profile": "https://www.husky.nz",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+18
-1
@@ -90,7 +90,16 @@ IMAGE_LIB=gd
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: BACKUP SETTINGS
|
||||
# --------------------------------------------
|
||||
# Backup filesystem configuration
|
||||
# - BACKUP_FILESYSTEM_DRIVER: Driver to use (local, s3, etc.)
|
||||
# Default: local (backward compatible)
|
||||
# Set to s3 to use S3 for backups (requires PRIVATE_AWS_* credentials)
|
||||
# - BACKUP_FILESYSTEM_ROOT: Root path/prefix
|
||||
# For local driver: leave commented for default to storage_path("app")
|
||||
# For S3 driver: empty string = bucket root, or specify prefix like "backups/"
|
||||
#--------------------------------------------
|
||||
BACKUP_FILESYSTEM_DRIVER=local
|
||||
#BACKUP_FILESYSTEM_ROOT=
|
||||
MAIL_BACKUP_NOTIFICATION_DRIVER=null
|
||||
MAIL_BACKUP_NOTIFICATION_ADDRESS=null
|
||||
BACKUP_ENV=true
|
||||
@@ -149,6 +158,14 @@ REDIS_PORT=null
|
||||
MEMCACHED_HOST=null
|
||||
MEMCACHED_PORT=null
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: S3 PROXY
|
||||
# When enabled, public uploads (images, logos, avatars) are served through
|
||||
# the application instead of directly from S3. This allows using a single
|
||||
# fully private S3 bucket for all storage.
|
||||
# --------------------------------------------
|
||||
PUBLIC_S3_PROXY=false
|
||||
|
||||
# --------------------------------------------
|
||||
# OPTIONAL: PUBLIC S3 Settings
|
||||
# --------------------------------------------
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Pint: Models
|
||||
9623fa4d87e7fb38307028338c6991afb7d4e099
|
||||
|
||||
# Pint: Actions
|
||||
a3c7410c35388af08997b1c52adebda1056488a6
|
||||
|
||||
# Pint: Console
|
||||
8bce38b9187d23089a28a4f3a4ab960ac7471e90
|
||||
|
||||
# Pint: Enums and Events
|
||||
f7b82ad1ff513a25d775c20b58e9a8ce23461ec2
|
||||
|
||||
# Pint: Exceptions
|
||||
2e7046a810ce1f7562dec9d3ee4fee0cbc7262db
|
||||
|
||||
# Pint: Non-api controllers
|
||||
9bc92f57c8a29ac0e89c2d3f72f23c6c64567dd8
|
||||
|
||||
# Pint: Api controllers
|
||||
1e5d426e70dcd72fd7e87c2b11ff42fe3cc7a1a4
|
||||
|
||||
# Pint: Middleware
|
||||
ec6caf9b5959c6c57bd7be047e91bbb70fc303a7
|
||||
|
||||
# Pint: Requests
|
||||
93168326da54fa87880570c82df3ccbf3ff152e1
|
||||
|
||||
# Pint: Traits and Transformers
|
||||
a613380811f63f51e2951d2f4b8454d5274d5cdf
|
||||
|
||||
# Pint: Importer
|
||||
3e831bf9b3cc060f11c88ec69a9313131de8ee1f
|
||||
|
||||
# Pint: Jobs and Listeners
|
||||
317b1a462e079bf96d492dd3782de38b7144be9f
|
||||
|
||||
# Pint: Livewire
|
||||
53f2ef2ca11b0571de758b101f08f259de7830cf
|
||||
|
||||
# Pint: Mail
|
||||
de607e7d83704b30f809238c44d3d759196a77db
|
||||
|
||||
# Pint: Notifications
|
||||
31043d1f5cb5d287c0ab2ca2ba1ae08665bc6ad5
|
||||
|
||||
# Pint: Observers and policies
|
||||
b2c0a21230977443536655e43e524773e2ad9e27
|
||||
|
||||
# Pint: Presenters
|
||||
55d46cbefec5fe0bb7e28b859d540977d2cfee46
|
||||
|
||||
# Pint: Providers
|
||||
8b658a19b9182bf9a19e34bc9101ee11a13ed85b
|
||||
|
||||
# Pint: Config
|
||||
c1a93e3ac890ed1fc1c27ba6c431f6b58ff661d6
|
||||
|
||||
# Pint: Lang and resources
|
||||
84fdb5d6c19bf7882cb91d42fe8768fc0db0ce67
|
||||
|
||||
# Pint: Database
|
||||
b5a46a370f85c6e87c8a9fa4a4593424bb027712
|
||||
|
||||
# Pint: Tests
|
||||
d84eb43278177a9bcdfffe04c94d933eb49f2c48
|
||||
446f5f3cefdc1837a65fd4bc983741b29f821a78
|
||||
+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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+31
-11
@@ -7,6 +7,10 @@ class IconHelper
|
||||
public static function icon($type)
|
||||
{
|
||||
switch ($type) {
|
||||
case 'apple':
|
||||
return 'fa-brands fa-apple';
|
||||
case 'google':
|
||||
return 'fa-brands fa-google';
|
||||
case 'checkout':
|
||||
return 'fa-solid fa-rotate-left';
|
||||
case 'checkin':
|
||||
@@ -16,6 +20,8 @@ class IconHelper
|
||||
return 'fas fa-pencil-alt';
|
||||
case 'clone':
|
||||
return 'far fa-clone';
|
||||
case 'upload':
|
||||
return 'fa-solid fa-file-circle-plus';
|
||||
case 'delete':
|
||||
case 'upload deleted':
|
||||
return 'fas fa-trash';
|
||||
@@ -114,7 +120,7 @@ class IconHelper
|
||||
case 'password':
|
||||
return 'fa-solid fa-key';
|
||||
case 'api-key':
|
||||
return 'fa-solid fa-user-secret';
|
||||
return 'fas fa-user-secret';
|
||||
case 'nav-toggle':
|
||||
return 'fas fa-bars';
|
||||
case 'dashboard':
|
||||
@@ -137,19 +143,25 @@ class IconHelper
|
||||
return 'fa-regular fa-clipboard';
|
||||
case 'paperclip':
|
||||
return 'fas fa-paperclip';
|
||||
case 'files':
|
||||
return 'fa-solid fa-file-contract';
|
||||
case 'contact-card':
|
||||
return 'fa-regular fa-id-card';
|
||||
case 'files':
|
||||
return 'fa-regular fa-file';
|
||||
case 'eula':
|
||||
case 'eulas':
|
||||
return 'fa-regular fa-handshake';
|
||||
case 'star':
|
||||
case 'vip':
|
||||
return 'fa-solid fa-star';
|
||||
case 'remote':
|
||||
return 'fa-solid fa-house-laptop';
|
||||
case 'more-info':
|
||||
case 'support':
|
||||
return 'far fa-life-ring';
|
||||
case 'calendar':
|
||||
return 'fas fa-calendar';
|
||||
case 'plus':
|
||||
return 'fas fa-plus';
|
||||
case 'history':
|
||||
return 'fas fa-history';
|
||||
return 'fa-solid fa-timeline';
|
||||
case 'more-files':
|
||||
return 'fa-solid fa-laptop-file';
|
||||
case 'maintenances':
|
||||
@@ -213,7 +225,7 @@ class IconHelper
|
||||
case 'highlight':
|
||||
return 'fa-solid fa-highlighter';
|
||||
case 'manager':
|
||||
return 'fa-solid fa-building-user';
|
||||
return 'fa-solid fa-user-tie';
|
||||
case 'company':
|
||||
return 'fa-regular fa-building';
|
||||
case 'parent':
|
||||
@@ -222,18 +234,24 @@ class IconHelper
|
||||
return 'fa-solid fa-hashtag';
|
||||
case 'depreciation':
|
||||
return 'fa-solid fa-arrows-down-to-line';
|
||||
case 'calendar':
|
||||
return 'fas fa-calendar';
|
||||
case 'depreciation-calendar':
|
||||
case 'expiration':
|
||||
case 'terminates':
|
||||
return 'fa-regular fa-calendar-xmark';
|
||||
case 'deleted-date':
|
||||
case 'end_date':
|
||||
return 'fa-solid fa-calendar-xmark';
|
||||
case 'expected_checkin':
|
||||
case 'start_date':
|
||||
return 'fa-solid fa-calendar-check';
|
||||
case 'eol':
|
||||
return 'fa-regular fa-calendar-days';
|
||||
case 'manufacturer':
|
||||
return 'fa-solid fa-industry';
|
||||
case 'fieldset':
|
||||
return 'fa-regular fa-rectangle-list';
|
||||
case 'deleted-date':
|
||||
return 'fa-solid fa-calendar-xmark';
|
||||
case 'eol':
|
||||
return 'fa-regular fa-calendar-days';
|
||||
case 'category':
|
||||
return 'fa-solid fa-icons';
|
||||
case 'cost':
|
||||
@@ -248,6 +266,8 @@ class IconHelper
|
||||
return 'fa-solid fa-file-invoice';
|
||||
case 'checkout-all':
|
||||
return 'fa-solid fa-arrows-down-to-people';
|
||||
case 'checkin-all':
|
||||
return 'fa-solid fa-arrows-turn-right';
|
||||
case 'square-right':
|
||||
return 'fa-regular fa-square-caret-right';
|
||||
case 'square-left':
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Helpers;
|
||||
|
||||
use Illuminate\Contracts\Filesystem\FileNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
@@ -20,7 +19,14 @@ class StorageHelper
|
||||
return response()->download(Storage::disk($disk)->path($filename)); // works for PRIVATE or public?!
|
||||
|
||||
case 's3':
|
||||
return redirect()->away(Storage::disk($disk)->temporaryUrl($filename, now()->addMinutes(5))); // works for private or public, I guess?
|
||||
Storage::disk($disk)->temporaryUrl(
|
||||
$filename,
|
||||
now()->addMinutes(5),
|
||||
[
|
||||
'ResponseContentType' => 'application/octet-stream',
|
||||
'ResponseContentDisposition' => 'attachment; filename=download-file',
|
||||
]
|
||||
);
|
||||
|
||||
default:
|
||||
return Storage::disk($disk)->download($filename);
|
||||
@@ -119,33 +125,4 @@ class StorageHelper
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether to show the file inline or download it.
|
||||
*/
|
||||
public static function showOrDownloadFile($file, $filename)
|
||||
{
|
||||
|
||||
$headers = [];
|
||||
|
||||
if (request('inline') == 'true') {
|
||||
|
||||
$headers = [
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
|
||||
// This is NOT allowed as inline - force it to be displayed as text in the browser
|
||||
if (self::allowSafeInline($file) != true) {
|
||||
$headers = array_merge($headers, ['Content-Type' => 'text/plain']);
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else seems okay, but the file doesn't exist on the server.
|
||||
if (Storage::missing($file)) {
|
||||
throw new FileNotFoundException;
|
||||
}
|
||||
|
||||
return Storage::download($file, $filename, $headers);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Contracts\View\View;
|
||||
@@ -88,12 +89,53 @@ class AccessoryCheckoutController extends Controller
|
||||
$request->input('note'),
|
||||
[],
|
||||
$accessory->checkout_qty,
|
||||
$request->boolean('sign_in_place'),
|
||||
));
|
||||
|
||||
$request->request->add(['checkout_to_type' => request('checkout_to_type')]);
|
||||
$request->request->add(['assigned_to' => $target->id]);
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
// When sign_in_place is requested for a user checkout, redirect to the
|
||||
// acceptance/signature page so the user can sign in person.
|
||||
if ($request->boolean('sign_in_place') && ! in_array($request->input('checkout_to_type'), ['asset', 'location'], true)) {
|
||||
$targetUser = User::find($target->id);
|
||||
|
||||
if (! $targetUser instanceof User) {
|
||||
return redirect()->route('accessories.checkout.show', $accessory)
|
||||
->with('error', trans('admin/accessories/message.checkout.user_does_not_exist'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Accessory::class)
|
||||
->where('checkoutable_id', $accessory->id)
|
||||
->where('assigned_to_id', $targetUser->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($accessory);
|
||||
$acceptance->assignedTo()->associate($targetUser);
|
||||
$acceptance->qty = $accessory->checkout_qty;
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $accessory->id,
|
||||
'sign_in_place_resource_type' => 'Accessories',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/accessories/message.checkout.success'));
|
||||
}
|
||||
|
||||
// Redirect to the new accessory page
|
||||
return Helper::getRedirectOption($request, $accessory->id, 'Accessories')
|
||||
|
||||
@@ -6,9 +6,16 @@ use App\Events\CheckoutAccepted;
|
||||
use App\Events\CheckoutDeclined;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AcceptSignatureRequest;
|
||||
use App\Mail\CheckoutAcceptanceResponseMail;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Actionlog;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Company;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Notifications\AcceptanceItemAcceptedNotification;
|
||||
@@ -30,7 +37,8 @@ class AcceptanceController extends Controller
|
||||
*/
|
||||
public function index(): View
|
||||
{
|
||||
$acceptances = CheckoutAcceptance::forUser(auth()->user())->pending()->get();
|
||||
$user = auth()->user();
|
||||
$acceptances = CheckoutAcceptance::forUser($user)->pending()->get();
|
||||
|
||||
return view('account/accept.index', compact('acceptances'));
|
||||
}
|
||||
@@ -40,27 +48,43 @@ class AcceptanceController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function create($id): View|RedirectResponse
|
||||
public function create(Request $request, $id): View|RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $currentUser instanceof User) {
|
||||
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$acceptance = CheckoutAcceptance::find($id);
|
||||
|
||||
if (is_null($acceptance)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
if (! $acceptance) {
|
||||
return redirect()->route('account.accept')->with('error', trans('general.generic_model_not_found', ['model' => trans('general.accept_eula')]));
|
||||
}
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
if (! Company::isCurrentUserHasAccess($acceptance->checkoutable)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('general.error_user_company'));
|
||||
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
return view('account/accept.create', compact('acceptance'));
|
||||
$checkedOutAt = Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false);
|
||||
$checkedOutBy = $this->resolveCheckoutActorName($acceptance);
|
||||
|
||||
return view('account/accept.create', compact('acceptance', 'isSignInPlaceAdminFlow', 'checkedOutAt', 'checkedOutBy'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,22 +92,32 @@ class AcceptanceController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function store(Request $request, $id): RedirectResponse
|
||||
public function store(AcceptSignatureRequest $request, CheckoutAcceptance $acceptance): RedirectResponse
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
if (! $acceptance = CheckoutAcceptance::find($id)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
if (! $currentUser instanceof User) {
|
||||
return redirect()->route('account.accept')->with('error', trans('general.insufficient_permissions'));
|
||||
}
|
||||
|
||||
$assigned_user = User::find($acceptance->assigned_to_id);
|
||||
$assignedUser = User::find($acceptance->assigned_to_id);
|
||||
$settings = Setting::getSettings();
|
||||
$requiresSignature = (string) $settings->require_accept_signature === '1';
|
||||
$sig_filename = '';
|
||||
$encodedSignatureImage = null;
|
||||
|
||||
if (! $acceptance->isPending()) {
|
||||
if ($this->isStaleSignInPlaceAdminAttempt($acceptance, $currentUser)) {
|
||||
return $this->redirectToIntendedSignInPlaceDestination($request, $acceptance)
|
||||
->with('warning', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.asset_already_accepted'));
|
||||
}
|
||||
|
||||
if (! $acceptance->isCheckedOutTo(auth()->user())) {
|
||||
$isSignInPlaceAdminFlow = $this->isSignInPlaceAdminFlow($acceptance);
|
||||
|
||||
if (! $acceptance->isCheckedOutTo($currentUser) && (! $isSignInPlaceAdminFlow)) {
|
||||
return redirect()->route('account.accept')->with('error', trans('admin/users/message.error.incorrect_user_accepted'));
|
||||
}
|
||||
|
||||
@@ -112,14 +146,25 @@ class AcceptanceController extends Controller
|
||||
$item = $acceptance->checkoutable_type::find($acceptance->checkoutable_id);
|
||||
|
||||
// If signatures are required, make sure we have one
|
||||
if (Setting::getSettings()->require_accept_signature == '1') {
|
||||
if ($requiresSignature) {
|
||||
|
||||
// The item was accepted, check for a signature
|
||||
if ($request->filled('signature_output')) {
|
||||
$sig_filename = 'siglog-'.Str::uuid().'-'.date('Y-m-d-his').'.png';
|
||||
$data_uri = $request->input('signature_output');
|
||||
$encoded_image = explode(',', $data_uri);
|
||||
$decoded_image = base64_decode($encoded_image[1]);
|
||||
$dataUri = (string) $request->input('signature_output');
|
||||
$encodedSignatureImage = Str::contains($dataUri, ',')
|
||||
? Str::after($dataUri, ',')
|
||||
: $dataUri;
|
||||
|
||||
$decoded_image = base64_decode($encodedSignatureImage, true);
|
||||
|
||||
if ($decoded_image === false) {
|
||||
return redirect()->back()->with('error', trans('general.shitty_browser'));
|
||||
}
|
||||
|
||||
$decoded_image = $this->flattenSignatureBackgroundToWhite($decoded_image);
|
||||
$encodedSignatureImage = base64_encode($decoded_image);
|
||||
|
||||
Storage::put('private_uploads/signatures/'.$sig_filename, (string) $decoded_image);
|
||||
|
||||
// No image data is present, kick them back.
|
||||
@@ -133,7 +178,34 @@ class AcceptanceController extends Controller
|
||||
// This is needed for TCPDF to properly embed the image if it's a png and the cache isn't writable
|
||||
$encoded_logo = null;
|
||||
if (($settings->acceptance_pdf_logo) && (Storage::disk('public')->exists($settings->acceptance_pdf_logo))) {
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.$settings->acceptance_pdf_logo));
|
||||
$encoded_logo = base64_encode(file_get_contents(public_path().'/uploads/'.basename($settings->acceptance_pdf_logo)));
|
||||
}
|
||||
|
||||
if ($isSignInPlaceAdminFlow && (! $acceptance->signed_in_place || (int) $acceptance->signed_in_place_admin !== (int) $currentUser->id)) {
|
||||
$acceptance->forceFill([
|
||||
'signed_in_place' => true,
|
||||
'signed_in_place_admin' => $currentUser->id,
|
||||
])->save();
|
||||
}
|
||||
|
||||
// Determine signed_in_place and admin for PDF/email output
|
||||
$signedInPlace = $isSignInPlaceAdminFlow ? true : (bool) $acceptance->signed_in_place;
|
||||
$signedInPlaceAdmin = null;
|
||||
if ($isSignInPlaceAdminFlow) {
|
||||
$signedInPlaceAdmin = [
|
||||
'name' => $currentUser->display_name,
|
||||
'username' => $currentUser->username,
|
||||
'email' => $currentUser->email,
|
||||
];
|
||||
} elseif ($acceptance->signed_in_place && $acceptance->signed_in_place_admin) {
|
||||
$admin = User::find($acceptance->signed_in_place_admin);
|
||||
if ($admin) {
|
||||
$signedInPlaceAdmin = [
|
||||
'name' => $admin->display_name,
|
||||
'username' => $admin->username,
|
||||
'email' => $admin->email,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Get the data array ready for the notifications and PDF generation
|
||||
@@ -142,41 +214,66 @@ class AcceptanceController extends Controller
|
||||
'item_name' => $item->display_name, // this handles licenses seats, which don't have a 'name' field
|
||||
'item_model' => $item->model?->name,
|
||||
'item_serial' => $item->serial,
|
||||
'item_status' => $item->assetstatus?->name,
|
||||
'item_status' => $item->status?->name,
|
||||
'eula' => $item->getEula(),
|
||||
'note' => $request->input('note'),
|
||||
'check_out_date' => Helper::getFormattedDateObject($acceptance->created_at, 'datetime', false),
|
||||
'accepted_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'declined_date' => Helper::getFormattedDateObject(now()->format('Y-m-d H:i:s'), 'datetime', false),
|
||||
'assigned_to' => $assigned_user->display_name,
|
||||
'email' => $assigned_user->email,
|
||||
'employee_num' => $assigned_user->employee_num,
|
||||
'assigned_to' => $assignedUser->display_name,
|
||||
'email' => $assignedUser->email,
|
||||
'employee_num' => $assignedUser->employee_num,
|
||||
'site_name' => $settings->site_name,
|
||||
'company_name' => $item->company?->name ?? $settings->site_name,
|
||||
'signature' => (($sig_filename && array_key_exists('1', $encoded_image))) ? $encoded_image[1] : null,
|
||||
'signature' => ($sig_filename !== '') ? $encodedSignatureImage : null,
|
||||
'logo' => ($encoded_logo) ?? null,
|
||||
'date_settings' => $settings->date_display_format,
|
||||
'qty' => $acceptance->qty ?? 1,
|
||||
'signed_in_place' => $signedInPlace,
|
||||
];
|
||||
if ($signedInPlaceAdmin) {
|
||||
$data['signed_in_place_admin'] = $signedInPlaceAdmin;
|
||||
}
|
||||
|
||||
if ($request->input('asset_acceptance') == 'accepted') {
|
||||
// Add custom fields for asset (show_in_email = 1, field_encrypted = 0)
|
||||
$customFields = [];
|
||||
if ($item instanceof Asset && $item->model && $item->model->fieldset) {
|
||||
$fields = $item->model->fieldset->fields->where('show_in_email', true)->where('field_encrypted', false);
|
||||
foreach ($fields as $field) {
|
||||
$label = $field->name;
|
||||
$dbColumn = $field->db_column;
|
||||
$value = $item->$dbColumn;
|
||||
if (! is_null($value) && $value !== '') {
|
||||
$customFields[] = [
|
||||
'label' => $label,
|
||||
'value' => $value,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (! empty($customFields)) {
|
||||
$data['custom_fields'] = $customFields;
|
||||
}
|
||||
|
||||
if ($request->input('asset_acceptance') === 'accepted') {
|
||||
$pdf_filename = 'accepted-'.$acceptance->checkoutable_id.'-'.$acceptance->display_checkoutable_type.'-eula-'.date('Y-m-d-h-i-s').'.pdf';
|
||||
|
||||
// Generate the PDF content
|
||||
$pdf_content = $acceptance->generateAcceptancePdf($data, $acceptance);
|
||||
Storage::put('private_uploads/eula-pdfs/'.$pdf_filename, $pdf_content);
|
||||
|
||||
// Log the acceptance
|
||||
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'));
|
||||
$accept_qty = $request->input('accept_qty', $acceptance->qty ?? 1);
|
||||
$acceptance->accept($sig_filename, $item->getEula(), $pdf_filename, $request->input('note'), $accept_qty);
|
||||
|
||||
$alwaysSendAcceptanceCopy = (bool) (config('app.always_send_email') || config('app.always_send_eula'));
|
||||
|
||||
// Send the PDF to the signing user
|
||||
if (($request->input('send_copy') == '1') && ($assigned_user->email != '')) {
|
||||
if (($alwaysSendAcceptanceCopy || ($request->input('send_copy') === '1')) && ($assignedUser->email !== '')) {
|
||||
|
||||
// Add the attachment for the signing user into the $data array
|
||||
$data['file'] = $pdf_filename;
|
||||
try {
|
||||
$assigned_user->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assigned_user->locale));
|
||||
$assignedUser->notify((new AcceptanceItemAcceptedToUserNotification($data))->locale($assignedUser->locale));
|
||||
} catch (Exception $e) {
|
||||
Log::warning($e);
|
||||
}
|
||||
@@ -215,7 +312,7 @@ class AcceptanceController extends Controller
|
||||
$recipient,
|
||||
$request->input('asset_acceptance') === 'accepted',
|
||||
));
|
||||
Log::debug('Send email notification sucess on checkout acceptance response.');
|
||||
Log::debug('Send email notification success on checkout acceptance response.');
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error($e->getMessage());
|
||||
@@ -223,7 +320,163 @@ class AcceptanceController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if ($isSignInPlaceAdminFlow) {
|
||||
$request->request->add(['assigned_user' => $assignedUser?->id]);
|
||||
|
||||
$redirect = Helper::getRedirectOption(
|
||||
$request,
|
||||
session('sign_in_place_item_id'),
|
||||
session('sign_in_place_resource_type'),
|
||||
);
|
||||
|
||||
session()->forget([
|
||||
'sign_in_place_acceptance_id',
|
||||
'sign_in_place_item_id',
|
||||
'sign_in_place_resource_type',
|
||||
]);
|
||||
|
||||
return $redirect->with('success', $return_msg);
|
||||
}
|
||||
|
||||
return redirect()->to('account/accept')->with('success', $return_msg);
|
||||
|
||||
}
|
||||
|
||||
private function isSignInPlaceAdminFlow(CheckoutAcceptance $acceptance): bool
|
||||
{
|
||||
$currentUser = auth()->user();
|
||||
|
||||
return ((int) session('sign_in_place_acceptance_id') === (int) $acceptance->id)
|
||||
&& ($currentUser?->can('checkout', $acceptance->checkoutable));
|
||||
}
|
||||
|
||||
private function resolveCheckoutActorName(CheckoutAcceptance $acceptance): ?string
|
||||
{
|
||||
[$itemType, $itemId] = $this->resolveCheckoutLogItem($acceptance);
|
||||
|
||||
$checkoutLog = Actionlog::query()
|
||||
->where('action_type', 'checkout')
|
||||
->where('item_type', $itemType)
|
||||
->where('item_id', $itemId)
|
||||
->where('target_type', User::class)
|
||||
->where('target_id', $acceptance->assigned_to_id)
|
||||
->where('created_at', '<=', $acceptance->created_at->copy()->addMinutes(5))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
return $checkoutLog?->adminuser?->display_name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action logs normalize license seat checkouts to the parent license.
|
||||
*
|
||||
* @return array{0: class-string, 1: int}
|
||||
*/
|
||||
private function resolveCheckoutLogItem(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [License::class, (int) $checkoutable->license_id];
|
||||
}
|
||||
|
||||
return [$acceptance->checkoutable_type, (int) $acceptance->checkoutable_id];
|
||||
}
|
||||
|
||||
private function isStaleSignInPlaceAdminAttempt(CheckoutAcceptance $acceptance, User $currentUser): bool
|
||||
{
|
||||
$redirectOption = session('redirect_option');
|
||||
$checkoutToType = session('checkout_to_type');
|
||||
|
||||
if (session('sign_in_place') !== true) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($redirectOption === 'target' && $checkoutToType === 'user' && empty($acceptance->assigned_to_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ! $acceptance->isCheckedOutTo($currentUser)
|
||||
&& $currentUser->can('checkout', $acceptance->checkoutable)
|
||||
&& ($checkoutToType === 'user');
|
||||
}
|
||||
|
||||
private function redirectToIntendedSignInPlaceDestination(Request $request, CheckoutAcceptance $acceptance): RedirectResponse
|
||||
{
|
||||
if (empty($acceptance->assigned_to_id)) {
|
||||
return redirect()->route('account.accept');
|
||||
}
|
||||
|
||||
[$itemId, $resourceType] = $this->resolveRedirectTarget($acceptance);
|
||||
|
||||
$request->request->add(['assigned_user' => $acceptance->assigned_to_id]);
|
||||
|
||||
return Helper::getRedirectOption($request, $itemId, $resourceType);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private function resolveRedirectTarget(CheckoutAcceptance $acceptance): array
|
||||
{
|
||||
$checkoutable = $acceptance->checkoutable;
|
||||
|
||||
if ($checkoutable instanceof Asset) {
|
||||
return [(int) $checkoutable->id, 'Assets'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Accessory) {
|
||||
return [(int) $checkoutable->id, 'Accessories'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof Consumable) {
|
||||
return [(int) $checkoutable->id, 'Consumables'];
|
||||
}
|
||||
|
||||
if ($checkoutable instanceof LicenseSeat) {
|
||||
return [(int) $checkoutable->license_id, 'Licenses'];
|
||||
}
|
||||
|
||||
return [(int) $acceptance->checkoutable_id, session('sign_in_place_resource_type', 'Assets')];
|
||||
}
|
||||
|
||||
private function flattenSignatureBackgroundToWhite(string $signatureBinary): string
|
||||
{
|
||||
if (! function_exists('imagecreatefromstring') || ! function_exists('imagecreatetruecolor')) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$source = @imagecreatefromstring($signatureBinary);
|
||||
|
||||
if ($source === false) {
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$width = imagesx($source);
|
||||
$height = imagesy($source);
|
||||
$flattened = imagecreatetruecolor($width, $height);
|
||||
|
||||
if ($flattened === false) {
|
||||
imagedestroy($source);
|
||||
|
||||
return $signatureBinary;
|
||||
}
|
||||
|
||||
$white = imagecolorallocate($flattened, 255, 255, 255);
|
||||
imagefilledrectangle($flattened, 0, 0, $width, $height, $white);
|
||||
imagecopy($flattened, $source, 0, 0, 0, 0, $width, $height);
|
||||
|
||||
ob_start();
|
||||
imagepng($flattened);
|
||||
$output = ob_get_clean();
|
||||
|
||||
imagedestroy($source);
|
||||
imagedestroy($flattened);
|
||||
|
||||
return is_string($output) ? $output : $signatureBinary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ class ActionlogController extends Controller
|
||||
{
|
||||
public function displaySig($filename): RedirectResponse|Response|bool
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
// PHP doesn't let you handle file not found errors well with
|
||||
// file_get_contents, so we set the error reporting for just this class
|
||||
error_reporting(0);
|
||||
@@ -44,6 +46,7 @@ class ActionlogController extends Controller
|
||||
|
||||
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
if ($actionlog = Actionlog::where('filename', $filename)->with('user')->with('target')->firstOrFail()) {
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Requests\AccessoryCheckoutRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreAccessoryRequest;
|
||||
use App\Http\Transformers\AccessoriesTransformer;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\AccessoryCheckout;
|
||||
@@ -69,20 +70,9 @@ class AccessoriesController extends Controller
|
||||
->with('category', 'company', 'manufacturer', 'checkouts', 'location', 'supplier', 'adminuser')
|
||||
->withCount('checkouts as checkouts_count');
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$accessories->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$accessories->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$accessories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
@@ -411,4 +401,16 @@ class AccessoriesController extends Controller
|
||||
|
||||
return (new SelectlistTransformer)->transformSelectlist($accessories);
|
||||
}
|
||||
|
||||
public function history(Request $request, Accessory $accessory): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $accessory);
|
||||
$historyQuery = $accessory->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreAssetModelRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\AssetModelsTransformer;
|
||||
use App\Http\Transformers\AssetsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -92,21 +93,9 @@ class AssetModelsController extends Controller
|
||||
->withCount('assignedAssets as assets_assigned_count')
|
||||
->withCount('archivedAssets as assets_archived_count');
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$assetmodels->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$assetmodels->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$assetmodels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->input('status') == 'deleted') {
|
||||
@@ -350,4 +339,16 @@ class AssetModelsController extends Controller
|
||||
|
||||
return (new SelectlistTransformer)->transformSelectlist($assetmodels);
|
||||
}
|
||||
|
||||
public function history(Request $request, AssetModel $model): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $model);
|
||||
$historyQuery = $model->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,8 @@ use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreAssetRequest;
|
||||
use App\Http\Requests\UpdateAssetRequest;
|
||||
use App\Http\Traits\MigratesLegacyAssetLocations;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\AssetsTransformer;
|
||||
use App\Http\Transformers\ComponentsTransformer;
|
||||
use App\Http\Transformers\LicensesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\AccessoryCheckout;
|
||||
use App\Models\Actionlog;
|
||||
@@ -21,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;
|
||||
@@ -39,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
|
||||
@@ -128,9 +129,9 @@ class AssetsController extends Controller
|
||||
'location',
|
||||
'rtd_location',
|
||||
'category',
|
||||
'status_label',
|
||||
'manufacturer',
|
||||
'supplier',
|
||||
'status',
|
||||
'jobtitle',
|
||||
'assigned_to',
|
||||
'created_by',
|
||||
@@ -143,17 +144,6 @@ class AssetsController extends Controller
|
||||
$allowed_columns[] = $field->db_column_name();
|
||||
}
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
}
|
||||
|
||||
$assets = Asset::select('assets.*')
|
||||
// ->addSelect([
|
||||
// 'first_checkout_at' => Actionlog::query()
|
||||
@@ -167,7 +157,7 @@ class AssetsController extends Controller
|
||||
->with(
|
||||
'model',
|
||||
'location',
|
||||
'assetstatus',
|
||||
'status',
|
||||
'company',
|
||||
'defaultLoc',
|
||||
'assignedTo',
|
||||
@@ -185,21 +175,9 @@ class AssetsController extends Controller
|
||||
$assets->InModelList($non_deprecable_models->toArray());
|
||||
}
|
||||
|
||||
// These are used by the API to query against specific ID numbers.
|
||||
// They are also used by the individual searches on detail pages like
|
||||
// locations, etc.
|
||||
|
||||
// Search custom fields by column name
|
||||
foreach ($all_custom_fields as $field) {
|
||||
if ($request->filled($field->db_column_name()) && $field->db_column_name()) {
|
||||
$assets->where('assets.'.$field->db_column_name(), '=', $request->input($field->db_column_name()));
|
||||
}
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$assets->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$assets->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$assets->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,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;
|
||||
@@ -417,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':
|
||||
@@ -489,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') {
|
||||
@@ -529,7 +515,7 @@ class AssetsController extends Controller
|
||||
{
|
||||
$this->authorize('index', Asset::class);
|
||||
$assets = Asset::where('serial', $serial)->with([
|
||||
'assetstatus',
|
||||
'status',
|
||||
'assignedTo',
|
||||
'company',
|
||||
'defaultLoc',
|
||||
@@ -573,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)
|
||||
) {
|
||||
@@ -585,14 +571,13 @@ class AssetsController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('admin/hardware/message.does_not_exist')), 200);
|
||||
}
|
||||
|
||||
public function licenses(Request $request, $id): array
|
||||
public function licenses(Asset $asset): array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$this->authorize('view', $asset);
|
||||
$this->authorize('view', License::class);
|
||||
$asset = Asset::where('id', $id)->withTrashed()->firstorfail();
|
||||
$licenses = $asset->licenses()->get();
|
||||
$licenses = $asset->licenseseats()->get();
|
||||
|
||||
return (new LicensesTransformer)->transformLicenses($licenses, $licenses->count());
|
||||
return (new AssetsTransformer)->transformLicensesCheckedToAsset($licenses, $licenses->count());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -614,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();
|
||||
}
|
||||
|
||||
@@ -642,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;
|
||||
@@ -977,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.'));
|
||||
}
|
||||
@@ -1127,13 +1117,29 @@ class AssetsController extends Controller
|
||||
$this->authorize('audit', Asset::class);
|
||||
|
||||
$settings = Setting::getSettings();
|
||||
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
|
||||
|
||||
// Allow the asset tag to be passed in the payload (legacy method)
|
||||
if ($request->filled('asset_tag')) {
|
||||
$dt = null;
|
||||
if (! is_null($settings->audit_interval)) {
|
||||
$dt = Carbon::now()->addMonths($settings->audit_interval)->toDateString();
|
||||
}
|
||||
|
||||
$audit_by_field = $request->input('audit_by_field', 'asset_tag');
|
||||
$audit_key = $request->input('audit_key', null);
|
||||
|
||||
// If they have selected to scan by serial, use that
|
||||
if (($settings->unique_serial == '1') && ($audit_by_field == 'serial') && ($audit_key)) {
|
||||
$asset = Asset::where('serial', '=', trim($audit_key))->first();
|
||||
|
||||
// If they have selected by asset tag, use that
|
||||
} elseif (($audit_by_field == 'asset_tag') && ($audit_key)) {
|
||||
$asset = Asset::where('asset_tag', '=', trim($audit_key))->first();
|
||||
|
||||
// Allow the asset tag to be passed in the payload (legacy method)
|
||||
} elseif ($request->filled('asset_tag')) {
|
||||
$asset = Asset::where('asset_tag', '=', $request->input('asset_tag'))->first();
|
||||
}
|
||||
|
||||
// If none of the above were selected, fall back to the route-model-binding
|
||||
if ($asset) {
|
||||
|
||||
$originalValues = $asset->getRawOriginal();
|
||||
@@ -1155,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),
|
||||
];
|
||||
|
||||
@@ -1197,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()));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1230,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);
|
||||
|
||||
}
|
||||
|
||||
@@ -1265,7 +1278,7 @@ class AssetsController extends Controller
|
||||
$assets = Asset::select('assets.*')
|
||||
->with(
|
||||
'location',
|
||||
'assetstatus',
|
||||
'status',
|
||||
'assetlog',
|
||||
'company',
|
||||
'assignedTo',
|
||||
@@ -1340,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)
|
||||
@@ -1358,15 +1370,39 @@ class AssetsController extends Controller
|
||||
|
||||
public function assignedComponents(Request $request, Asset $asset): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$this->authorize('view', $asset);
|
||||
|
||||
$asset->loadCount('components');
|
||||
$total = $asset->components_count;
|
||||
|
||||
$components = $asset->load(['components' => fn ($query) => $query->applyOffsetAndLimit($total)])->components;
|
||||
$allowed_columns = [
|
||||
'created_at',
|
||||
'assigned_qty',
|
||||
'note',
|
||||
];
|
||||
|
||||
return (new ComponentsTransformer)->transformComponents($components, $total);
|
||||
$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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1453,4 +1489,16 @@ class AssetsController extends Controller
|
||||
], $e->getMessage()), 500);
|
||||
}
|
||||
}
|
||||
|
||||
public function history(Request $request, Asset $asset): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $asset);
|
||||
$historyQuery = $asset->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Categories\DestroyCategoryAction;
|
||||
use App\Exceptions\ItemStillHasChildren;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\CategoriesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -26,62 +27,50 @@ class CategoriesController extends Controller
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request): array
|
||||
public function index(FilterRequest $request): array
|
||||
{
|
||||
$this->authorize('view', Category::class);
|
||||
$allowed_columns = [
|
||||
'id',
|
||||
'name',
|
||||
'category_type',
|
||||
'category_type',
|
||||
'use_default_eula',
|
||||
'eula_text',
|
||||
'require_acceptance',
|
||||
'checkin_email',
|
||||
'assets_count',
|
||||
'accessories_count',
|
||||
'consumables_count',
|
||||
'assets_count',
|
||||
'category_type',
|
||||
'checkin_email',
|
||||
'components_count',
|
||||
'licenses_count',
|
||||
'consumables_count',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'eula_text',
|
||||
'id',
|
||||
'image',
|
||||
'tag_color',
|
||||
'licenses_count',
|
||||
'name',
|
||||
'notes',
|
||||
'require_acceptance',
|
||||
'tag_color',
|
||||
'updated_at',
|
||||
'use_default_eula',
|
||||
];
|
||||
|
||||
$categories = Category::select([
|
||||
'id',
|
||||
'created_by',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'name', 'category_type',
|
||||
'use_default_eula',
|
||||
'eula_text',
|
||||
'require_acceptance',
|
||||
'category_type',
|
||||
'checkin_email',
|
||||
'created_at',
|
||||
'created_by',
|
||||
'eula_text',
|
||||
'id',
|
||||
'image',
|
||||
'tag_color',
|
||||
'name',
|
||||
'notes',
|
||||
'require_acceptance',
|
||||
'tag_color',
|
||||
'updated_at',
|
||||
'use_default_eula',
|
||||
])
|
||||
->with('adminuser')
|
||||
->withCount('accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'licenses as licenses_count', 'models as models_count');
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$categories->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$categories->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$categories->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -140,6 +129,11 @@ class CategoriesController extends Controller
|
||||
case 'created_by':
|
||||
$categories = $categories->OrderByCreatedBy($order);
|
||||
break;
|
||||
// This is annoying, since it's not a real relationship, which is what we usually use these switches for, but
|
||||
// we call the field has_eula, not eula_text, so there won't be a matching field
|
||||
case 'has_eula':
|
||||
$categories = $categories->orderBy('eula_text', $order);
|
||||
break;
|
||||
default:
|
||||
$categories = $categories->orderBy($column_sort, $order);
|
||||
break;
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\CompaniesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -21,7 +22,7 @@ class CompaniesController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Company::class);
|
||||
|
||||
@@ -49,8 +50,9 @@ class CompaniesController extends Controller
|
||||
->with('adminuser')
|
||||
->withCount('licenses as licenses_count', 'accessories as accessories_count', 'consumables as consumables_count', 'components as components_count', 'users as users_count');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$companies->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$companies->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
|
||||
@@ -6,9 +6,11 @@ use App\Events\CheckoutableCheckedIn;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\ComponentsTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Component;
|
||||
use App\Models\ComponentAssignment;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -72,10 +74,9 @@ class ComponentsController extends Controller
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$components->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$components->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$components->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
@@ -247,17 +248,17 @@ class ComponentsController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function getAssets(Request $request, $id): array
|
||||
public function getAssets(Component $component, Request $request): array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
|
||||
$component = Component::findOrFail($id);
|
||||
$component_checkouts = ComponentAssignment::where('component_id', $component->id)->with('adminuser')->with('assets');
|
||||
|
||||
$offset = request('offset', 0);
|
||||
$limit = $request->input('limit', 50);
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$assets = $component->assets()
|
||||
$assets = $component_checkouts->assets()
|
||||
->where(function ($query) use ($request) {
|
||||
$search_str = '%'.$request->input('search').'%';
|
||||
$query->where('name', 'like', $search_str)
|
||||
@@ -271,7 +272,6 @@ class ComponentsController extends Controller
|
||||
$total = $assets->count();
|
||||
} else {
|
||||
$assets = $component->assets();
|
||||
|
||||
$total = $assets->count();
|
||||
$assets = $assets->skip($offset)->take($limit)->get();
|
||||
}
|
||||
@@ -387,4 +387,16 @@ class ComponentsController extends Controller
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, 'No matching checkouts for that component join record'));
|
||||
}
|
||||
|
||||
public function history(Request $request, Component $component): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $component);
|
||||
$historyQuery = $component->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ namespace App\Http\Controllers\Api;
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreConsumableRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\ConsumablesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\Company;
|
||||
@@ -24,7 +26,7 @@ class ConsumablesController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): array
|
||||
public function index(FilterRequest $request): array
|
||||
{
|
||||
$this->authorize('index', Consumable::class);
|
||||
|
||||
@@ -59,21 +61,9 @@ class ConsumablesController extends Controller
|
||||
'manufacturer',
|
||||
];
|
||||
|
||||
$filter = [];
|
||||
|
||||
if ($request->filled('filter')) {
|
||||
$filter = json_decode($request->input('filter'), true);
|
||||
|
||||
$filter = array_filter($filter, function ($key) use ($allowed_columns) {
|
||||
return in_array($key, $allowed_columns);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$consumables->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$consumables->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$consumables->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
@@ -367,4 +357,16 @@ class ConsumablesController extends Controller
|
||||
|
||||
return (new SelectlistTransformer)->transformSelectlist($consumables);
|
||||
}
|
||||
|
||||
public function history(Request $request, Consumable $consumable): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $consumable);
|
||||
$historyQuery = $consumable->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Requests\StoreDepartmentRequest;
|
||||
use App\Http\Transformers\DepartmentsTransformer;
|
||||
@@ -22,7 +23,7 @@ class DepartmentsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Department::class);
|
||||
$allowed_columns = ['id', 'name', 'image', 'users_count', 'notes', 'tag_color'];
|
||||
@@ -43,8 +44,9 @@ class DepartmentsController extends Controller
|
||||
'departments.notes',
|
||||
])->with('location')->with('manager')->with('company')->withCount('users as users_count');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$departments = $departments->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$departments->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\DepreciationsTransformer;
|
||||
use App\Models\Depreciation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,7 +19,7 @@ class DepreciationsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Depreciation::class);
|
||||
$allowed_columns = [
|
||||
@@ -33,14 +34,15 @@ class DepreciationsController extends Controller
|
||||
'licenses_count',
|
||||
];
|
||||
|
||||
$depreciations = Depreciation::select('id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by')
|
||||
$depreciations = Depreciation::select(['id', 'name', 'months', 'depreciation_min', 'depreciation_type', 'created_at', 'updated_at', 'created_by'])
|
||||
->with('adminuser')
|
||||
->withCount('assets as assets_count')
|
||||
->withCount('models as models_count')
|
||||
->withCount('licenses as licenses_count');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$depreciations = $depreciations->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$depreciations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
// Make sure the offset and limit are actually integers and do not exceed system limits
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\GroupsTransformer;
|
||||
use App\Models\Group;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -18,7 +20,7 @@ class GroupsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('superadmin');
|
||||
|
||||
@@ -26,8 +28,9 @@ class GroupsController extends Controller
|
||||
|
||||
$groups = Group::select(['id', 'name', 'permissions', 'notes', 'created_at', 'updated_at', 'created_by'])->with('adminuser')->withCount('users as users_count');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$groups = $groups->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$groups->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
@@ -75,14 +78,17 @@ class GroupsController extends Controller
|
||||
{
|
||||
$this->authorize('superadmin');
|
||||
$group = new Group;
|
||||
// Get all the available permissions
|
||||
$permissions = json_encode(config('permissions'));
|
||||
$groupPermissions = Helper::selectedPermissionsArray($permissions, $permissions);
|
||||
$defaultPermissions = Helper::selectedPermissionsArray(config('permissions'), config('permissions'));
|
||||
|
||||
$group->name = $request->input('name');
|
||||
$requestedPermissions = $request->has('permissions')
|
||||
? NormalizePermissionsPayloadAction::run($request->input('permissions'))
|
||||
: $defaultPermissions;
|
||||
|
||||
$group->fill($request->only(['name', 'notes']));
|
||||
$group->created_by = auth()->id();
|
||||
$group->notes = $request->input('notes');
|
||||
$group->permissions = json_encode($request->input('permissions', $groupPermissions));
|
||||
$group->permissions = json_encode(
|
||||
Helper::selectedPermissionsArray(config('permissions'), $requestedPermissions)
|
||||
);
|
||||
|
||||
if ($group->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.create')));
|
||||
@@ -122,9 +128,18 @@ class GroupsController extends Controller
|
||||
$this->authorize('superadmin');
|
||||
$group = Group::findOrFail($id);
|
||||
|
||||
$group->name = $request->input('name');
|
||||
$group->notes = $request->input('notes');
|
||||
$group->permissions = $request->input('permissions'); // Todo - some JSON validation stuff here
|
||||
// Fill only the keys present in the request, so PATCH skips absent fields naturally.
|
||||
$group->fill($request->only(['name', 'notes']));
|
||||
|
||||
// Preserve existing permissions when omitted from PATCH/PUT payload.
|
||||
if ($request->has('permissions')) {
|
||||
$group->permissions = json_encode(
|
||||
Helper::selectedPermissionsArray(
|
||||
config('permissions'),
|
||||
NormalizePermissionsPayloadAction::run($request->input('permissions'))
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ($group->save()) {
|
||||
return response()->json(Helper::formatStandardApiResponse('success', (new GroupsTransformer)->transformGroup($group), trans('admin/groups/message.success.update')));
|
||||
@@ -146,6 +161,10 @@ class GroupsController extends Controller
|
||||
{
|
||||
$this->authorize('superadmin');
|
||||
$group = Group::findOrFail($id);
|
||||
if (! $group->isDeletable()) {
|
||||
return response()
|
||||
->json(Helper::formatStandardApiResponse('error', null, trans('admin/groups/message.assoc_users')));
|
||||
}
|
||||
$group->delete();
|
||||
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, trans('admin/groups/message.delete.success')));
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\LicensesTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Models\License;
|
||||
@@ -21,7 +23,7 @@ class LicensesController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', License::class);
|
||||
|
||||
@@ -96,8 +98,9 @@ class LicensesController extends Controller
|
||||
$licenses->whereNull('expiration_date');
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$licenses = $licenses->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$licenses->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->input('deleted') == 'true') {
|
||||
@@ -275,4 +278,16 @@ class LicensesController extends Controller
|
||||
|
||||
return (new SelectlistTransformer)->transformSelectlist($licenses);
|
||||
}
|
||||
|
||||
public function history(Request $request, License $license): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $license);
|
||||
$historyQuery = $license->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\AssetsTransformer;
|
||||
use App\Http\Transformers\LocationsTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -31,7 +33,7 @@ class LocationsController extends Controller
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Location::class);
|
||||
$allowed_columns = [
|
||||
@@ -106,8 +108,9 @@ class LocationsController extends Controller
|
||||
$locations = Company::scopeCompanyables($locations);
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$locations = $locations->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$locations->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
@@ -308,7 +311,7 @@ class LocationsController extends Controller
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$this->authorize('view', $location);
|
||||
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
|
||||
$assets = Asset::where('location_id', '=', $location->id)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
|
||||
$assets = $assets->get();
|
||||
|
||||
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
|
||||
@@ -318,7 +321,7 @@ class LocationsController extends Controller
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$this->authorize('view', $location);
|
||||
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'assetstatus', 'location', 'company', 'defaultLoc');
|
||||
$assets = Asset::where('assigned_to', '=', $location->id)->where('assigned_type', '=', Location::class)->with('model', 'model.category', 'status', 'location', 'company', 'defaultLoc');
|
||||
$assets = $assets->get();
|
||||
|
||||
return (new AssetsTransformer)->transformAssets($assets, $assets->count(), $request);
|
||||
@@ -455,4 +458,16 @@ class LocationsController extends Controller
|
||||
|
||||
return (new SelectlistTransformer)->transformSelectlist($paginated_results);
|
||||
}
|
||||
|
||||
public function history(Request $request, Location $location): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $location);
|
||||
$historyQuery = $location->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Http\Transformers\MaintenancesTransformer;
|
||||
use App\Models\Asset;
|
||||
use App\Models\Company;
|
||||
@@ -31,15 +33,16 @@ class MaintenancesController extends Controller
|
||||
*
|
||||
* @since [v1.8]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
|
||||
$maintenances = Maintenance::select('maintenances.*')
|
||||
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.assetstatus', 'adminuser');
|
||||
->with('asset', 'asset.model', 'asset.location', 'asset.defaultLoc', 'supplier', 'asset.company', 'asset.status', 'adminuser', 'asset.assignedTo');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$maintenances = $maintenances->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$maintenances->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('asset_id')) {
|
||||
@@ -126,6 +129,10 @@ class MaintenancesController extends Controller
|
||||
$total = $maintenances->count();
|
||||
$maintenances = $maintenances->skip($offset)->take($limit)->get();
|
||||
|
||||
if (request()->input('format') == 'flat') {
|
||||
return (new MaintenancesTransformer)->transformMaintenancesFlat($maintenances, $total);
|
||||
}
|
||||
|
||||
return (new MaintenancesTransformer)->transformMaintenances($maintenances, $total);
|
||||
|
||||
}
|
||||
@@ -247,4 +254,18 @@ class MaintenancesController extends Controller
|
||||
return (new MaintenancesTransformer)->transformMaintenance($maintenance);
|
||||
|
||||
}
|
||||
|
||||
public function history(Request $request, Maintenance $maintenance): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Asset::class);
|
||||
$asset = $maintenance->asset;
|
||||
$this->authorize('history', $asset);
|
||||
$historyQuery = $maintenance->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Actions\Manufacturers\DeleteManufacturerAction;
|
||||
use App\Exceptions\ItemStillHasChildren;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\ManufacturersTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -28,7 +29,7 @@ class ManufacturersController extends Controller
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('view', Manufacturer::class);
|
||||
$allowed_columns = [
|
||||
@@ -81,8 +82,9 @@ class ManufacturersController extends Controller
|
||||
$manufacturers->onlyTrashed();
|
||||
}
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$manufacturers = $manufacturers->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$manufacturers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\ActionlogsTransformer;
|
||||
use App\Models\Actionlog;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
class ReportsController extends Controller
|
||||
{
|
||||
@@ -17,32 +19,54 @@ class ReportsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): JsonResponse|array
|
||||
public function index(FilterRequest $request): JsonResponse|array
|
||||
{
|
||||
$this->authorize('activity.view');
|
||||
|
||||
// If the user doesn't have permission to view the item or the target,
|
||||
// then they shouldn't be able to see the activity log for that item or target,
|
||||
// but if they have the general activity view permission,
|
||||
// then they can see all activity logs regardless of the item or target.
|
||||
if ((! Gate::allows('activity.view')) && (($request->filled('target_type')) && ($request->filled('target_id'))) || (($request->filled('item_type')) && ($request->filled('item_id')))) {
|
||||
|
||||
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
|
||||
$target = Helper::normalizeFullModelName(request()->input('target_type'));
|
||||
$target::find(request()->input('target_id'))?->withTrashed();
|
||||
$this->authorize('view', $target);
|
||||
}
|
||||
|
||||
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
|
||||
$item = Helper::normalizeFullModelName(request()->input('item_type'));
|
||||
$item::find(request()->input('item_id'))?->withTrashed();
|
||||
$this->authorize('view', $item);
|
||||
}
|
||||
|
||||
} else {
|
||||
$this->authorize('activity.view');
|
||||
}
|
||||
|
||||
$actionlogs = Actionlog::with('item', 'user', 'adminuser', 'target', 'location');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$actionlogs = $actionlogs->TextSearch(e($request->input('search')));
|
||||
}
|
||||
|
||||
if (($request->filled('target_type')) && ($request->filled('target_id'))) {
|
||||
$actionlogs = $actionlogs->where('target_id', '=', $request->input('target_id'))
|
||||
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('target_type')));
|
||||
->where('target_type', '=', Helper::normalizeFullModelName($request->input('target_type')));
|
||||
}
|
||||
|
||||
if (($request->filled('item_type')) && ($request->filled('item_id'))) {
|
||||
$actionlogs = $actionlogs->where(function ($query) use ($request) {
|
||||
$query->where('item_id', '=', $request->input('item_id'))
|
||||
->where('item_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')))
|
||||
->where('item_type', '=', Helper::normalizeFullModelName($request->input('item_type')))
|
||||
->orWhere(function ($query) use ($request) {
|
||||
$query->where('target_id', '=', $request->input('item_id'))
|
||||
->where('target_type', '=', 'App\\Models\\'.ucwords($request->input('item_type')));
|
||||
->where('target_type', '=', Helper::normalizeFullModelName($request->input('item_type')));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$actionlogs->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('action_type')) {
|
||||
$actionlogs = $actionlogs->where('action_type', '=', $request->input('action_type'));
|
||||
}
|
||||
@@ -99,5 +123,6 @@ class ReportsController extends Controller
|
||||
$actionlogs = $actionlogs->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($actionlogs, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +162,13 @@ class SettingsController extends Controller
|
||||
public function ajaxTestEmail(): JsonResponse
|
||||
{
|
||||
if (! config('app.lock_passwords')) {
|
||||
|
||||
if (config('mail.reply_to.address') == '') {
|
||||
Log::debug('MAIL_REPLYTO_ADDR not set in env. Skipping mail test.');
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.mail_test_no_email')], 403);
|
||||
}
|
||||
|
||||
try {
|
||||
Notification::send(Setting::first(), new MailTest);
|
||||
Log::debug('Attempting to sending to '.config('mail.reply_to.address'));
|
||||
@@ -286,6 +293,11 @@ class SettingsController extends Controller
|
||||
*/
|
||||
public function downloadBackup($file): JsonResponse|BinaryFileResponse
|
||||
{
|
||||
$file = $this->sanitizeBackupFilename($file);
|
||||
|
||||
if ($file === null) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_not_found')), 404);
|
||||
}
|
||||
|
||||
$path = storage_path('app/backups');
|
||||
|
||||
@@ -329,4 +341,21 @@ class SettingsController extends Controller
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private function sanitizeBackupFilename(mixed $filename): ?string
|
||||
{
|
||||
$filename = trim((string) $filename);
|
||||
|
||||
if ($filename === '' || str_contains($filename, "\0")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sanitized = basename($filename);
|
||||
|
||||
if (($sanitized === '') || ($sanitized === '.') || ($sanitized === '..')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ($sanitized === $filename) ? $sanitized : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Transformers\AssetsTransformer;
|
||||
use App\Http\Transformers\PieChartTransformer;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
@@ -23,7 +24,7 @@ class StatuslabelsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function index(Request $request): array
|
||||
public function index(FilterRequest $request): array
|
||||
{
|
||||
$this->authorize('view', Statuslabel::class);
|
||||
$allowed_columns = [
|
||||
@@ -38,8 +39,9 @@ class StatuslabelsController extends Controller
|
||||
|
||||
$statuslabels = Statuslabel::with('adminuser')->withCount('assets as assets_count');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$statuslabels = $statuslabels->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$statuslabels->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Exceptions\ItemStillHasLicenses;
|
||||
use App\Exceptions\ItemStillHasMaintenances;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\FilterRequest;
|
||||
use App\Http\Requests\ImageUploadRequest;
|
||||
use App\Http\Transformers\SelectlistTransformer;
|
||||
use App\Http\Transformers\SuppliersTransformer;
|
||||
@@ -31,7 +32,7 @@ class SuppliersController extends Controller
|
||||
*
|
||||
* @return Response
|
||||
*/
|
||||
public function index(Request $request): array
|
||||
public function index(FilterRequest $request): array
|
||||
{
|
||||
$this->authorize('view', Supplier::class);
|
||||
$allowed_columns = [
|
||||
@@ -67,8 +68,9 @@ class SuppliersController extends Controller
|
||||
->withCount('consumables as consumables_count')
|
||||
->with('adminuser');
|
||||
|
||||
if ($request->filled('search')) {
|
||||
$suppliers->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$suppliers->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('name')) {
|
||||
|
||||
@@ -32,7 +32,7 @@ class UploadedFilesController extends Controller
|
||||
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('view', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
|
||||
@@ -91,7 +91,7 @@ class UploadedFilesController extends Controller
|
||||
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('update', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
|
||||
@@ -141,7 +141,7 @@ class UploadedFilesController extends Controller
|
||||
{
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('view', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
|
||||
@@ -153,7 +153,7 @@ class UploadedFilesController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_id')), 200);
|
||||
}
|
||||
|
||||
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
|
||||
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.file_not_found'), 200));
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ class UploadedFilesController extends Controller
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
|
||||
return Storage::download(self::$map_storage_path[$object_type].'/'.$log->filename, $log->filename, $headers);
|
||||
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
|
||||
}
|
||||
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].'/'.$log->filename);
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
|
||||
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ class UploadedFilesController extends Controller
|
||||
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('update', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return response()->json(Helper::formatStandardApiResponse('error', null, trans('general.file_upload_status.invalid_object')));
|
||||
@@ -202,8 +202,8 @@ class UploadedFilesController extends Controller
|
||||
|
||||
if ($log) {
|
||||
// Check the file actually exists, and delete it
|
||||
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
|
||||
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
|
||||
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
|
||||
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
|
||||
}
|
||||
// Delete the record of the file
|
||||
if ($log->logUploadDelete($object, $log->filename)) {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\Permissions\NormalizePermissionsPayloadAction;
|
||||
use App\Actions\Permissions\PreserveUnauthorizedPrivilegedPermissionsAction;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\DeleteUserRequest;
|
||||
@@ -171,10 +173,9 @@ class UsersController extends Controller
|
||||
|
||||
}
|
||||
|
||||
if ((! is_null($filter)) && (count($filter)) > 0) {
|
||||
$users->ByFilter($filter);
|
||||
} elseif ($request->filled('search')) {
|
||||
$users->TextSearch($request->input('search'));
|
||||
// This invokes the Searchable model trait scopeTextSearch and will handle input by search or by advanced search filter
|
||||
if ($request->filled('filter') || $request->filled('search')) {
|
||||
$users->TextSearch($request->input('filter') ? $request->input('filter') : $request->input('search'));
|
||||
}
|
||||
|
||||
if ($request->filled('activated')) {
|
||||
@@ -437,27 +438,17 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('create', User::class);
|
||||
|
||||
$authenticatedUser = auth()->user();
|
||||
$user = new User;
|
||||
$user->fill($request->all());
|
||||
$user->company_id = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
$user->created_by = auth()->id();
|
||||
|
||||
if ($request->has('permissions')) {
|
||||
$permissions_array = $request->input('permissions');
|
||||
|
||||
if (! auth()->user()->isSuperUser()) {
|
||||
if ((is_array($permissions_array)) && (array_key_exists('superuser', $permissions_array))) {
|
||||
unset($permissions_array['superuser']);
|
||||
}
|
||||
}
|
||||
|
||||
if (! auth()->user()->isAdmin()) {
|
||||
if ((is_array($permissions_array)) && (array_key_exists('admin', $permissions_array))) {
|
||||
unset($permissions_array['admin']);
|
||||
}
|
||||
}
|
||||
|
||||
$user->permissions = $permissions_array;
|
||||
$user->permissions = json_encode(PreserveUnauthorizedPrivilegedPermissionsAction::run(
|
||||
requestedPermissions: NormalizePermissionsPayloadAction::run($request->input('permissions')),
|
||||
authenticatedUser: $authenticatedUser,
|
||||
));
|
||||
}
|
||||
|
||||
//
|
||||
@@ -536,6 +527,8 @@ class UsersController extends Controller
|
||||
{
|
||||
$this->authorize('update', $user);
|
||||
|
||||
$authenticatedUser = auth()->user();
|
||||
|
||||
/**
|
||||
* This is a janky hack to prevent people from changing admin demo user data on the public demo.
|
||||
* The $ids 1 and 2 are special since they are seeded as superadmins in the demo seeder.
|
||||
@@ -554,56 +547,31 @@ class UsersController extends Controller
|
||||
$user->password = bcrypt($request->input('password'));
|
||||
}
|
||||
|
||||
if ($request->filled('username')) {
|
||||
$user->username = $request->input('username');
|
||||
}
|
||||
|
||||
if ($request->filled('email')) {
|
||||
$user->email = $request->input('email');
|
||||
}
|
||||
|
||||
if ($request->filled('activated')) {
|
||||
$user->activated = $request->input('activated');
|
||||
}
|
||||
|
||||
// We need to use has() instead of filled()
|
||||
// here because we need to overwrite permissions
|
||||
// if someone needs to null them out
|
||||
|
||||
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;
|
||||
if ($request->has('username')) {
|
||||
$user->username = $request->input('username');
|
||||
}
|
||||
|
||||
}
|
||||
if ($request->has('email')) {
|
||||
$user->email = $request->input('email');
|
||||
}
|
||||
|
||||
if ($request->has('activated')) {
|
||||
$user->activated = $request->input('activated');
|
||||
}
|
||||
|
||||
if ($request->has('permissions')) {
|
||||
// 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')) {
|
||||
@@ -840,21 +808,27 @@ class UsersController extends Controller
|
||||
try {
|
||||
$user = User::find($request->input('id'));
|
||||
$this->authorize('update', $user);
|
||||
$user->two_factor_secret = null;
|
||||
$user->two_factor_enrolled = 0;
|
||||
$user->saveQuietly();
|
||||
|
||||
// Log the reset
|
||||
$logaction = new Actionlog;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $user->id;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $user->id;
|
||||
$logaction->created_at = date('Y-m-d H:i:s');
|
||||
$logaction->created_by = auth()->id();
|
||||
$logaction->logaction('2FA reset');
|
||||
if (auth()->user()->can('canEditAuthFields', $user) && auth()->user()->can('editableOnDemo')) {
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
|
||||
$user->two_factor_secret = null;
|
||||
$user->two_factor_enrolled = 0;
|
||||
$user->saveQuietly();
|
||||
|
||||
// Log the reset
|
||||
$logaction = new Actionlog;
|
||||
$logaction->target_type = User::class;
|
||||
$logaction->target_id = $user->id;
|
||||
$logaction->item_type = User::class;
|
||||
$logaction->item_id = $user->id;
|
||||
$logaction->created_at = date('Y-m-d H:i:s');
|
||||
$logaction->created_by = auth()->id();
|
||||
$logaction->logaction('2FA reset');
|
||||
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_success')], 200);
|
||||
}
|
||||
|
||||
return response()->json(['message' => trans('general.unauthorized')], 500);
|
||||
} catch (\Exception $e) {
|
||||
return response()->json(['message' => trans('admin/settings/general.two_factor_reset_error')], 500);
|
||||
}
|
||||
@@ -967,4 +941,16 @@ class UsersController extends Controller
|
||||
return response()->json(Helper::formatStandardApiResponse('success', null, $ldap_results['summary']), 200);
|
||||
|
||||
}
|
||||
|
||||
public function history(Request $request, User $user): JsonResponse|array
|
||||
{
|
||||
$this->authorize('history', $user);
|
||||
$historyQuery = $user->getHistory($request);
|
||||
$total = (clone $historyQuery)->count();
|
||||
$offset = ($request->input('offset') > $total) ? $total : app('api_offset_value');
|
||||
$limit = app('api_limit_value');
|
||||
$history = (clone $historyQuery)->skip($offset)->take($limit)->get();
|
||||
|
||||
return response()->json((new ActionlogsTransformer)->transformActionlogs($history, $total), 200, ['Content-Type' => 'application/json;charset=utf8'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\Actionlog;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\SnipeModel;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Http\Traits\MigratesLegacyAssetLocations;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\Statuslabel;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -56,9 +57,16 @@ class AssetCheckinController extends Controller
|
||||
default => trans('admin/hardware/form.redirect_to_type', ['type' => trans('general.user')]),
|
||||
};
|
||||
|
||||
$deployableStatusIds = array_map('intval', array_keys(Helper::deployableStatusLabelList()));
|
||||
$selectedStatusId = old('status_id');
|
||||
$showRequestableToggle = is_numeric($selectedStatusId)
|
||||
&& in_array((int) $selectedStatusId, $deployableStatusIds, true);
|
||||
|
||||
return view('hardware/checkin', compact('asset', 'target_option'))
|
||||
->with('item', $asset)
|
||||
->with('statusLabel_list', Helper::statusLabelList())
|
||||
->with('deployable_status_ids', $deployableStatusIds)
|
||||
->with('show_requestable_toggle', $showRequestableToggle)
|
||||
->with('backto', $backto)
|
||||
->with('table_name', 'Assets');
|
||||
}
|
||||
@@ -107,6 +115,19 @@ class AssetCheckinController extends Controller
|
||||
$asset->status_id = e($request->input('status_id'));
|
||||
}
|
||||
|
||||
$selectedStatusId = $request->filled('status_id')
|
||||
? (int) $request->input('status_id')
|
||||
: (int) $asset->status_id;
|
||||
|
||||
$isDeployableStatus = Statuslabel::query()
|
||||
->whereKey($selectedStatusId)
|
||||
->where('deployable', 1)
|
||||
->exists();
|
||||
|
||||
if ($request->boolean('set_requestable') && $isDeployableStatus) {
|
||||
$asset->requestable = true;
|
||||
}
|
||||
|
||||
// Add any custom fields that should be included in the checkout
|
||||
$asset->customFieldsForCheckinCheckout('display_checkin');
|
||||
|
||||
@@ -164,4 +185,34 @@ class AssetCheckinController extends Controller
|
||||
// Redirect to the asset management page with error
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.checkin.error').$asset->getErrors());
|
||||
}
|
||||
|
||||
/**
|
||||
* This would only be used if the target is actually hard-deleted
|
||||
* and literally does not exist in the database anymore. This will null out the assigned_to
|
||||
* and assigned_type fields, but will not trigger any events or do any of the other things that a
|
||||
* normal checkin would do, since the target itself is now invalid.
|
||||
*/
|
||||
public function forceCheckin(Asset $asset)
|
||||
{
|
||||
|
||||
$this->authorize('checkin', $asset);
|
||||
|
||||
if (! $asset->hasOrphanedAssignment()) {
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('error', trans('admin/hardware/message.checkin.force_checkin_not_orphaned'));
|
||||
}
|
||||
|
||||
$asset->assigned_to = null;
|
||||
$asset->assigned_type = null;
|
||||
|
||||
if ($asset->save()) {
|
||||
$asset->logForceCheckin();
|
||||
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('success', trans('admin/hardware/message.checkin.force_checkin_orphaned_success'));
|
||||
}
|
||||
|
||||
return redirect()->route('hardware.show', $asset->id)
|
||||
->with('error', trans('admin/hardware/message.checkin.force_checkin_error'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ use App\Http\Controllers\CheckInOutRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@@ -101,6 +103,10 @@ class AssetCheckoutController extends Controller
|
||||
$asset->status_id = $request->input('status_id');
|
||||
}
|
||||
|
||||
if ($request->boolean('set_not_requestable')) {
|
||||
$asset->requestable = false;
|
||||
}
|
||||
|
||||
if (! empty($asset->licenseseats->all())) {
|
||||
if (request('checkout_to_type') == 'user') {
|
||||
foreach ($asset->licenseseats as $seat) {
|
||||
@@ -122,9 +128,43 @@ class AssetCheckoutController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'), null, $request->boolean('sign_in_place'))) {
|
||||
|
||||
// When sign_in_place is requested and the target is a user, redirect to the
|
||||
// acceptance/signature page so the user can sign in person. The signature is
|
||||
// attributed to the target user, not the admin.
|
||||
if ($request->boolean('sign_in_place') && $target instanceof User) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Asset::class)
|
||||
->where('checkoutable_id', $asset->id)
|
||||
->where('assigned_to_id', $target->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($asset);
|
||||
$acceptance->assignedTo()->associate($target);
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $asset->id,
|
||||
'sign_in_place_resource_type' => 'Assets',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/hardware/message.checkout.success'));
|
||||
}
|
||||
|
||||
if ($asset->checkOut($target, $admin, $checkout_at, $expected_checkin, $request->input('note'), $request->input('name'))) {
|
||||
return Helper::getRedirectOption($request, $asset->id, 'Assets')
|
||||
->with('success', trans('admin/hardware/message.checkout.success'));
|
||||
}
|
||||
|
||||
@@ -360,8 +360,23 @@ class AssetsController extends Controller
|
||||
'url' => route('qr_code/hardware', $asset),
|
||||
];
|
||||
|
||||
$total_maintenance_cost = $asset->maintenances?->sum('cost');
|
||||
$total_asset_cost = ($asset->assignedAssets()?->AssetsForShow()) ? $asset->assignedAssets()?->AssetsForShow()?->sum('purchase_cost') : 0;
|
||||
$total_license_cost = ($asset->licenses) ? $asset->licenses->sum('purchase_cost') : 0;
|
||||
$total_accessory_cost = ($asset->accessories) ? $asset->accessories()->sum('purchase_cost') : 0;
|
||||
$total_component_cost = ($asset->components) ? $asset->components->sum('calculated_purchase_cost') : 0;
|
||||
|
||||
$total_cost_for_asset = $asset->purchase_cost + $total_maintenance_cost + $total_asset_cost + $total_license_cost + $total_accessory_cost + $total_component_cost;
|
||||
|
||||
return view('hardware/view', compact('asset', 'qr_code', 'settings'))
|
||||
->with('use_currency', $use_currency)->with('audit_log', $audit_log);
|
||||
->with('total_maintenance_cost', $total_maintenance_cost)
|
||||
->with('total_asset_cost', $total_asset_cost)
|
||||
->with('total_license_cost', $total_license_cost)
|
||||
->with('total_accessory_cost', $total_accessory_cost)
|
||||
->with('total_component_cost', $total_component_cost)
|
||||
->with('total_cost_for_asset', $total_cost_for_asset)
|
||||
->with('use_currency', $use_currency)
|
||||
->with('audit_log', $audit_log);
|
||||
}
|
||||
|
||||
return redirect()->route('hardware.index')->with('error', trans('admin/hardware/message.does_not_exist'));
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\AssetCheckoutRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Company;
|
||||
use App\Models\CustomField;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Statuslabel;
|
||||
@@ -371,7 +372,7 @@ class BulkAssetsController extends Controller
|
||||
}
|
||||
|
||||
if ($request->filled('company_id')) {
|
||||
$this->update_array['company_id'] = $request->input('company_id');
|
||||
$this->update_array['company_id'] = Company::getIdForCurrentUser($request->input('company_id'));
|
||||
if ($request->input('company_id') == 'clear') {
|
||||
$this->update_array['company_id'] = null;
|
||||
}
|
||||
@@ -406,7 +407,7 @@ class BulkAssetsController extends Controller
|
||||
// Otherwise we need to make sure the status type is still a deployable one.
|
||||
|
||||
$unassigned = $asset->assigned_to == '';
|
||||
$deployable = $updated_status->deployable == '1' && $asset->assetstatus?->deployable == '1';
|
||||
$deployable = $updated_status->deployable == '1' && $asset->status?->deployable == '1';
|
||||
$pending = $updated_status->pending === 1;
|
||||
|
||||
if ($unassigned || $deployable || $pending) {
|
||||
@@ -715,6 +716,10 @@ class BulkAssetsController extends Controller
|
||||
$asset->status_id = $request->input('status_id');
|
||||
}
|
||||
|
||||
if ($request->boolean('set_not_requestable')) {
|
||||
$asset->requestable = false;
|
||||
}
|
||||
|
||||
$checkout_success = $asset->checkOut($target, $admin, $checkout_at, $expected_checkin, e($request->input('note')), $asset->name, null);
|
||||
|
||||
// TODO - I think this logic is duplicated in the checkOut method?
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Consumables;
|
||||
use App\Events\CheckoutableCheckedOut;
|
||||
use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
@@ -24,33 +25,27 @@ class ConsumableCheckoutController extends Controller
|
||||
*
|
||||
* @param int $id
|
||||
*/
|
||||
public function create($id): View|RedirectResponse
|
||||
public function create(Consumable $consumable): View|RedirectResponse
|
||||
{
|
||||
|
||||
if ($consumable = Consumable::find($id)) {
|
||||
$this->authorize('checkout', $consumable);
|
||||
|
||||
$this->authorize('checkout', $consumable);
|
||||
// Make sure the category is valid
|
||||
if ($consumable->category) {
|
||||
|
||||
// Make sure the category is valid
|
||||
if ($consumable->category) {
|
||||
|
||||
// Make sure there is at least one available to checkout
|
||||
if ($consumable->numRemaining() <= 0) {
|
||||
return redirect()->route('consumables.index')
|
||||
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
|
||||
}
|
||||
|
||||
// Return the checkout view
|
||||
return view('consumables/checkout', compact('consumable'));
|
||||
// Make sure there is at least one available to checkout
|
||||
if ($consumable->numRemaining() <= 0) {
|
||||
return redirect()->route('consumables.index')
|
||||
->with('error', trans('admin/consumables/message.checkout.unavailable', ['requested' => 1, 'remaining' => $consumable->numRemaining()]));
|
||||
}
|
||||
|
||||
// Invalid category
|
||||
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
|
||||
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
|
||||
// Return the checkout view
|
||||
return view('consumables/checkout', compact('consumable'));
|
||||
}
|
||||
|
||||
// Not found
|
||||
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.does_not_exist'));
|
||||
// Invalid category
|
||||
return redirect()->route('consumables.edit', ['consumable' => $consumable->id])
|
||||
->with('error', trans('general.invalid_item_category_single', ['type' => trans('general.consumable')]));
|
||||
|
||||
}
|
||||
|
||||
@@ -67,12 +62,8 @@ class ConsumableCheckoutController extends Controller
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function store(Request $request, $consumableId)
|
||||
public function store(Request $request, Consumable $consumable)
|
||||
{
|
||||
if (is_null($consumable = Consumable::with('users')->find($consumableId))) {
|
||||
return redirect()->route('consumables.index')->with('error', trans('admin/consumables/message.not_found'));
|
||||
}
|
||||
|
||||
$this->authorize('checkout', $consumable);
|
||||
|
||||
// If the quantity is not present in the request or is not a positive integer, set it to 1
|
||||
@@ -98,14 +89,14 @@ class ConsumableCheckoutController extends Controller
|
||||
// Update the consumable data
|
||||
$consumable->assigned_to = e($request->input('assigned_to'));
|
||||
|
||||
for ($i = 0; $i < $quantity; $i++) {
|
||||
$consumable->users()->attach($consumable->id, [
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $admin_user->id,
|
||||
'assigned_to' => e($request->input('assigned_to')),
|
||||
'note' => $request->input('note'),
|
||||
]);
|
||||
}
|
||||
// Attach the consumable to the user ONCE with the correct qty and note
|
||||
$consumable->users()->attach($consumable->id, [
|
||||
'consumable_id' => $consumable->id,
|
||||
'created_by' => $admin_user->id,
|
||||
'assigned_to' => $assigned_to,
|
||||
'note' => $request->input('note'),
|
||||
'qty' => $quantity,
|
||||
]);
|
||||
|
||||
$consumable->checkout_qty = $quantity;
|
||||
|
||||
@@ -116,12 +107,46 @@ class ConsumableCheckoutController extends Controller
|
||||
$request->input('note'),
|
||||
[],
|
||||
$consumable->checkout_qty,
|
||||
$request->boolean('sign_in_place'),
|
||||
));
|
||||
|
||||
$request->request->add(['checkout_to_type' => 'user']);
|
||||
$request->request->add(['assigned_user' => $user->id]);
|
||||
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => $request->input('checkout_to_type')]);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => $request->input('checkout_to_type'),
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
// When sign_in_place is requested, redirect to the acceptance/signature page
|
||||
// so the user can sign in person. The signature is attributed to the target user.
|
||||
if ($request->boolean('sign_in_place')) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', Consumable::class)
|
||||
->where('checkoutable_id', $consumable->id)
|
||||
->where('assigned_to_id', $user->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($consumable);
|
||||
$acceptance->assignedTo()->associate($user);
|
||||
$acceptance->qty = $quantity;
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $consumable->id,
|
||||
'sign_in_place_resource_type' => 'Consumables',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/consumables/message.checkout.success'));
|
||||
}
|
||||
|
||||
// Redirect to the new consumable page
|
||||
return Helper::getRedirectOption($request, $consumable->id, 'Consumables')
|
||||
|
||||
@@ -26,8 +26,10 @@ namespace App\Http\Controllers;
|
||||
use App\Models\Accessory;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use App\Models\Company;
|
||||
use App\Models\Component;
|
||||
use App\Models\Consumable;
|
||||
use App\Models\Department;
|
||||
use App\Models\License;
|
||||
use App\Models\Location;
|
||||
use App\Models\Maintenance;
|
||||
@@ -46,6 +48,8 @@ abstract class Controller extends BaseController
|
||||
|
||||
public static $map_object_type = [
|
||||
'accessories' => Accessory::class,
|
||||
'companies' => Company::class,
|
||||
'departments' => Department::class,
|
||||
'maintenances' => Maintenance::class,
|
||||
'assets' => Asset::class,
|
||||
'audits' => Asset::class,
|
||||
@@ -64,6 +68,8 @@ abstract class Controller extends BaseController
|
||||
'maintenances' => 'private_uploads/maintenances/',
|
||||
'assets' => 'private_uploads/assets/',
|
||||
'audits' => 'private_uploads/audits/',
|
||||
'departments' => 'private_uploads/departments/',
|
||||
'companies' => 'private_uploads/companies/',
|
||||
'components' => 'private_uploads/components/',
|
||||
'consumables' => 'private_uploads/consumables/',
|
||||
'hardware' => 'private_uploads/assets/',
|
||||
@@ -79,6 +85,8 @@ abstract class Controller extends BaseController
|
||||
'maintenances' => 'maintenance',
|
||||
'assets' => 'asset',
|
||||
'audits' => 'audits',
|
||||
'companies' => 'company',
|
||||
'departments' => 'department',
|
||||
'components' => 'component',
|
||||
'consumables' => 'consumable',
|
||||
'hardware' => 'asset',
|
||||
|
||||
@@ -54,7 +54,7 @@ class DepartmentsController extends Controller
|
||||
$department->created_by = auth()->id();
|
||||
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
|
||||
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
|
||||
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
|
||||
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
|
||||
$department->tag_color = $request->input('tag_color');
|
||||
$department->notes = $request->input('notes');
|
||||
$department = $request->handleImages($department);
|
||||
@@ -107,12 +107,8 @@ class DepartmentsController extends Controller
|
||||
*
|
||||
* @since [v4.0]
|
||||
*/
|
||||
public function destroy($id): RedirectResponse
|
||||
public function destroy(Department $department): RedirectResponse
|
||||
{
|
||||
if (is_null($department = Department::find($id))) {
|
||||
return redirect()->to(route('departments.index'))->with('error', trans('admin/departments/message.not_found'));
|
||||
}
|
||||
|
||||
$this->authorize('delete', $department);
|
||||
|
||||
if ($department->users->count() > 0) {
|
||||
@@ -168,7 +164,7 @@ class DepartmentsController extends Controller
|
||||
$department->fill($request->all());
|
||||
$department->manager_id = ($request->filled('manager_id') ? $request->input('manager_id') : null);
|
||||
$department->location_id = ($request->filled('location_id') ? $request->input('location_id') : null);
|
||||
$department->company_id = ($request->filled('company_id') ? $request->input('company_id') : null);
|
||||
$department->company_id = ($request->filled('company_id') ? Company::getIdForCurrentUser($request->input('company_id')) : null);
|
||||
$department->phone = $request->input('phone');
|
||||
$department->fax = $request->input('fax');
|
||||
$department->tag_color = $request->input('tag_color');
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -207,9 +208,15 @@ class GroupsController extends Controller
|
||||
public function destroy($id): RedirectResponse
|
||||
{
|
||||
if (! config('app.lock_passwords')) {
|
||||
|
||||
if (! $group = Group::find($id)) {
|
||||
return redirect()->route('groups.index')->with('error', trans('admin/groups/message.group_not_found', ['id' => $id]));
|
||||
}
|
||||
|
||||
if (! $group->isDeletable()) {
|
||||
return redirect()->route('groups.index')->with('error', trans('admin/groups/message.assoc_users'));
|
||||
}
|
||||
|
||||
$group->delete();
|
||||
|
||||
return redirect()->route('groups.index')->with('success', trans('admin/groups/message.success.delete'));
|
||||
|
||||
@@ -43,7 +43,9 @@ class LabelsController extends Controller
|
||||
'name' => trans('admin/labels/table.example_company'),
|
||||
'phone' => '1-555-555-5555',
|
||||
'email' => 'company@example.com',
|
||||
'logo' => 'label-preview-logo.png',
|
||||
]);
|
||||
$exampleAsset->is_label_preview = true;
|
||||
|
||||
$exampleAsset->setRelation('assignedTo', new User(['first_name' => 'Luke', 'last_name' => 'Skywalker']));
|
||||
$exampleAsset->defaultLoc = new Location(['name' => trans('admin/labels/table.example_defaultloc'), 'phone' => '1-555-555-5555']);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Helpers\Helper;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\LicenseCheckoutRequest;
|
||||
use App\Models\Asset;
|
||||
use App\Models\CheckoutAcceptance;
|
||||
use App\Models\License;
|
||||
use App\Models\LicenseSeat;
|
||||
use App\Models\User;
|
||||
@@ -101,17 +102,53 @@ class LicenseCheckoutController extends Controller
|
||||
session()->put(['checkout_to_type' => 'asset']);
|
||||
$checkoutTarget = $this->checkoutToAsset($licenseSeat);
|
||||
$request->request->add(['assigned_asset' => $checkoutTarget->id]);
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'asset']);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => 'asset',
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
|
||||
} elseif ($request->filled('assigned_to')) {
|
||||
session()->put(['checkout_to_type' => 'user']);
|
||||
$checkoutTarget = $this->checkoutToUser($licenseSeat);
|
||||
$request->request->add(['assigned_user' => $checkoutTarget->id]);
|
||||
session()->put(['redirect_option' => $request->input('redirect_option'), 'checkout_to_type' => 'user']);
|
||||
session()->put([
|
||||
'redirect_option' => $request->input('redirect_option'),
|
||||
'checkout_to_type' => 'user',
|
||||
'sign_in_place' => $request->boolean('sign_in_place'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($checkoutTarget) {
|
||||
|
||||
// When sign_in_place is requested and the target is a user, redirect to the
|
||||
// acceptance/signature page so the user can sign in person.
|
||||
if ($request->boolean('sign_in_place') && $checkoutTarget instanceof User) {
|
||||
$acceptance = CheckoutAcceptance::where('checkoutable_type', LicenseSeat::class)
|
||||
->where('checkoutable_id', $licenseSeat->id)
|
||||
->where('assigned_to_id', $checkoutTarget->id)
|
||||
->pending()
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
// If requireAcceptance() is false the listener won't have created one; create it now.
|
||||
if (! $acceptance) {
|
||||
$acceptance = new CheckoutAcceptance;
|
||||
$acceptance->checkoutable()->associate($licenseSeat);
|
||||
$acceptance->assignedTo()->associate($checkoutTarget);
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
session([
|
||||
'sign_in_place_acceptance_id' => $acceptance->id,
|
||||
'sign_in_place_item_id' => $license->id,
|
||||
'sign_in_place_resource_type' => 'Licenses',
|
||||
]);
|
||||
|
||||
return redirect()->route('account.accept.item', $acceptance->id)
|
||||
->with('success', trans('admin/licenses/message.checkout.success'));
|
||||
}
|
||||
|
||||
return Helper::getRedirectOption($request, $license->id, 'Licenses')
|
||||
->with('success', trans('admin/licenses/message.checkout.success'));
|
||||
}
|
||||
@@ -150,7 +187,7 @@ class LicenseCheckoutController extends Controller
|
||||
$licenseSeat->assigned_to = $target->assigned_to;
|
||||
}
|
||||
if ($licenseSeat->save()) {
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
|
||||
|
||||
return $target;
|
||||
}
|
||||
@@ -167,7 +204,7 @@ class LicenseCheckoutController extends Controller
|
||||
$licenseSeat->assigned_to = request('assigned_to');
|
||||
|
||||
if ($licenseSeat->save()) {
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes')));
|
||||
event(new CheckoutableCheckedOut($licenseSeat, $target, auth()->user(), request('notes'), [], 1, request()->boolean('sign_in_place')));
|
||||
|
||||
return $target;
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ class LicensesController extends Controller
|
||||
*/
|
||||
public function show(License $license)
|
||||
{
|
||||
$license = License::with('assignedusers')->find($license->id);
|
||||
$license = License::with('assignedusers')->withCount('freeSeats as free_seats_count')->find($license->id);
|
||||
|
||||
$users_count = User::where('autoassign_licenses', '1')->count();
|
||||
|
||||
|
||||
@@ -251,6 +251,7 @@ class ProfileController extends Controller
|
||||
|
||||
public function getStoredEula($filename): Response|BinaryFileResponse|RedirectResponse
|
||||
{
|
||||
$filename = basename((string) $filename);
|
||||
|
||||
$logentry = Actionlog::where('filename', $filename)->first();
|
||||
|
||||
|
||||
@@ -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, '\\');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class StorageProxyController extends Controller
|
||||
{
|
||||
/**
|
||||
* Proxy files from the "public" storage disk through the application.
|
||||
*
|
||||
* When PUBLIC_S3_PROXY is enabled, this serves files that would normally
|
||||
* be accessed directly from S3 (images, logos, avatars, etc.), allowing
|
||||
* a fully private S3 bucket setup.
|
||||
*/
|
||||
public function show(string $path): Response|StreamedResponse
|
||||
{
|
||||
if ($this->hasPathTraversalSegments($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
// The S3 adapter includes the disk's root prefix in generated URLs,
|
||||
// but Flysystem also prepends it internally on every operation.
|
||||
// Strip it here to avoid double-prefixing.
|
||||
$root = trim(config('filesystems.disks.public.root', ''), '/');
|
||||
if ($root !== '' && str_starts_with($path, $root.'/')) {
|
||||
$path = substr($path, strlen($root) + 1);
|
||||
}
|
||||
|
||||
if (! $disk->exists($path)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$mimeType = $disk->mimeType($path) ?: 'application/octet-stream';
|
||||
$lastModified = $disk->lastModified($path);
|
||||
$etag = md5($path.$lastModified);
|
||||
$size = $disk->size($path);
|
||||
|
||||
if ($this->isNotModified($etag, $lastModified)) {
|
||||
return response('', 304)
|
||||
->header('ETag', '"'.$etag.'"')
|
||||
->header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
return new StreamedResponse(function () use ($disk, $path) {
|
||||
$stream = $disk->readStream($path);
|
||||
fpassthru($stream);
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}, 200, [
|
||||
'Content-Type' => $mimeType,
|
||||
'Content-Length' => $size,
|
||||
'ETag' => '"'.$etag.'"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s', $lastModified).' GMT',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
|
||||
private function isNotModified(string $etag, int $lastModified): bool
|
||||
{
|
||||
$requestEtag = request()->header('If-None-Match');
|
||||
if ($requestEtag && $requestEtag === '"'.$etag.'"') {
|
||||
return true;
|
||||
}
|
||||
|
||||
$ifModifiedSince = request()->header('If-Modified-Since');
|
||||
if ($ifModifiedSince && strtotime($ifModifiedSince) >= $lastModified) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function hasPathTraversalSegments(string $path): bool
|
||||
{
|
||||
$normalizedPath = str_replace('\\', '/', $path);
|
||||
|
||||
return str_contains($normalizedPath, "\0")
|
||||
|| str_starts_with($normalizedPath, '/')
|
||||
|| str_contains($normalizedPath, '../')
|
||||
|| str_contains($normalizedPath, '/..')
|
||||
|| str_ends_with($normalizedPath, '/..')
|
||||
|| $normalizedPath === '..';
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ class UploadedFilesController extends Controller
|
||||
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('update', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
|
||||
@@ -85,7 +85,7 @@ class UploadedFilesController extends Controller
|
||||
{
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('view', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
|
||||
@@ -96,7 +96,7 @@ class UploadedFilesController extends Controller
|
||||
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_id'));
|
||||
}
|
||||
|
||||
if (! Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
|
||||
if (! Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
|
||||
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.file_not_found'));
|
||||
}
|
||||
|
||||
@@ -105,10 +105,10 @@ class UploadedFilesController extends Controller
|
||||
'Content-Disposition' => 'inline',
|
||||
];
|
||||
|
||||
return Storage::download(self::$map_storage_path[$object_type].'/'.$log->filename, $log->filename, $headers);
|
||||
return Storage::download(self::$map_storage_path[$object_type].$log->filename, $log->filename, $headers);
|
||||
}
|
||||
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].'/'.$log->filename);
|
||||
return StorageHelper::downloader(self::$map_storage_path[$object_type].$log->filename);
|
||||
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ class UploadedFilesController extends Controller
|
||||
|
||||
// Check the permissions to make sure the user can view the object
|
||||
$object = self::$map_object_type[$object_type]::withTrashed()->find($id);
|
||||
$this->authorize('update', $object);
|
||||
$this->authorize('files', $object);
|
||||
|
||||
if (! $object) {
|
||||
return redirect()->back()->withFragment('files')->with('error', trans('general.file_upload_status.invalid_object'));
|
||||
@@ -141,8 +141,8 @@ class UploadedFilesController extends Controller
|
||||
|
||||
if ($log) {
|
||||
// Check the file actually exists, and delete it
|
||||
if (Storage::exists(self::$map_storage_path[$object_type].'/'.$log->filename)) {
|
||||
Storage::delete(self::$map_storage_path[$object_type].'/'.$log->filename);
|
||||
if (Storage::exists(self::$map_storage_path[$object_type].$log->filename)) {
|
||||
Storage::delete(self::$map_storage_path[$object_type].$log->filename);
|
||||
}
|
||||
// Delete the record of the file
|
||||
if ($log->logUploadDelete($object, $log->filename)) {
|
||||
|
||||
@@ -362,7 +362,9 @@ class BulkUsersController extends Controller
|
||||
$logAction->target_id = $item->assigned_to;
|
||||
$logAction->target_type = User::class;
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->note = 'Bulk checkin items';
|
||||
$logAction->action_date = now();
|
||||
$logAction->created_at = now();
|
||||
$logAction->note = 'Bulk checkin items on user bulk edit/delete';
|
||||
$logAction->logaction('checkin from');
|
||||
}
|
||||
}
|
||||
@@ -376,7 +378,9 @@ class BulkUsersController extends Controller
|
||||
$logAction->target_id = $accessoryUserRow->assigned_to;
|
||||
$logAction->target_type = User::class;
|
||||
$logAction->created_by = auth()->id();
|
||||
$logAction->note = 'Bulk checkin items';
|
||||
$logAction->created_at = now();
|
||||
$logAction->action_date = now();
|
||||
$logAction->note = 'Bulk checkin accessory on user bulk edit/delete';
|
||||
$logAction->logaction('checkin from');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class AccessoriesTransformer
|
||||
] : null,
|
||||
'notes' => ($accessory->notes) ? Helper::parseEscapedMarkedownInline($accessory->notes) : null,
|
||||
'qty' => ($accessory->qty) ? (int) $accessory->qty : null,
|
||||
'percent_remaining' => round($accessory->percentRemaining()),
|
||||
'purchase_date' => ($accessory->purchase_date) ? Helper::getFormattedDateObject($accessory->purchase_date, 'date') : null,
|
||||
'purchase_cost' => Helper::formatCurrencyOutput($accessory->purchase_cost),
|
||||
'total_cost' => Helper::formatCurrencyOutput($accessory->totalCostSum()),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Http\Transformers;
|
||||
|
||||
use App\Helpers\Helper;
|
||||
use App\Models\Asset;
|
||||
use App\Models\AssetModel;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
@@ -58,6 +59,7 @@ class AssetModelsTransformer
|
||||
'assets_assigned_count' => (int) $assetmodel->assets_assigned_count,
|
||||
'assets_archived_count' => (int) $assetmodel->assets_archived_count,
|
||||
'remaining' => (int) ($assetmodel->assets_count - (int) $assetmodel->assets_assigned_count) - (int) $assetmodel->assets_archived_count,
|
||||
'percent_remaining' => round($assetmodel->percentRemaining()),
|
||||
'category' => ($assetmodel->category) ? [
|
||||
'id' => (int) $assetmodel->category->id,
|
||||
'name' => e($assetmodel->category->name),
|
||||
@@ -83,6 +85,7 @@ class AssetModelsTransformer
|
||||
];
|
||||
|
||||
$permissions_array['available_actions'] = [
|
||||
'create_asset' => (Gate::allows('create', Asset::class) && ($assetmodel->deleted_at == '')),
|
||||
'update' => (Gate::allows('update', AssetModel::class) && ($assetmodel->deleted_at == '')),
|
||||
'delete' => $assetmodel->isDeletable(),
|
||||
'clone' => (Gate::allows('create', AssetModel::class) && ($assetmodel->deleted_at == '')),
|
||||
|
||||
@@ -6,6 +6,9 @@ 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;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -43,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)) ? [
|
||||
@@ -89,8 +98,8 @@ class AssetsTransformer
|
||||
'tag_color' => ($asset->defaultLoc->tag_color) ? e($asset->defaultLoc->tag_color) : null,
|
||||
] : null,
|
||||
'image' => ($asset->getImageUrl()) ? $asset->getImageUrl() : null,
|
||||
'qr' => ($setting->qr_code == '1') ? config('app.url').'/uploads/barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png' : null,
|
||||
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? config('app.url').'/uploads/barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png' : null,
|
||||
'qr' => ($setting->qr_code == '1') ? Storage::disk('public')->url('barcodes/qr-'.str_slug($asset->asset_tag).'-'.str_slug($asset->id).'.png') : null,
|
||||
'alt_barcode' => ($setting->alt_barcode_enabled == '1') ? Storage::disk('public')->url('barcodes/'.str_slug($setting->alt_barcode).'-'.str_slug($asset->asset_tag).'.png') : null,
|
||||
'assigned_to' => $this->transformAssignedTo($asset),
|
||||
'warranty_months' => ($asset->warranty_months > 0) ? e($asset->warranty_months.' '.trans('admin/hardware/form.months')) : null,
|
||||
'warranty_expires' => ($asset->warranty_months > 0) ? Helper::getFormattedDateObject($asset->warranty_expires, 'date') : null,
|
||||
@@ -183,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'),
|
||||
|
||||
];
|
||||
@@ -248,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()),
|
||||
];
|
||||
|
||||
@@ -341,4 +350,78 @@ class AssetsTransformer
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
||||
public function transformLicensesCheckedToAsset($license_checkouts, $total)
|
||||
{
|
||||
|
||||
$array = [];
|
||||
foreach ($license_checkouts as $checkout) {
|
||||
$array[] = self::transformLicenseCheckedToAsset($checkout);
|
||||
}
|
||||
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
|
||||
public function transformLicenseCheckedToAsset(LicenseSeat $licenseseat)
|
||||
{
|
||||
|
||||
if (Gate::allows('viewKeys', $licenseseat->license)) {
|
||||
$product_key = $licenseseat->license->serial ?? null;
|
||||
} else {
|
||||
$product_key = '------------';
|
||||
}
|
||||
|
||||
$array = [
|
||||
'id' => $licenseseat->id,
|
||||
'license' => [
|
||||
'id' => $licenseseat->license?->id,
|
||||
'name' => e($licenseseat->license?->display_name),
|
||||
'serial' => $product_key ? e($product_key) : null,
|
||||
'note' => $licenseseat->license?->note ? e($licenseseat->license?->note) : null,
|
||||
|
||||
],
|
||||
'assigned_asset' => $licenseseat->asset_id,
|
||||
'expiration_date' => $licenseseat->license?->expiration_date ? Helper::getFormattedDateObject($licenseseat->license?->expiration_date, 'date') : null,
|
||||
'notes' => $licenseseat->notes ? e($licenseseat->notes) : null,
|
||||
];
|
||||
|
||||
$permissions_array['available_actions'] = [
|
||||
'checkout' => false,
|
||||
'checkin' => Gate::allows('checkin', License::class),
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
return $array;
|
||||
|
||||
}
|
||||
|
||||
public function transformCheckedoutComponents(Collection $components_assets, $total)
|
||||
{
|
||||
$array = [];
|
||||
foreach ($components_assets as $component_checkout) {
|
||||
$array[] = [
|
||||
'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)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class CategoriesTransformer
|
||||
'name' => e($category->name),
|
||||
'image' => ($category->image) ? Storage::disk('public')->url('categories/'.e($category->image)) : null,
|
||||
'category_type' => Helper::categoryTypeList($category->category_type),
|
||||
'has_eula' => ($category->getEula() ? true : false),
|
||||
'has_eula' => ($category->eula_text) ? true : false,
|
||||
'use_default_eula' => ($category->use_default_eula == '1' ? true : false),
|
||||
'eula' => ($category->getEula()),
|
||||
'checkin_email' => ($category->checkin_email == '1'),
|
||||
|
||||
@@ -55,6 +55,7 @@ class ComponentsTransformer
|
||||
'purchase_cost' => Helper::formatCurrencyOutput($component->purchase_cost),
|
||||
'total_cost' => Helper::formatCurrencyOutput($component->totalCostSum()),
|
||||
'remaining' => (int) $component->numRemaining(),
|
||||
'percent_remaining' => round($component->percentRemaining()),
|
||||
'company' => ($component->company) ? [
|
||||
'id' => (int) $component->company->id,
|
||||
'name' => e($component->company->name),
|
||||
@@ -87,12 +88,11 @@ class ComponentsTransformer
|
||||
$array = [];
|
||||
foreach ($components_assets as $asset) {
|
||||
$array[] = [
|
||||
'assigned_pivot_id' => $asset->pivot->id,
|
||||
'id' => (int) $asset->id,
|
||||
'name' => e($asset->model->display_name).' '.e($asset->display_name),
|
||||
'qty' => $asset->pivot->assigned_qty,
|
||||
'note' => e($asset->pivot->note),
|
||||
'type' => 'asset',
|
||||
'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,
|
||||
'created_at' => Helper::getFormattedDateObject($asset->pivot->created_at, 'datetime'),
|
||||
'available_actions' => ['checkin' => true],
|
||||
];
|
||||
@@ -100,4 +100,9 @@ class ComponentsTransformer
|
||||
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
|
||||
public function transformAssignedTo($componentCheckout)
|
||||
{
|
||||
return (new AssetsTransformer)->transformAssetCompact($componentCheckout);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ class ConsumablesTransformer
|
||||
'min_amt' => (int) $consumable->min_amt,
|
||||
'model_number' => ($consumable->model_number != '') ? e($consumable->model_number) : null,
|
||||
'remaining' => $consumable->numRemaining(),
|
||||
'percent_remaining' => round($consumable->percentRemaining()),
|
||||
'order_number' => e($consumable->order_number),
|
||||
'purchase_cost' => Helper::formatCurrencyOutput($consumable->purchase_cost),
|
||||
'total_cost' => Helper::formatCurrencyOutput($consumable->totalCostSum()),
|
||||
|
||||
@@ -88,8 +88,8 @@ class DepreciationReportTransformer
|
||||
'model' => ($asset->model) ? e($asset->model->name) : null,
|
||||
'model_number' => (($asset->model) && ($asset->model->model_number)) ? e($asset->model->model_number) : null,
|
||||
'eol' => ($asset->purchase_date != '') ? Helper::getFormattedDateObject($asset->present()->eol_date(), 'date') : null,
|
||||
'status_label' => ($asset->assetstatus) ? e($asset->assetstatus->name) : null,
|
||||
'status' => ($asset->assetstatus) ? e($asset->present()->statusMeta) : null,
|
||||
'status_label' => ($asset->status) ? e($asset->status->name) : null,
|
||||
'status' => ($asset->status) ? e($asset->present()->statusMeta) : null,
|
||||
'category' => (($asset->model) && ($asset->model->category)) ? e($asset->model->category->name) : null,
|
||||
'manufacturer' => (($asset->model) && ($asset->model->manufacturer)) ? e($asset->model->manufacturer->name) : null,
|
||||
'supplier' => ($asset->supplier) ? e($asset->supplier->name) : null,
|
||||
|
||||
@@ -37,7 +37,7 @@ class GroupsTransformer
|
||||
|
||||
$permissions_array['available_actions'] = [
|
||||
'update' => Gate::allows('superadmin') ? true : false,
|
||||
'delete' => Gate::allows('superadmin') ? true : false,
|
||||
'delete' => $group->isDeletable() ? true : false,
|
||||
];
|
||||
|
||||
$array += $permissions_array;
|
||||
|
||||
@@ -54,7 +54,7 @@ class LicenseSeatsTransformer
|
||||
] : null,
|
||||
'location' => ($seat->location()) ? [
|
||||
'id' => (int) $seat->location()->id,
|
||||
'name' => e($seat->location()->name),
|
||||
'name' => e($seat->location()->display_name),
|
||||
'tag_color' => $seat->location()->tag_color ? e($seat->location()->tag_color) : null,
|
||||
'created_at' => Helper::getFormattedDateObject($seat->created_at, 'datetime'),
|
||||
] : null,
|
||||
|
||||
@@ -30,7 +30,7 @@ class LicensesTransformer
|
||||
'name' => e($license->manufacturer->name),
|
||||
'tag_color' => ($license->manufacturer->tag_color) ? e($license->manufacturer->tag_color) : null,
|
||||
] : null,
|
||||
'product_key' => (Gate::allows('viewKeys', License::class)) ? e($license->serial) : '------------',
|
||||
'product_key' => (Gate::allows('viewKeys', $license)) ? e($license->serial) : '------------',
|
||||
'order_number' => ($license->order_number) ? e($license->order_number) : null,
|
||||
'purchase_order' => ($license->purchase_order) ? e($license->purchase_order) : null,
|
||||
'purchase_date' => Helper::getFormattedDateObject($license->purchase_date, 'date'),
|
||||
@@ -43,6 +43,7 @@ class LicensesTransformer
|
||||
'seats' => (int) $license->seats,
|
||||
'free_seats_count' => (int) $license->free_seats_count - License::unReassignableCount($license),
|
||||
'remaining' => (int) $license->free_seats_count,
|
||||
'percent_remaining' => round($license->percentRemaining()),
|
||||
'min_amt' => ($license->min_amt) ? (int) ($license->min_amt) : null,
|
||||
'license_name' => ($license->license_name) ? e($license->license_name) : null,
|
||||
'license_email' => ($license->license_email) ? e($license->license_email) : null,
|
||||
|
||||
@@ -113,7 +113,11 @@ class LocationsTransformer
|
||||
|
||||
$array = [
|
||||
'id' => $accessory_checkout->id,
|
||||
'assigned_to' => $accessory_checkout->assigned_to,
|
||||
'assigned_to' => $accessory_checkout->assignedTo ? [
|
||||
'id' => $accessory_checkout->assignedTo?->id,
|
||||
'name' => $accessory_checkout->assignedTo?->display_name,
|
||||
'type' => strtolower($accessory_checkout->assignedType()),
|
||||
] : null,
|
||||
'accessory' => $this->transformAccessory($accessory_checkout->accessory),
|
||||
'image' => ($accessory_checkout?->accessory?->image) ? Storage::disk('public')->url('accessories/'.e($accessory_checkout->accessory->image)) : null,
|
||||
'note' => $accessory_checkout->note ? e($accessory_checkout->note) : null,
|
||||
|
||||
@@ -40,12 +40,13 @@ 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),
|
||||
'company' => (($assetmaintenance->asset) && ($assetmaintenance->asset->company)) ? [
|
||||
'id' => (int) $assetmaintenance->asset->company->id,
|
||||
'name' => ($assetmaintenance->asset->company->name) ? e($assetmaintenance->asset->company->name) : null,
|
||||
@@ -71,7 +72,7 @@ class MaintenancesTransformer
|
||||
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
|
||||
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
|
||||
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
|
||||
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
|
||||
'asset_maintenance_time' => (int) $assetmaintenance->asset_maintenance_time,
|
||||
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
|
||||
'user_id' => ($assetmaintenance->adminuser) ? [
|
||||
'id' => $assetmaintenance->adminuser->id,
|
||||
@@ -83,7 +84,7 @@ class MaintenancesTransformer
|
||||
] : null,
|
||||
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
|
||||
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
|
||||
'is_warranty' => $assetmaintenance->is_warranty,
|
||||
'is_warranty' => (bool) $assetmaintenance->is_warranty,
|
||||
|
||||
];
|
||||
|
||||
@@ -96,4 +97,48 @@ class MaintenancesTransformer
|
||||
|
||||
return $array;
|
||||
}
|
||||
|
||||
public function transformMaintenancesFlat(Collection $maintenances, $total)
|
||||
{
|
||||
$array = [];
|
||||
foreach ($maintenances as $assetmaintenance) {
|
||||
$array[] = self::transformMaintenanceForReport($assetmaintenance);
|
||||
}
|
||||
|
||||
return (new DatatablesTransformer)->transformDatatables($array, $total);
|
||||
}
|
||||
|
||||
public function transformMaintenanceForReport(Maintenance $assetmaintenance)
|
||||
{
|
||||
$array = [
|
||||
'id' => (int) $assetmaintenance->id,
|
||||
'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,
|
||||
'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?->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,
|
||||
'title' => ($assetmaintenance->name) ? e($assetmaintenance->name) : null, // legacy to not change the shape of the API
|
||||
'location' => (($assetmaintenance->asset) && ($assetmaintenance->asset->location)) ? e($assetmaintenance->asset->location->name) : null,
|
||||
'notes' => ($assetmaintenance->notes) ? Helper::parseEscapedMarkedownInline($assetmaintenance->notes) : null,
|
||||
'supplier' => ($assetmaintenance->supplier) ? e($assetmaintenance->supplier?->name) : null,
|
||||
'url' => ($assetmaintenance->url) ? e($assetmaintenance->url) : null,
|
||||
'cost' => Helper::formatCurrencyOutput($assetmaintenance->cost),
|
||||
'asset_maintenance_type' => e($assetmaintenance->asset_maintenance_type),
|
||||
'start_date' => Helper::getFormattedDateObject($assetmaintenance->start_date, 'date'),
|
||||
'asset_maintenance_time' => $assetmaintenance->asset_maintenance_time,
|
||||
'completion_date' => Helper::getFormattedDateObject($assetmaintenance->completion_date, 'date'),
|
||||
'created_by' => ($assetmaintenance->adminuser) ? e($assetmaintenance->adminuser->display_name) : null,
|
||||
'created_at' => Helper::getFormattedDateObject($assetmaintenance->created_at, 'datetime'),
|
||||
'updated_at' => Helper::getFormattedDateObject($assetmaintenance->updated_at, 'datetime'),
|
||||
'is_warranty' => (bool) $assetmaintenance->is_warranty,
|
||||
|
||||
];
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,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;
|
||||
}
|
||||
@@ -109,7 +114,7 @@ class CheckoutableListener
|
||||
if (! empty($cc)) {
|
||||
try {
|
||||
$ccMail = (clone $mailable)->locale(Setting::getSettings()->locale);
|
||||
Mail::to(array_flatten($cc))->send($ccMail);
|
||||
Mail::cc(array_flatten($cc))->send($ccMail);
|
||||
} catch (ClientException $e) {
|
||||
Log::debug('Exception caught during checkout email: '.$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
@@ -170,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;
|
||||
@@ -193,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::to(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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -387,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);
|
||||
|
||||
@@ -461,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;
|
||||
}
|
||||
@@ -481,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;
|
||||
@@ -493,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(
|
||||
@@ -522,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
|
||||
@@ -532,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
|
||||
|
||||
+28
-97
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Http\Controllers\Api\AccessoriesController\checkedout;
|
||||
use App\Models\Traits\Acceptable;
|
||||
use App\Models\Traits\CompanyableTrait;
|
||||
use App\Models\Traits\HasUploads;
|
||||
@@ -47,7 +46,15 @@ class Accessory extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'model_number', 'order_number', 'purchase_date', 'notes'];
|
||||
protected $searchableAttributes = [
|
||||
'created_at',
|
||||
'model_number',
|
||||
'name',
|
||||
'notes',
|
||||
'order_number',
|
||||
'purchase_cost',
|
||||
'purchase_date',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
@@ -57,9 +64,13 @@ class Accessory extends SnipeModel
|
||||
protected $searchableRelations = [
|
||||
'category' => ['name'],
|
||||
'company' => ['name'],
|
||||
'location' => ['name'],
|
||||
'manufacturer' => ['name'],
|
||||
'supplier' => ['name'],
|
||||
'location' => ['name'],
|
||||
];
|
||||
|
||||
protected $searchableCounts = [
|
||||
'checkouts_count',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -67,7 +78,7 @@ class Accessory extends SnipeModel
|
||||
*/
|
||||
public $rules = [
|
||||
'name' => 'required|max:255',
|
||||
'qty' => 'required|integer|min:1',
|
||||
'qty' => 'nullable|integer|min:0',
|
||||
'category_id' => 'required|integer|exists:categories,id',
|
||||
'company_id' => 'integer|nullable',
|
||||
'location_id' => 'exists:locations,id|nullable|fmcs_location',
|
||||
@@ -243,7 +254,7 @@ class Accessory extends SnipeModel
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getImageUrl()
|
||||
public function getImageUrl($path = null)
|
||||
{
|
||||
if ($this->image) {
|
||||
return Storage::disk('public')->url(app('accessories_upload_path').$this->image);
|
||||
@@ -268,6 +279,18 @@ class Accessory extends SnipeModel
|
||||
->with('assignedTo');
|
||||
}
|
||||
|
||||
public function percentRemaining()
|
||||
{
|
||||
if (($this->qty == '' || $this->qty == 0)) {
|
||||
return 0;
|
||||
}
|
||||
if ($this->checkouts_count == 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return ($this->qty - $this->checkouts_count) / $this->qty * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the accessory -> users relationship
|
||||
*
|
||||
@@ -283,20 +306,6 @@ class Accessory extends SnipeModel
|
||||
->with('assignedTo');
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the accessory -> admin user relationship
|
||||
*
|
||||
* @author A. Gianotto <snipe@snipe.net>
|
||||
*
|
||||
* @since [v7.0.13]
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether or not the accessory has users
|
||||
*
|
||||
@@ -444,84 +453,6 @@ class Accessory extends SnipeModel
|
||||
* BEGIN QUERY SCOPES
|
||||
* -----------------------------------------------
|
||||
**/
|
||||
/**
|
||||
* Query builder scope to search on text filters for complex Bootstrap Tables API
|
||||
*
|
||||
* @param Builder $query Query builder instance
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $fieldname => $search_val) {
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('accessories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'notes') {
|
||||
$query->where('accessories.notes', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'model_number') {
|
||||
$query->where('accessories.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'order_number') {
|
||||
$query->where('accessories.order_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'purchase_cost') {
|
||||
$query->where('accessories.purchase_cost', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'location') {
|
||||
$query->whereHas(
|
||||
'location', function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'manufacturer') {
|
||||
$query->whereHas(
|
||||
'manufacturer', function ($query) use ($search_val) {
|
||||
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'supplier') {
|
||||
$query->whereHas(
|
||||
'supplier', function ($query) use ($search_val) {
|
||||
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'category') {
|
||||
$query->whereHas(
|
||||
'category', function ($query) use ($search_val) {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'company') {
|
||||
$query->whereHas(
|
||||
'company', function ($query) use ($search_val) {
|
||||
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to order on created_by name
|
||||
|
||||
+30
-23
@@ -8,8 +8,8 @@ use App\Models\Traits\Searchable;
|
||||
use App\Presenters\ActionlogPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -329,6 +329,27 @@ class Actionlog extends SnipeModel
|
||||
return $this->morphTo('target')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager load history relations used by the API transformer to avoid N+1 queries.
|
||||
*/
|
||||
public function scopeForApiHistory($query)
|
||||
{
|
||||
return $query->with([
|
||||
'adminuser',
|
||||
'location',
|
||||
'item' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([
|
||||
Asset::class => ['model'],
|
||||
]);
|
||||
},
|
||||
'target' => function (MorphTo $morphTo) {
|
||||
$morphTo->morphWith([
|
||||
Asset::class => ['model'],
|
||||
]);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the actionlog -> location relationship
|
||||
*
|
||||
@@ -423,39 +444,21 @@ class Actionlog extends SnipeModel
|
||||
/**
|
||||
* Calculate the date of the next audit
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
* @return Datetime | string
|
||||
*
|
||||
* @since [v4.0]
|
||||
*
|
||||
* @return \Datetime
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*/
|
||||
public function calcNextAuditDate($monthInterval = 12, $asset = null)
|
||||
{
|
||||
$last_audit_date = Carbon::parse($this->created_at);
|
||||
// If there is an asset-specific next date already given,
|
||||
if (($asset) && ($asset->next_audit_date)) {
|
||||
return \Carbon::parse($asset->next_audit_date);
|
||||
return Carbon::parse($asset->next_audit_date);
|
||||
}
|
||||
|
||||
return \Carbon::parse($last_audit_date)->addMonths($monthInterval)->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets action logs in chronological order, excluding uploads
|
||||
*
|
||||
* @author Vincent Sposato <vincent.sposato@gmail.com>
|
||||
*
|
||||
* @since v1.0
|
||||
*
|
||||
* @return Collection
|
||||
*/
|
||||
public function getListingOfActionLogsChronologicalOrder()
|
||||
{
|
||||
return $this->all()
|
||||
->where('action_type', '!=', 'uploaded')
|
||||
->orderBy('item_id', 'asc')
|
||||
->orderBy('created_at', 'asc')
|
||||
->get();
|
||||
return Carbon::parse($last_audit_date)->addMonths($monthInterval)->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -553,8 +556,12 @@ class Actionlog extends SnipeModel
|
||||
return 'private_uploads/assets/'.$this->filename;
|
||||
case AssetModel::class:
|
||||
return 'private_uploads/models/'.$this->filename;
|
||||
case Company::class:
|
||||
return 'private_uploads/companies/'.$this->filename;
|
||||
case Consumable::class:
|
||||
return 'private_uploads/consumables/'.$this->filename;
|
||||
case Department::class:
|
||||
return 'private_uploads/departments/'.$this->filename;
|
||||
case Component::class:
|
||||
return 'private_uploads/components/'.$this->filename;
|
||||
case License::class:
|
||||
|
||||
+149
-270
@@ -34,11 +34,17 @@ class Asset extends Depreciable
|
||||
{
|
||||
protected $presenter = AssetPresenter::class;
|
||||
|
||||
protected $with = ['model', 'adminuser'];
|
||||
protected $with = ['model', 'adminuser', 'location', 'company'];
|
||||
|
||||
use CompanyableTrait;
|
||||
use HasFactory, Loggable, Presentable, Requestable, SoftDeletes, UniqueUndeletedTrait, ValidatingTrait;
|
||||
use HasFactory;
|
||||
use HasUploads;
|
||||
use Loggable;
|
||||
use Presentable;
|
||||
use Requestable;
|
||||
use SoftDeletes;
|
||||
use UniqueUndeletedTrait;
|
||||
use ValidatingTrait;
|
||||
|
||||
public const LOCATION = 'location';
|
||||
|
||||
@@ -73,7 +79,6 @@ class Asset extends Depreciable
|
||||
* Leaving this commented out, since we need to test further, but this would eager load the model relationship every single
|
||||
* time the asset model is loaded.
|
||||
*/
|
||||
// protected $with = ['model'];
|
||||
|
||||
/**
|
||||
* Whether the model should inject it's identifier to the unique
|
||||
@@ -200,14 +205,29 @@ class Asset extends Depreciable
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [
|
||||
'assetstatus' => ['name'],
|
||||
'status' => ['name'],
|
||||
'supplier' => ['name'],
|
||||
'company' => ['name'],
|
||||
'defaultLoc' => ['name'],
|
||||
'location' => ['name'],
|
||||
'model' => ['name', 'model_number', 'eol'],
|
||||
'model.category' => ['name'],
|
||||
'model.manufacturer' => ['name'],
|
||||
'category' => ['name'],
|
||||
'manufacturer' => ['name'],
|
||||
'assigned_to' => ['name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps the field names exposed by the API / transformers to the actual
|
||||
* Eloquent relation names used in $searchableRelations.
|
||||
*
|
||||
* This lets callers filter using the same key they see in API responses
|
||||
* without needing to know the internal relation name.
|
||||
*
|
||||
* @var array<string, string> [ api_key => relation_name ]
|
||||
*/
|
||||
protected $searchableRelationAliases = [
|
||||
'status_label' => 'status',
|
||||
'assigned_to' => 'assignedTo',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -410,6 +430,13 @@ class Asset extends Depreciable
|
||||
);
|
||||
}
|
||||
|
||||
public function isDeletable()
|
||||
{
|
||||
|
||||
return Gate::allows('delete', $this)
|
||||
&& ($this->deleted_at == '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the asset -> company relationship
|
||||
*
|
||||
@@ -443,12 +470,28 @@ class Asset extends Depreciable
|
||||
if ((! $this->assigned_to) && (! $this->deleted_at)) {
|
||||
|
||||
// The asset status is not archived and is deployable
|
||||
if (($this->assetstatus) && ($this->assetstatus->archived == '0')
|
||||
&& ($this->assetstatus->deployable == '1')
|
||||
if (($this->status) && ($this->status->archived == '0')
|
||||
&& ($this->status->deployable == '1')
|
||||
) {
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function availableForCheckIn()
|
||||
{
|
||||
|
||||
// This asset is currently assigned to anyone and is not deleted...
|
||||
if (($this->assigned_to != '') && ($this->status) && ($this->status->archived == '0')
|
||||
&& ($this->status->deployable == '1')
|
||||
) {
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -473,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;
|
||||
@@ -517,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);
|
||||
|
||||
@@ -595,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
|
||||
*
|
||||
@@ -620,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');
|
||||
return $this->belongsToMany('\App\Models\Component', 'components_assets', 'asset_id', 'component_id')
|
||||
->withPivot('id', 'assigned_qty', 'created_at', 'note', 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -707,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
|
||||
*
|
||||
@@ -729,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) {
|
||||
@@ -819,7 +889,7 @@ class Asset extends Depreciable
|
||||
*
|
||||
* @return string | false
|
||||
*/
|
||||
public function getImageUrl()
|
||||
public function getImageUrl($path = null)
|
||||
{
|
||||
if ($this->image && ! empty($this->image)) {
|
||||
return Storage::disk('public')->url(app('assets_upload_path').e($this->image));
|
||||
@@ -930,20 +1000,6 @@ class Asset extends Depreciable
|
||||
->orderBy('created_at', 'desc');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who created the item
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*
|
||||
* @since [v1.0]
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the asset -> status relationship
|
||||
*
|
||||
@@ -953,7 +1009,7 @@ class Asset extends Depreciable
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function assetstatus()
|
||||
public function status()
|
||||
{
|
||||
return $this->belongsTo(Statuslabel::class, 'status_id');
|
||||
}
|
||||
@@ -1028,7 +1084,7 @@ class Asset extends Depreciable
|
||||
*/
|
||||
public function licenses()
|
||||
{
|
||||
return $this->belongsToMany(License::class, 'license_seats', 'asset_id', 'license_id');
|
||||
return $this->belongsToMany(License::class, 'license_seats', 'asset_id', 'license_id')->withPivot('notes', 'created_at', 'created_by');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1208,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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1288,6 +1376,13 @@ class Asset extends Depreciable
|
||||
);
|
||||
}
|
||||
|
||||
public function journal()
|
||||
{
|
||||
return $this->assetlog()->where('action_type', '=', 'note added')
|
||||
->orderBy('created_at', 'desc')
|
||||
->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* -----------------------------------------------
|
||||
* BEGIN QUERY SCOPES
|
||||
@@ -1296,6 +1391,7 @@ class Asset extends Depreciable
|
||||
|
||||
/**
|
||||
* Run additional, advanced searches.
|
||||
* This overrides the advancedTextSearch method on the Searchable model trait to add searching of assigned user, location, and assets.
|
||||
*
|
||||
* @param array $terms The search terms
|
||||
* @return Builder
|
||||
@@ -1383,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);
|
||||
@@ -1440,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);
|
||||
@@ -1457,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);
|
||||
@@ -1474,7 +1570,7 @@ class Asset extends Depreciable
|
||||
public function scopeNotArchived($query)
|
||||
{
|
||||
return $query->whereHas(
|
||||
'assetstatus', function ($query) {
|
||||
'status', function ($query) {
|
||||
$query->where('archived', '=', 0);
|
||||
}
|
||||
);
|
||||
@@ -1643,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);
|
||||
}
|
||||
);
|
||||
@@ -1662,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);
|
||||
@@ -1693,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)
|
||||
@@ -1860,235 +1956,6 @@ class Asset extends Depreciable
|
||||
)->withTrashed()->whereNull('assets.deleted_at'); // workaround for laravel bug
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to search on text filters for complex Bootstrap Tables API
|
||||
*
|
||||
* @param \Illuminate\Database\Query\Builder $query Query builder instance
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return \Illuminate\Database\Query\Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $key => $search_val) {
|
||||
|
||||
$fieldname = str_replace('custom_fields.', '', $key);
|
||||
|
||||
if ($fieldname == 'asset_tag') {
|
||||
$query->where('assets.asset_tag', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('assets.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'serial') {
|
||||
$query->where('assets.serial', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'purchase_date') {
|
||||
$query->where('assets.purchase_date', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'purchase_cost') {
|
||||
$query->where('assets.purchase_cost', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'notes') {
|
||||
$query->where('assets.notes', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'order_number') {
|
||||
$query->where('assets.order_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'status_label') {
|
||||
$query->whereHas(
|
||||
'assetstatus', function ($query) use ($search_val) {
|
||||
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'location') {
|
||||
$query->whereHas(
|
||||
'location', function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'rtd_location') {
|
||||
$query->whereHas(
|
||||
'defaultLoc', function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'assigned_to') {
|
||||
$query->whereHasMorph(
|
||||
'assignedTo', [User::class], function ($query) use ($search_val) {
|
||||
$query->where(
|
||||
function ($query) use ($search_val) {
|
||||
$query->where('users.first_name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('users.last_name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('users.display_name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('users.username', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
)->orWhereHasMorph(
|
||||
'assignedTo', [Location::class], function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
)->orWhereHasMorph(
|
||||
'assignedTo', [Asset::class], function ($query) use ($search_val) {
|
||||
$query->where(
|
||||
function ($query) use ($search_val) {
|
||||
// Don't use the asset table prefix here because it will pull from the original asset,
|
||||
// not the subselect we're doing here to get the assigned asset
|
||||
$query->where('name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('asset_tag', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'manufacturer') {
|
||||
$query->whereHas(
|
||||
'model', function ($query) use ($search_val) {
|
||||
$query->whereHas(
|
||||
'manufacturer', function ($query) use ($search_val) {
|
||||
$query->where(
|
||||
function ($query) use ($search_val) {
|
||||
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'category') {
|
||||
$query->whereHas(
|
||||
'model', function ($query) use ($search_val) {
|
||||
$query->whereHas(
|
||||
'category', function ($query) use ($search_val) {
|
||||
$query->where(
|
||||
function ($query) use ($search_val) {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('models.name', 'LIKE', '%'.$search_val.'%')
|
||||
->orWhere('models.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'model') {
|
||||
$query->whereHas(
|
||||
'model', function ($query) use ($search_val) {
|
||||
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'model_number') {
|
||||
$query->whereHas(
|
||||
'model', function ($query) use ($search_val) {
|
||||
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'company') {
|
||||
$query->whereHas(
|
||||
'company', function ($query) use ($search_val) {
|
||||
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'supplier') {
|
||||
$query->whereHas(
|
||||
'supplier', function ($query) use ($search_val) {
|
||||
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'status_label') {
|
||||
$query->whereHas(
|
||||
'assetstatus', function ($query) use ($search_val) {
|
||||
$query->where('status_labels.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'jobtitle') {
|
||||
$query->where(function ($query) use ($search_val) {
|
||||
if (is_array($search_val)) {
|
||||
$query->whereHasMorph(
|
||||
'assignedTo',
|
||||
[User::class],
|
||||
function ($query) use ($search_val) {
|
||||
$query->whereIn('users.jobtitle', $search_val);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
$query->whereHasMorph(
|
||||
'assignedTo',
|
||||
[User::class],
|
||||
function ($query) use ($search_val) {
|
||||
$query->where(function ($query) use ($search_val) {
|
||||
$query->where('users.jobtitle', 'LIKE', '%'.$search_val.'%');
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* THIS CLUNKY BIT IS VERY IMPORTANT
|
||||
*
|
||||
* Although inelegant, this section matters a lot when querying against fields that do not
|
||||
* exist on the asset table. There's probably a better way to do this moving forward, for
|
||||
* example using the Schema:: methods to determine whether or not a column actually exists,
|
||||
* or even just using the $searchableRelations variable earlier in this file.
|
||||
*
|
||||
* In short, this set of statements tells the query builder to ONLY query against an
|
||||
* actual field that's being passed if it doesn't meet known relational fields. This
|
||||
* allows us to query custom fields directly in the assets table
|
||||
* (regardless of their name) and *skip* any fields that we already know can only be
|
||||
* searched through relational searches that we do earlier in this method.
|
||||
*
|
||||
* For example, we do not store "location" as a field on the assets table, we store
|
||||
* that relationship through location_id on the assets table, therefore querying
|
||||
* assets.location would fail, as that field doesn't exist -- plus we're already searching
|
||||
* against those relationships earlier in this method.
|
||||
*
|
||||
* - snipe
|
||||
*/
|
||||
if (($fieldname != 'category') && ($fieldname != 'model_number') && ($fieldname != 'rtd_location') && ($fieldname != 'location') && ($fieldname != 'supplier')
|
||||
&& ($fieldname != 'status_label') && ($fieldname != 'assigned_to') && ($fieldname != 'model') && ($fieldname != 'jobtitle') && ($fieldname != 'company') && ($fieldname != 'manufacturer')
|
||||
) {
|
||||
$query->where('assets.'.$fieldname, 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to order on model
|
||||
*
|
||||
@@ -2299,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);
|
||||
}
|
||||
}
|
||||
|
||||
+29
-65
@@ -85,10 +85,12 @@ class AssetModel extends SnipeModel
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'model_number',
|
||||
'notes',
|
||||
'created_at',
|
||||
'eol',
|
||||
'min_amt',
|
||||
'model_number',
|
||||
'name',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -100,6 +102,20 @@ class AssetModel extends SnipeModel
|
||||
'depreciation' => ['name'],
|
||||
'category' => ['name'],
|
||||
'manufacturer' => ['name'],
|
||||
'fieldset' => ['name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Computed aliases (withCount/withSum) that can be searched via TextSearch filters.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableCounts = [
|
||||
'assets_count',
|
||||
'remaining',
|
||||
'assets_assigned_count',
|
||||
'assets_archived_count',
|
||||
];
|
||||
|
||||
protected static function booted(): void
|
||||
@@ -142,6 +158,15 @@ class AssetModel extends SnipeModel
|
||||
return $this->hasMany(Asset::class, 'model_id')->Archived();
|
||||
}
|
||||
|
||||
public function percentRemaining()
|
||||
{
|
||||
if ($this->availableAssets()->count() == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $this->availableAssets()->count() / $this->assets()->count() * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the model -> category relationship
|
||||
*
|
||||
@@ -228,7 +253,7 @@ class AssetModel extends SnipeModel
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function getImageUrl()
|
||||
public function getImageUrl($path = null)
|
||||
{
|
||||
if ($this->image) {
|
||||
return Storage::disk('public')->url(app('models_upload_path').$this->image);
|
||||
@@ -253,73 +278,12 @@ class AssetModel extends SnipeModel
|
||||
&& ($this->deleted_at == '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user who created the item
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*
|
||||
* @since [v1.0]
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* -----------------------------------------------
|
||||
* BEGIN QUERY SCOPES
|
||||
* -----------------------------------------------
|
||||
**/
|
||||
|
||||
/**
|
||||
* Query builder scope to search on text filters for complex Bootstrap Tables API
|
||||
*
|
||||
* @param Builder $query Query builder instance
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $fieldname => $search_val) {
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('models.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'notes') {
|
||||
$query->where('models.notes', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'model_number') {
|
||||
$query->where('models.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'category') {
|
||||
$query->whereHas(
|
||||
'category', function ($query) use ($search_val) {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'manufacturer') {
|
||||
$query->whereHas(
|
||||
'manufacturer', function ($query) use ($search_val) {
|
||||
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* scopeInCategory
|
||||
* Get all models that are in the array of category ids
|
||||
|
||||
+18
-34
@@ -89,14 +89,30 @@ class Category extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'category_type', 'notes'];
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'category_type',
|
||||
'notes',
|
||||
'eula_text',
|
||||
'created_at',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [];
|
||||
protected $searchableRelations = [
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
protected $searchableCounts = [
|
||||
'accessories_count',
|
||||
'consumables_count',
|
||||
'components_count',
|
||||
'licenses_count',
|
||||
'models_count',
|
||||
];
|
||||
|
||||
/**
|
||||
* Checks if category can be deleted
|
||||
@@ -263,11 +279,6 @@ class Category extends SnipeModel
|
||||
return $this->hasMany(AssetModel::class, 'category_id');
|
||||
}
|
||||
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for a category-specific EULA, and if that doesn't exist,
|
||||
* checks for a settings level EULA
|
||||
@@ -315,33 +326,6 @@ class Category extends SnipeModel
|
||||
* -----------------------------------------------
|
||||
**/
|
||||
|
||||
/**
|
||||
* Query builder scope to search on text filters for complex Bootstrap Tables API
|
||||
*
|
||||
* @param Builder $query Query builder instance
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $fieldname => $search_val) {
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'category_type') {
|
||||
$query->where('categories.category_type', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope for whether or not the category requires acceptance
|
||||
*
|
||||
|
||||
@@ -23,6 +23,21 @@ class CheckoutAcceptance extends Model
|
||||
'alert_on_response_id' => 'integer',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'assigned_to_id',
|
||||
'checkoutable_type',
|
||||
'checkoutable_id',
|
||||
'accepted_at',
|
||||
'declined_at',
|
||||
'note',
|
||||
'signature_filename',
|
||||
'stored_eula',
|
||||
'stored_eula_file',
|
||||
'qty',
|
||||
'signed_in_place',
|
||||
'signed_in_place_admin',
|
||||
];
|
||||
|
||||
/**
|
||||
* Get the mail recipient from the config
|
||||
*
|
||||
@@ -112,7 +127,7 @@ class CheckoutAcceptance extends Model
|
||||
*/
|
||||
public function isCheckedOutTo(User $user)
|
||||
{
|
||||
return $this->assignedTo?->is($user);
|
||||
return $this->assigned_to_id === $user->id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,20 +136,27 @@ class CheckoutAcceptance extends Model
|
||||
* checkout_acceptances table or you'll get an error.
|
||||
*
|
||||
* @param string $signature_filename
|
||||
* @param string|null $eula
|
||||
* @param string|null $filename
|
||||
* @param string|null $note
|
||||
* @param int|null $qty
|
||||
*/
|
||||
public function accept($signature_filename, $eula = null, $filename = null, $note = null)
|
||||
public function accept($signature_filename, $eula = null, $filename = null, $note = null, $qty = null)
|
||||
{
|
||||
$this->accepted_at = now();
|
||||
$this->signature_filename = $signature_filename;
|
||||
$this->stored_eula = $eula;
|
||||
$this->stored_eula_file = $filename;
|
||||
$this->note = $note;
|
||||
if ($qty !== null) {
|
||||
$this->qty = $qty;
|
||||
}
|
||||
$this->save();
|
||||
|
||||
/**
|
||||
* Update state for the checked out item
|
||||
*/
|
||||
$this->checkoutable->acceptedCheckout($this->assignedTo, $signature_filename, $filename);
|
||||
$this->checkoutable->acceptedCheckout($this->assignedTo, $qty, $note, $signature_filename, $filename);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +230,7 @@ class CheckoutAcceptance extends Model
|
||||
$pdf->SetAuthor($data['assigned_to']);
|
||||
$pdf->SetTitle('Asset Acceptance: '.$data['item_tag']);
|
||||
$pdf->SetSubject('Asset Acceptance: '.$data['item_tag']);
|
||||
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos');
|
||||
$pdf->SetKeywords('Snipe-IT, assets, acceptance, eula, tos,'.$data['item_tag'] ?? null.', '.$data['item_name'] ?? null.', '.$data['assigned_to'] ?? null);
|
||||
$pdf->SetFont('dejavusans', '', 8, '', true);
|
||||
$pdf->SetPrintHeader(false);
|
||||
$pdf->SetPrintFooter(false);
|
||||
@@ -243,6 +265,17 @@ class CheckoutAcceptance extends Model
|
||||
if ($data['item_serial'] != null) {
|
||||
$pdf->writeHTML(trans('admin/hardware/form.serial').': '.e($data['item_serial']), true, 0, true, 0, '');
|
||||
}
|
||||
// Render custom fields if present
|
||||
if (! empty($data['custom_fields']) && is_array($data['custom_fields'])) {
|
||||
foreach ($data['custom_fields'] as $customField) {
|
||||
$label = $customField['label'] ?? '';
|
||||
$value = $customField['value'] ?? '';
|
||||
if ($label !== '' && $value !== '') {
|
||||
$pdf->writeHTML(e($label).': '.e($value), true, 0, true, 0, '');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (($data['qty'] != null) && ($data['qty'] > 1)) {
|
||||
$pdf->writeHTML(trans('general.qty').': '.e($data['qty']), true, 0, true, 0, '');
|
||||
}
|
||||
@@ -250,6 +283,35 @@ class CheckoutAcceptance extends Model
|
||||
if ($data['email'] != null) {
|
||||
$pdf->writeHTML(trans('general.email').': '.e($data['email']), true, 0, true, 0, '');
|
||||
}
|
||||
// Add assigning user if present
|
||||
if (! empty($data['assigning_user'])) {
|
||||
$assigningUser = $data['assigning_user'];
|
||||
$assigningUserLine = trans('general.assigned_by').': '.e($assigningUser['name'] ?? $assigningUser['email'] ?? '');
|
||||
if (! empty($assigningUser['employee_num'])) {
|
||||
$assigningUserLine .= ' ('.e($assigningUser['employee_num']).')';
|
||||
}
|
||||
$pdf->writeHTML($assigningUserLine, true, 0, true, 0, '');
|
||||
}
|
||||
// Add signed in place row (always show)
|
||||
$signedInPlace = ! empty($data['signed_in_place']) && filter_var($data['signed_in_place'], FILTER_VALIDATE_BOOLEAN);
|
||||
$pdf->writeHTML(trans('general.signed_in_place').': '.($signedInPlace ? trans('general.yes') : trans('general.no')), true, 0, true, 0, '');
|
||||
// If signed in place, show admin info
|
||||
if ($signedInPlace && ! empty($data['signed_in_place_admin'])) {
|
||||
$admin = $data['signed_in_place_admin'];
|
||||
$adminName = $admin['name'] ?? '';
|
||||
$adminUsername = $admin['username'] ?? '';
|
||||
$adminEmail = $admin['email'] ?? '';
|
||||
$adminDetails = $adminName;
|
||||
if (! empty($adminUsername)) {
|
||||
$adminDetails .= ' ('.$adminUsername.')';
|
||||
}
|
||||
if (! empty($adminEmail)) {
|
||||
$adminDetails .= ' <'.$adminEmail.'>';
|
||||
}
|
||||
$adminLine = trans('general.signed_in_place_admin', ['admin' => $adminDetails]);
|
||||
$pdf->writeHTML($adminLine, true, 0, true, 0, '');
|
||||
}
|
||||
|
||||
$pdf->Ln();
|
||||
$pdf->writeHTML('<hr>', true, 0, true, 0, '');
|
||||
|
||||
|
||||
+18
-7
@@ -3,10 +3,13 @@
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Traits\CompanyableTrait;
|
||||
use App\Models\Traits\HasUploads;
|
||||
use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\CompanyPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -22,6 +25,9 @@ final class Company extends SnipeModel
|
||||
{
|
||||
use CompanyableTrait;
|
||||
use HasFactory;
|
||||
use HasUploads;
|
||||
use Loggable;
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'companies';
|
||||
|
||||
@@ -54,14 +60,24 @@ final class Company extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'phone', 'fax', 'email', 'created_at', 'updated_at'];
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'phone',
|
||||
'fax',
|
||||
'email',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [];
|
||||
protected $searchableRelations = [
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
@@ -311,11 +327,6 @@ final class Company extends SnipeModel
|
||||
|
||||
}
|
||||
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* I legit do not know what this method does, but we can't remove it (yet).
|
||||
*
|
||||
|
||||
+32
-100
@@ -8,6 +8,7 @@ use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\ComponentPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
@@ -115,6 +116,7 @@ class Component extends SnipeModel
|
||||
'location' => ['name'],
|
||||
'supplier' => ['name'],
|
||||
'manufacturer' => ['name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
public static function booted()
|
||||
@@ -164,20 +166,24 @@ class Component extends SnipeModel
|
||||
return $this->belongsToMany(Asset::class, 'components_assets')->withPivot('id', 'assigned_qty', 'created_at', 'created_by', 'note');
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the component -> admin user relationship
|
||||
*
|
||||
* @todo this is probably not needed - refactor
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*
|
||||
* @since [v3.0]
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function adminuser()
|
||||
protected function calculatedPurchaseCost(): Attribute
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
return Attribute::make(
|
||||
get: function ($value) {
|
||||
$unitPurchaseCost = $this->getRawOriginal('purchase_cost');
|
||||
$assignedQty = $this->pivot?->assigned_qty;
|
||||
|
||||
if ($unitPurchaseCost === null) {
|
||||
return $assignedQty !== null ? 0.0 : null;
|
||||
}
|
||||
|
||||
if ($assignedQty !== null) {
|
||||
return (float) $unitPurchaseCost * (int) $assignedQty;
|
||||
}
|
||||
|
||||
return (float) $unitPurchaseCost;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,6 +323,19 @@ class Component extends SnipeModel
|
||||
|
||||
}
|
||||
|
||||
public function percentRemaining()
|
||||
{
|
||||
$totalQuantity = (int) $this->qty;
|
||||
|
||||
if ($totalQuantity <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$availableQuantity = max(0, min($this->numRemaining(), $totalQuantity));
|
||||
|
||||
return ($availableQuantity / $totalQuantity) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether to send a checkin/checkout email based on
|
||||
* asset model category
|
||||
@@ -397,93 +416,6 @@ class Component extends SnipeModel
|
||||
* -----------------------------------------------
|
||||
**/
|
||||
|
||||
/**
|
||||
* Query builder scope to search on text filters for complex Bootstrap Tables API
|
||||
*
|
||||
* @param Builder $query Query builder instance
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $fieldname => $search_val) {
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('components.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'notes') {
|
||||
$query->where('components.notes', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'model_number') {
|
||||
$query->where('components.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'order_number') {
|
||||
$query->where('components.order_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'serial') {
|
||||
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'serial') {
|
||||
$query->where('components.serial', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'purchase_cost') {
|
||||
$query->where('components.purchase_cost', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'location') {
|
||||
$query->whereHas(
|
||||
'location', function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'manufacturer') {
|
||||
$query->whereHas(
|
||||
'manufacturer', function ($query) use ($search_val) {
|
||||
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'supplier') {
|
||||
$query->whereHas(
|
||||
'supplier', function ($query) use ($search_val) {
|
||||
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'category') {
|
||||
$query->whereHas(
|
||||
'category', function ($query) use ($search_val) {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'company') {
|
||||
$query->whereHas(
|
||||
'company', function ($query) use ($search_val) {
|
||||
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to order on company
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
+103
-95
@@ -96,7 +96,15 @@ class Consumable extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'order_number', 'purchase_cost', 'purchase_date', 'item_no', 'model_number', 'notes'];
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'order_number',
|
||||
'purchase_cost',
|
||||
'purchase_date',
|
||||
'item_no',
|
||||
'model_number',
|
||||
'notes',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
@@ -109,6 +117,7 @@ class Consumable extends SnipeModel
|
||||
'location' => ['name'],
|
||||
'manufacturer' => ['name'],
|
||||
'supplier' => ['name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -141,20 +150,6 @@ class Consumable extends SnipeModel
|
||||
&& ($this->deleted_at == '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the consumable -> admin user relationship
|
||||
*
|
||||
* @author [A. Gianotto] [<snipe@snipe.net>]
|
||||
*
|
||||
* @since [v3.0]
|
||||
*
|
||||
* @return Relation
|
||||
*/
|
||||
public function adminuser()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by')->withTrashed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the component -> assignments relationship
|
||||
*
|
||||
@@ -169,6 +164,19 @@ class Consumable extends SnipeModel
|
||||
return $this->hasMany(ConsumableAssignment::class);
|
||||
}
|
||||
|
||||
public function percentRemaining()
|
||||
{
|
||||
if ($this->consumables_users_count == 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (($this->qty == '') || ($this->qty == 0)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return ($this->qty - $this->consumables_users_count) / $this->qty * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes the component -> company relationship
|
||||
*
|
||||
@@ -248,7 +256,7 @@ class Consumable extends SnipeModel
|
||||
*
|
||||
* @return string | false
|
||||
*/
|
||||
public function getImageUrl()
|
||||
public function getImageUrl($path = null)
|
||||
{
|
||||
// If there is a consumable image, use that
|
||||
if ($this->image) {
|
||||
@@ -408,85 +416,6 @@ class Consumable extends SnipeModel
|
||||
* @param text $filter JSON array of search keys and terms
|
||||
* @return Builder Modified query builder
|
||||
*/
|
||||
public function scopeByFilter($query, $filter)
|
||||
{
|
||||
return $query->where(
|
||||
function ($query) use ($filter) {
|
||||
foreach ($filter as $fieldname => $search_val) {
|
||||
|
||||
if ($fieldname == 'name') {
|
||||
$query->where('consumables.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'notes') {
|
||||
$query->where('consumables.notes', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'model_number') {
|
||||
$query->where('consumables.model_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'order_number') {
|
||||
$query->where('consumables.order_number', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'item_no') {
|
||||
$query->where('consumables.item_no', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'serial') {
|
||||
$query->where('consumables.serial', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'purchase_cost') {
|
||||
$query->where('consumables.purchase_cost', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
|
||||
if ($fieldname == 'location') {
|
||||
$query->whereHas(
|
||||
'location', function ($query) use ($search_val) {
|
||||
$query->where('locations.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'manufacturer') {
|
||||
$query->whereHas(
|
||||
'manufacturer', function ($query) use ($search_val) {
|
||||
$query->where('manufacturers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'supplier') {
|
||||
$query->whereHas(
|
||||
'supplier', function ($query) use ($search_val) {
|
||||
$query->where('suppliers.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'category') {
|
||||
$query->whereHas(
|
||||
'category', function ($query) use ($search_val) {
|
||||
$query->where('categories.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if ($fieldname == 'company') {
|
||||
$query->whereHas(
|
||||
'company', function ($query) use ($search_val) {
|
||||
$query->where('companies.name', 'LIKE', '%'.$search_val.'%');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder scope to order on company
|
||||
@@ -566,4 +495,83 @@ class Consumable extends SnipeModel
|
||||
{
|
||||
return $query->leftJoin('users as users_sort', 'consumables.created_by', '=', 'users_sort.id')->select('consumables.*')->orderBy('users_sort.first_name', $order)->orderBy('users_sort.last_name', $order);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logic after a consumable checkout is accepted by the user.
|
||||
*
|
||||
* @param string|null $signature
|
||||
* @param string|null $filename
|
||||
*/
|
||||
public function acceptedCheckout(User $acceptedBy, ?int $qty = null, ?string $note = null, $signature = null, $filename = null): void
|
||||
{
|
||||
// Find the pending acceptance for this user and consumable
|
||||
$acceptance = $acceptedBy->getAssignedItemsWithPendingAcceptance()
|
||||
->where('item_id', $this->id)
|
||||
->where('qty', $qty)
|
||||
->where('item_type', self::class)
|
||||
->whereNull('declined_at')
|
||||
->sortByDesc('created_at')
|
||||
->first();
|
||||
|
||||
if ($acceptance) {
|
||||
if ($qty !== null) {
|
||||
$acceptance->qty = $qty;
|
||||
}
|
||||
if ($note !== null) {
|
||||
$acceptance->note = $note;
|
||||
}
|
||||
$acceptance->save();
|
||||
}
|
||||
|
||||
// Attach the consumable to the user if not already attached
|
||||
$pivot = $acceptedBy->consumables()->where('consumable_id', $this->id)->first();
|
||||
if (! $pivot) {
|
||||
$acceptedBy->consumables()->attach($this->id, [
|
||||
'created_by' => $acceptance?->created_by ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Logging handled by event listener; do not log here to avoid duplicates.
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logic after a consumable checkout is declined by the user.
|
||||
*
|
||||
* @param string|null $signature
|
||||
*/
|
||||
public function declinedCheckout(User $declinedBy, $signature = null): void
|
||||
{
|
||||
// Find the pending acceptance for this user and consumable
|
||||
$acceptance = $declinedBy->acceptances()
|
||||
->where('item_id', $this->id)
|
||||
->where('item_type', self::class)
|
||||
->whereNull('accepted_at')
|
||||
->latest('created_at')
|
||||
->first();
|
||||
|
||||
$qty = $acceptance?->qty ?? 1;
|
||||
$note = $acceptance?->note;
|
||||
|
||||
// Detach the consumable from the user (if present)
|
||||
$declinedBy->consumables()->detach($this->id);
|
||||
|
||||
// Logging handled by event listener; do not log here to avoid duplicates.
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an acceptance or decline action for this consumable.
|
||||
*/
|
||||
protected function logActionAcceptance(string $actionType, User $user, int $qty, ?string $note = null): void
|
||||
{
|
||||
$this->assetlog()->create([
|
||||
'action_type' => $actionType,
|
||||
'target_id' => $user->id,
|
||||
'target_type' => User::class,
|
||||
'item_id' => $this->id,
|
||||
'item_type' => self::class,
|
||||
'quantity' => $qty,
|
||||
'note' => $note,
|
||||
'created_by' => auth()->id() ?? $user->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ class CustomField extends Model
|
||||
'show_in_listview' => 'boolean',
|
||||
'show_in_requestable_list' => 'boolean',
|
||||
'show_in_email' => 'boolean',
|
||||
'format' => 'nullable|string|max:191',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
@@ -87,6 +88,30 @@ class CustomField extends Model
|
||||
'show_in_requestable_list',
|
||||
];
|
||||
|
||||
/**
|
||||
* The attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'format',
|
||||
'element',
|
||||
'db_column',
|
||||
'help_text',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [
|
||||
'fieldset' => ['name'],
|
||||
'assetModels' => ['name'],
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
/**
|
||||
* This is confusing, since it's actually the custom fields table that
|
||||
* we're usually modifying, but since we alter the assets table, we have to
|
||||
|
||||
@@ -4,11 +4,14 @@ namespace App\Models;
|
||||
|
||||
use App\Http\Traits\UniqueUndeletedTrait;
|
||||
use App\Models\Traits\CompanyableTrait;
|
||||
use App\Models\Traits\HasUploads;
|
||||
use App\Models\Traits\Loggable;
|
||||
use App\Models\Traits\Searchable;
|
||||
use App\Presenters\DepartmentPresenter;
|
||||
use App\Presenters\Presentable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\Relation;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Watson\Validating\ValidatingTrait;
|
||||
@@ -17,6 +20,9 @@ class Department extends SnipeModel
|
||||
{
|
||||
use CompanyableTrait;
|
||||
use HasFactory;
|
||||
use HasUploads;
|
||||
use Loggable;
|
||||
use SoftDeletes;
|
||||
|
||||
/**
|
||||
* Whether the model should inject it's identifier to the unique
|
||||
@@ -71,14 +77,21 @@ class Department extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'notes', 'phone', 'fax'];
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'notes',
|
||||
'phone',
|
||||
'fax',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [];
|
||||
protected $searchableRelations = [
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
public function isDeletable()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -40,7 +40,10 @@ class Depreciation extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $fillable = ['name', 'months'];
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'months',
|
||||
];
|
||||
|
||||
use Searchable;
|
||||
|
||||
@@ -49,14 +52,19 @@ class Depreciation extends SnipeModel
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableAttributes = ['name', 'months'];
|
||||
protected $searchableAttributes = [
|
||||
'name',
|
||||
'months',
|
||||
];
|
||||
|
||||
/**
|
||||
* The relations and their attributes that should be included when searching the model.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $searchableRelations = [];
|
||||
protected $searchableRelations = [
|
||||
'adminuser' => ['first_name', 'last_name', 'display_name'],
|
||||
];
|
||||
|
||||
public function isDeletable()
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user