diff --git a/README.md b/README.md index 8d3b801f5..e8f8f6f55 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,9 @@ See details here: [ChiefOnboarding on Docker Hub](https://hub.docker.com/r/chief [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/chiefonboarding/chiefonboarding) +**Elestio** + +[![Deploy](https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png)](https://elest.io/open-source/chiefonboarding) ## Support This software is provided under an open source license and comes as is. If you have any questions, then you will have to open an issue on Github for that. If you want guaranteed, quick support, then we offer a paid support package for that (best effort - generally under 2 hours response time). Please see our [pricing page](https://chiefonboarding.com/pricing) for more details. diff --git a/back/Pipfile.lock b/back/Pipfile.lock index a3dfe26f9..f7eca21b1 100644 --- a/back/Pipfile.lock +++ b/back/Pipfile.lock @@ -155,15 +155,16 @@ "sha256:9f36834a1a777002b4b4600415ced83bc62d42b9c36d8c75f5fc007a58d0ae17" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==1.28.42" }, "botocore": { "hashes": [ - "sha256:46b0a75a38521aa6a75fddccb1542e002930e609d4e13516f40fef170d32e515", - "sha256:6d09881c5a8be34b497872ca3936f8757d886a6f42f2a8703411928189cfedc0" + "sha256:002f8bdca8efde50ae7267f342bc1d03a71d76024ce3949e4ffdd1151581c53e", + "sha256:83a3ca4d9247fdbde76c654137e6ab648bd976f652ce2354def1715c838af505" ], "markers": "python_version >= '3.7'", - "version": "==1.31.52" + "version": "==1.31.58" }, "certifi": { "hashes": [ @@ -175,153 +176,157 @@ }, "cffi": { "hashes": [ - "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5", - "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef", - "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104", - "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426", - "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405", - "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375", - "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a", - "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e", - "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc", - "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf", - "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185", - "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497", - "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3", - "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35", - "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c", - "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83", - "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21", - "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca", - "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984", - "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac", - "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd", - "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee", - "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a", - "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2", - "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192", - "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7", - "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585", - "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f", - "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e", - "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27", - "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b", - "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e", - "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e", - "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d", - "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c", - "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415", - "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82", - "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02", - "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314", - "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325", - "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c", - "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3", - "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914", - "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045", - "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d", - "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9", - "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5", - "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2", - "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c", - "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3", - "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2", - "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8", - "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d", - "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d", - "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9", - "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162", - "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76", - "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4", - "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e", - "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9", - "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6", - "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b", - "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01", - "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0" - ], - "version": "==1.15.1" + "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc", + "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a", + "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417", + "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab", + "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520", + "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36", + "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743", + "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8", + "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed", + "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684", + "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56", + "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324", + "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d", + "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235", + "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e", + "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088", + "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000", + "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7", + "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e", + "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673", + "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c", + "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe", + "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2", + "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098", + "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8", + "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a", + "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0", + "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b", + "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896", + "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e", + "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9", + "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2", + "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b", + "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6", + "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404", + "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f", + "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0", + "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4", + "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc", + "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936", + "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba", + "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872", + "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb", + "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614", + "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1", + "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d", + "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969", + "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b", + "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4", + "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627", + "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956", + "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357" + ], + "markers": "python_version >= '3.8'", + "version": "==1.16.0" }, "charset-normalizer": { "hashes": [ - "sha256:04e57ab9fbf9607b77f7d057974694b4f6b142da9ed4a199859d9d4d5c63fe96", - "sha256:09393e1b2a9461950b1c9a45d5fd251dc7c6f228acab64da1c9c0165d9c7765c", - "sha256:0b87549028f680ca955556e3bd57013ab47474c3124dc069faa0b6545b6c9710", - "sha256:1000fba1057b92a65daec275aec30586c3de2401ccdcd41f8a5c1e2c87078706", - "sha256:1249cbbf3d3b04902ff081ffbb33ce3377fa6e4c7356f759f3cd076cc138d020", - "sha256:1920d4ff15ce893210c1f0c0e9d19bfbecb7983c76b33f046c13a8ffbd570252", - "sha256:193cbc708ea3aca45e7221ae58f0fd63f933753a9bfb498a3b474878f12caaad", - "sha256:1a100c6d595a7f316f1b6f01d20815d916e75ff98c27a01ae817439ea7726329", - "sha256:1f30b48dd7fa1474554b0b0f3fdfdd4c13b5c737a3c6284d3cdc424ec0ffff3a", - "sha256:203f0c8871d5a7987be20c72442488a0b8cfd0f43b7973771640fc593f56321f", - "sha256:246de67b99b6851627d945db38147d1b209a899311b1305dd84916f2b88526c6", - "sha256:2dee8e57f052ef5353cf608e0b4c871aee320dd1b87d351c28764fc0ca55f9f4", - "sha256:2efb1bd13885392adfda4614c33d3b68dee4921fd0ac1d3988f8cbb7d589e72a", - "sha256:2f4ac36d8e2b4cc1aa71df3dd84ff8efbe3bfb97ac41242fbcfc053c67434f46", - "sha256:3170c9399da12c9dc66366e9d14da8bf7147e1e9d9ea566067bbce7bb74bd9c2", - "sha256:3b1613dd5aee995ec6d4c69f00378bbd07614702a315a2cf6c1d21461fe17c23", - "sha256:3bb3d25a8e6c0aedd251753a79ae98a093c7e7b471faa3aa9a93a81431987ace", - "sha256:3bb7fda7260735efe66d5107fb7e6af6a7c04c7fce9b2514e04b7a74b06bf5dd", - "sha256:41b25eaa7d15909cf3ac4c96088c1f266a9a93ec44f87f1d13d4a0e86c81b982", - "sha256:45de3f87179c1823e6d9e32156fb14c1927fcc9aba21433f088fdfb555b77c10", - "sha256:46fb8c61d794b78ec7134a715a3e564aafc8f6b5e338417cb19fe9f57a5a9bf2", - "sha256:48021783bdf96e3d6de03a6e39a1171ed5bd7e8bb93fc84cc649d11490f87cea", - "sha256:4957669ef390f0e6719db3613ab3a7631e68424604a7b448f079bee145da6e09", - "sha256:5e86d77b090dbddbe78867a0275cb4df08ea195e660f1f7f13435a4649e954e5", - "sha256:6339d047dab2780cc6220f46306628e04d9750f02f983ddb37439ca47ced7149", - "sha256:681eb3d7e02e3c3655d1b16059fbfb605ac464c834a0c629048a30fad2b27489", - "sha256:6c409c0deba34f147f77efaa67b8e4bb83d2f11c8806405f76397ae5b8c0d1c9", - "sha256:7095f6fbfaa55defb6b733cfeb14efaae7a29f0b59d8cf213be4e7ca0b857b80", - "sha256:70c610f6cbe4b9fce272c407dd9d07e33e6bf7b4aa1b7ffb6f6ded8e634e3592", - "sha256:72814c01533f51d68702802d74f77ea026b5ec52793c791e2da806a3844a46c3", - "sha256:7a4826ad2bd6b07ca615c74ab91f32f6c96d08f6fcc3902ceeedaec8cdc3bcd6", - "sha256:7c70087bfee18a42b4040bb9ec1ca15a08242cf5867c58726530bdf3945672ed", - "sha256:855eafa5d5a2034b4621c74925d89c5efef61418570e5ef9b37717d9c796419c", - "sha256:8700f06d0ce6f128de3ccdbc1acaea1ee264d2caa9ca05daaf492fde7c2a7200", - "sha256:89f1b185a01fe560bc8ae5f619e924407efca2191b56ce749ec84982fc59a32a", - "sha256:8b2c760cfc7042b27ebdb4a43a4453bd829a5742503599144d54a032c5dc7e9e", - "sha256:8c2f5e83493748286002f9369f3e6607c565a6a90425a3a1fef5ae32a36d749d", - "sha256:8e098148dd37b4ce3baca71fb394c81dc5d9c7728c95df695d2dca218edf40e6", - "sha256:94aea8eff76ee6d1cdacb07dd2123a68283cb5569e0250feab1240058f53b623", - "sha256:95eb302ff792e12aba9a8b8f8474ab229a83c103d74a750ec0bd1c1eea32e669", - "sha256:9bd9b3b31adcb054116447ea22caa61a285d92e94d710aa5ec97992ff5eb7cf3", - "sha256:9e608aafdb55eb9f255034709e20d5a83b6d60c054df0802fa9c9883d0a937aa", - "sha256:a103b3a7069b62f5d4890ae1b8f0597618f628b286b03d4bc9195230b154bfa9", - "sha256:a386ebe437176aab38c041de1260cd3ea459c6ce5263594399880bbc398225b2", - "sha256:a38856a971c602f98472050165cea2cdc97709240373041b69030be15047691f", - "sha256:a401b4598e5d3f4a9a811f3daf42ee2291790c7f9d74b18d75d6e21dda98a1a1", - "sha256:a7647ebdfb9682b7bb97e2a5e7cb6ae735b1c25008a70b906aecca294ee96cf4", - "sha256:aaf63899c94de41fe3cf934601b0f7ccb6b428c6e4eeb80da72c58eab077b19a", - "sha256:b0dac0ff919ba34d4df1b6131f59ce95b08b9065233446be7e459f95554c0dc8", - "sha256:baacc6aee0b2ef6f3d308e197b5d7a81c0e70b06beae1f1fcacffdbd124fe0e3", - "sha256:bf420121d4c8dce6b889f0e8e4ec0ca34b7f40186203f06a946fa0276ba54029", - "sha256:c04a46716adde8d927adb9457bbe39cf473e1e2c2f5d0a16ceb837e5d841ad4f", - "sha256:c0b21078a4b56965e2b12f247467b234734491897e99c1d51cee628da9786959", - "sha256:c1c76a1743432b4b60ab3358c937a3fe1341c828ae6194108a94c69028247f22", - "sha256:c4983bf937209c57240cff65906b18bb35e64ae872da6a0db937d7b4af845dd7", - "sha256:c4fb39a81950ec280984b3a44f5bd12819953dc5fa3a7e6fa7a80db5ee853952", - "sha256:c57921cda3a80d0f2b8aec7e25c8aa14479ea92b5b51b6876d975d925a2ea346", - "sha256:c8063cf17b19661471ecbdb3df1c84f24ad2e389e326ccaf89e3fb2484d8dd7e", - "sha256:ccd16eb18a849fd8dcb23e23380e2f0a354e8daa0c984b8a732d9cfaba3a776d", - "sha256:cd6dbe0238f7743d0efe563ab46294f54f9bc8f4b9bcf57c3c666cc5bc9d1299", - "sha256:d62e51710986674142526ab9f78663ca2b0726066ae26b78b22e0f5e571238dd", - "sha256:db901e2ac34c931d73054d9797383d0f8009991e723dab15109740a63e7f902a", - "sha256:e03b8895a6990c9ab2cdcd0f2fe44088ca1c65ae592b8f795c3294af00a461c3", - "sha256:e1c8a2f4c69e08e89632defbfabec2feb8a8d99edc9f89ce33c4b9e36ab63037", - "sha256:e4b749b9cc6ee664a3300bb3a273c1ca8068c46be705b6c31cf5d276f8628a94", - "sha256:e6a5bf2cba5ae1bb80b154ed68a3cfa2fa00fde979a7f50d6598d3e17d9ac20c", - "sha256:e857a2232ba53ae940d3456f7533ce6ca98b81917d47adc3c7fd55dad8fab858", - "sha256:ee4006268ed33370957f55bf2e6f4d263eaf4dc3cfc473d1d90baff6ed36ce4a", - "sha256:eef9df1eefada2c09a5e7a40991b9fc6ac6ef20b1372abd48d2794a316dc0449", - "sha256:f058f6963fd82eb143c692cecdc89e075fa0828db2e5b291070485390b2f1c9c", - "sha256:f25c229a6ba38a35ae6e25ca1264621cc25d4d38dca2942a7fce0b67a4efe918", - "sha256:f2a1d0fd4242bd8643ce6f98927cf9c04540af6efa92323e9d3124f57727bfc1", - "sha256:f7560358a6811e52e9c4d142d497f1a6e10103d3a6881f18d04dbce3729c0e2c", - "sha256:f779d3ad205f108d14e99bb3859aa7dd8e9c68874617c72354d7ecaec2a054ac", - "sha256:f87f746ee241d30d6ed93969de31e5ffd09a2961a051e60ae6bddde9ec3583aa" - ], - "markers": "python_version >= '3.7'", - "version": "==3.2.0" + "sha256:02673e456dc5ab13659f85196c534dc596d4ef260e4d86e856c3b2773ce09843", + "sha256:02af06682e3590ab952599fbadac535ede5d60d78848e555aa58d0c0abbde786", + "sha256:03680bb39035fbcffe828eae9c3f8afc0428c91d38e7d61aa992ef7a59fb120e", + "sha256:0570d21da019941634a531444364f2482e8db0b3425fcd5ac0c36565a64142c8", + "sha256:09c77f964f351a7369cc343911e0df63e762e42bac24cd7d18525961c81754f4", + "sha256:0d3d5b7db9ed8a2b11a774db2bbea7ba1884430a205dbd54a32d61d7c2a190fa", + "sha256:1063da2c85b95f2d1a430f1c33b55c9c17ffaf5e612e10aeaad641c55a9e2b9d", + "sha256:12ebea541c44fdc88ccb794a13fe861cc5e35d64ed689513a5c03d05b53b7c82", + "sha256:153e7b6e724761741e0974fc4dcd406d35ba70b92bfe3fedcb497226c93b9da7", + "sha256:15b26ddf78d57f1d143bdf32e820fd8935d36abe8a25eb9ec0b5a71c82eb3895", + "sha256:1872d01ac8c618a8da634e232f24793883d6e456a66593135aeafe3784b0848d", + "sha256:187d18082694a29005ba2944c882344b6748d5be69e3a89bf3cc9d878e548d5a", + "sha256:1b2919306936ac6efb3aed1fbf81039f7087ddadb3160882a57ee2ff74fd2382", + "sha256:232ac332403e37e4a03d209a3f92ed9071f7d3dbda70e2a5e9cff1c4ba9f0678", + "sha256:23e8565ab7ff33218530bc817922fae827420f143479b753104ab801145b1d5b", + "sha256:24817cb02cbef7cd499f7c9a2735286b4782bd47a5b3516a0e84c50eab44b98e", + "sha256:249c6470a2b60935bafd1d1d13cd613f8cd8388d53461c67397ee6a0f5dce741", + "sha256:24a91a981f185721542a0b7c92e9054b7ab4fea0508a795846bc5b0abf8118d4", + "sha256:2502dd2a736c879c0f0d3e2161e74d9907231e25d35794584b1ca5284e43f596", + "sha256:250c9eb0f4600361dd80d46112213dff2286231d92d3e52af1e5a6083d10cad9", + "sha256:278c296c6f96fa686d74eb449ea1697f3c03dc28b75f873b65b5201806346a69", + "sha256:2935ffc78db9645cb2086c2f8f4cfd23d9b73cc0dc80334bc30aac6f03f68f8c", + "sha256:2f4a0033ce9a76e391542c182f0d48d084855b5fcba5010f707c8e8c34663d77", + "sha256:30a85aed0b864ac88309b7d94be09f6046c834ef60762a8833b660139cfbad13", + "sha256:380c4bde80bce25c6e4f77b19386f5ec9db230df9f2f2ac1e5ad7af2caa70459", + "sha256:3ae38d325b512f63f8da31f826e6cb6c367336f95e418137286ba362925c877e", + "sha256:3b447982ad46348c02cb90d230b75ac34e9886273df3a93eec0539308a6296d7", + "sha256:3debd1150027933210c2fc321527c2299118aa929c2f5a0a80ab6953e3bd1908", + "sha256:4162918ef3098851fcd8a628bf9b6a98d10c380725df9e04caf5ca6dd48c847a", + "sha256:468d2a840567b13a590e67dd276c570f8de00ed767ecc611994c301d0f8c014f", + "sha256:4cc152c5dd831641e995764f9f0b6589519f6f5123258ccaca8c6d34572fefa8", + "sha256:542da1178c1c6af8873e143910e2269add130a299c9106eef2594e15dae5e482", + "sha256:557b21a44ceac6c6b9773bc65aa1b4cc3e248a5ad2f5b914b91579a32e22204d", + "sha256:5707a746c6083a3a74b46b3a631d78d129edab06195a92a8ece755aac25a3f3d", + "sha256:588245972aca710b5b68802c8cad9edaa98589b1b42ad2b53accd6910dad3545", + "sha256:5adf257bd58c1b8632046bbe43ee38c04e1038e9d37de9c57a94d6bd6ce5da34", + "sha256:619d1c96099be5823db34fe89e2582b336b5b074a7f47f819d6b3a57ff7bdb86", + "sha256:63563193aec44bce707e0c5ca64ff69fa72ed7cf34ce6e11d5127555756fd2f6", + "sha256:67b8cc9574bb518ec76dc8e705d4c39ae78bb96237cb533edac149352c1f39fe", + "sha256:6a685067d05e46641d5d1623d7c7fdf15a357546cbb2f71b0ebde91b175ffc3e", + "sha256:70f1d09c0d7748b73290b29219e854b3207aea922f839437870d8cc2168e31cc", + "sha256:750b446b2ffce1739e8578576092179160f6d26bd5e23eb1789c4d64d5af7dc7", + "sha256:7966951325782121e67c81299a031f4c115615e68046f79b85856b86ebffc4cd", + "sha256:7b8b8bf1189b3ba9b8de5c8db4d541b406611a71a955bbbd7385bbc45fcb786c", + "sha256:7f5d10bae5d78e4551b7be7a9b29643a95aded9d0f602aa2ba584f0388e7a557", + "sha256:805dfea4ca10411a5296bcc75638017215a93ffb584c9e344731eef0dcfb026a", + "sha256:81bf654678e575403736b85ba3a7867e31c2c30a69bc57fe88e3ace52fb17b89", + "sha256:82eb849f085624f6a607538ee7b83a6d8126df6d2f7d3b319cb837b289123078", + "sha256:85a32721ddde63c9df9ebb0d2045b9691d9750cb139c161c80e500d210f5e26e", + "sha256:86d1f65ac145e2c9ed71d8ffb1905e9bba3a91ae29ba55b4c46ae6fc31d7c0d4", + "sha256:86f63face3a527284f7bb8a9d4f78988e3c06823f7bea2bd6f0e0e9298ca0403", + "sha256:8eaf82f0eccd1505cf39a45a6bd0a8cf1c70dcfc30dba338207a969d91b965c0", + "sha256:93aa7eef6ee71c629b51ef873991d6911b906d7312c6e8e99790c0f33c576f89", + "sha256:96c2b49eb6a72c0e4991d62406e365d87067ca14c1a729a870d22354e6f68115", + "sha256:9cf3126b85822c4e53aa28c7ec9869b924d6fcfb76e77a45c44b83d91afd74f9", + "sha256:9fe359b2e3a7729010060fbca442ca225280c16e923b37db0e955ac2a2b72a05", + "sha256:a0ac5e7015a5920cfce654c06618ec40c33e12801711da6b4258af59a8eff00a", + "sha256:a3f93dab657839dfa61025056606600a11d0b696d79386f974e459a3fbc568ec", + "sha256:a4b71f4d1765639372a3b32d2638197f5cd5221b19531f9245fcc9ee62d38f56", + "sha256:aae32c93e0f64469f74ccc730a7cb21c7610af3a775157e50bbd38f816536b38", + "sha256:aaf7b34c5bc56b38c931a54f7952f1ff0ae77a2e82496583b247f7c969eb1479", + "sha256:abecce40dfebbfa6abf8e324e1860092eeca6f7375c8c4e655a8afb61af58f2c", + "sha256:abf0d9f45ea5fb95051c8bfe43cb40cda383772f7e5023a83cc481ca2604d74e", + "sha256:ac71b2977fb90c35d41c9453116e283fac47bb9096ad917b8819ca8b943abecd", + "sha256:ada214c6fa40f8d800e575de6b91a40d0548139e5dc457d2ebb61470abf50186", + "sha256:b09719a17a2301178fac4470d54b1680b18a5048b481cb8890e1ef820cb80455", + "sha256:b1121de0e9d6e6ca08289583d7491e7fcb18a439305b34a30b20d8215922d43c", + "sha256:b3b2316b25644b23b54a6f6401074cebcecd1244c0b8e80111c9a3f1c8e83d65", + "sha256:b3d9b48ee6e3967b7901c052b670c7dda6deb812c309439adaffdec55c6d7b78", + "sha256:b5bcf60a228acae568e9911f410f9d9e0d43197d030ae5799e20dca8df588287", + "sha256:b8f3307af845803fb0b060ab76cf6dd3a13adc15b6b451f54281d25911eb92df", + "sha256:c2af80fb58f0f24b3f3adcb9148e6203fa67dd3f61c4af146ecad033024dde43", + "sha256:c350354efb159b8767a6244c166f66e67506e06c8924ed74669b2c70bc8735b1", + "sha256:c5a74c359b2d47d26cdbbc7845e9662d6b08a1e915eb015d044729e92e7050b7", + "sha256:c71f16da1ed8949774ef79f4a0260d28b83b3a50c6576f8f4f0288d109777989", + "sha256:d47ecf253780c90ee181d4d871cd655a789da937454045b17b5798da9393901a", + "sha256:d7eff0f27edc5afa9e405f7165f85a6d782d308f3b6b9d96016c010597958e63", + "sha256:d97d85fa63f315a8bdaba2af9a6a686e0eceab77b3089af45133252618e70884", + "sha256:db756e48f9c5c607b5e33dd36b1d5872d0422e960145b08ab0ec7fd420e9d649", + "sha256:dc45229747b67ffc441b3de2f3ae5e62877a282ea828a5bdb67883c4ee4a8810", + "sha256:e0fc42822278451bc13a2e8626cf2218ba570f27856b536e00cfa53099724828", + "sha256:e39c7eb31e3f5b1f88caff88bcff1b7f8334975b46f6ac6e9fc725d829bc35d4", + "sha256:e46cd37076971c1040fc8c41273a8b3e2c624ce4f2be3f5dfcb7a430c1d3acc2", + "sha256:e5c1502d4ace69a179305abb3f0bb6141cbe4714bc9b31d427329a95acfc8bdd", + "sha256:edfe077ab09442d4ef3c52cb1f9dab89bff02f4524afc0acf2d46be17dc479f5", + "sha256:effe5406c9bd748a871dbcaf3ac69167c38d72db8c9baf3ff954c344f31c4cbe", + "sha256:f0d1e3732768fecb052d90d62b220af62ead5748ac51ef61e7b32c266cac9293", + "sha256:f5969baeaea61c97efa706b9b107dcba02784b1601c74ac84f2a532ea079403e", + "sha256:f8888e31e3a85943743f8fc15e71536bda1c81d5aa36d014a3c0c44481d7db6e", + "sha256:fc52b79d83a3fe3a360902d3f5d79073a993597d48114c29485e9431092905d8" + ], + "markers": "python_full_version >= '3.7.0'", + "version": "==3.3.0" }, "croniter": { "hashes": [ @@ -329,6 +334,7 @@ "sha256:9595da48af37ea06ec3a9f899738f1b2c1c13da3c38cea606ef7cd03ea421128" ], "index": "pypi", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.4.1" }, "cryptography": { @@ -358,6 +364,7 @@ "sha256:efc8ad4e6fc4f1752ebfb58aefece8b4e3c4cae940b0994d43649bdfce8d0d4f" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==41.0.4" }, "django": { @@ -365,7 +372,7 @@ "sha256:5e5c1c9548ffb7796b4a8a4782e9a2e5a3df3615259fc1bfd3ebc73b646146c1", "sha256:b6b2b5cae821077f137dc4dade696a1c2aa292f892eca28fa8d7bfdf2608ddd4" ], - "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==4.2.5" }, "django-anymail": { @@ -374,6 +381,7 @@ "sha256:e0a65c1e235dcb863dab67a3e193e899a48503b650df483d7b73f73983fd874b" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==10.1" }, "django-axes": { @@ -382,6 +390,7 @@ "sha256:cd1bc4f7becc8e9243eb4090dffa258d7d7125ca0ce3153b6ffc920bccbf2c3f" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==6.1.1" }, "django-crispy-forms": { @@ -390,6 +399,7 @@ "sha256:d1d4e585929058a9ab3b797666ea5b69320b9ba7937f9d146d32173246a6fd13" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==2.0" }, "django-environ": { @@ -398,6 +408,7 @@ "sha256:f32a87aa0899894c27d4e1776fa6b477e8164ed7f6b3e410a62a6d72caaf64be" ], "index": "pypi", + "markers": "python_version >= '3.6' and python_version < '4'", "version": "==0.11.2" }, "django-picklefield": { @@ -422,6 +433,7 @@ "sha256:536327e36f47b723270a6624fa6a2ffaba522a6a8eebc51ab6e258257a4c93d8" ], "index": "pypi", + "markers": "python_version >= '3.8' and python_version < '4'", "version": "==1.5.5" }, "djangorestframework": { @@ -430,6 +442,7 @@ "sha256:eb63f58c9f218e1a7d064d17a70751f528ed4e1d35547fdade9aaf4cd103fd08" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==3.14.0" }, "frozenlist": { @@ -505,6 +518,7 @@ "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033" ], "index": "pypi", + "markers": "python_version >= '3.5'", "version": "==21.2.0" }, "idna": { @@ -605,11 +619,11 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "psycopg": { "extras": [ @@ -619,7 +633,7 @@ "sha256:15b25741494344c24066dc2479b0f383dd1b82fa5e75612fa4fa5bb30726e9b6", "sha256:8bbeddae5075c7890b2fa3e3553440376d3c5e28418335dee3c3656b06fa2b52" ], - "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==3.1.10" }, "psycopg-binary": { @@ -703,6 +717,7 @@ "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==2.9.0" }, "python-dateutil": { @@ -726,6 +741,7 @@ "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==2.31.0" }, "s3transfer": { @@ -766,6 +782,7 @@ "sha256:63089a401ae3900c37698890249acd008a4651d06e86194edc7b72a00819bbac" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==1.18.0" }, "slack-sdk": { @@ -773,7 +790,7 @@ "sha256:6eacce0fa4f8cfb4d84eac0d7d7e1b1926040a2df654ae86b94179bdf2bc4d8c", "sha256:f102a4902115dff3b97c3e8883ad4e22d54732221886fc5ef29bfc290f063b4a" ], - "markers": "python_version >= '3.6'", + "markers": "python_full_version >= '3.6.0'", "version": "==3.22.0" }, "sparkpost": { @@ -798,6 +815,7 @@ "sha256:ffc38ccf05cffe050670f211e872c5d8bfcad420f2ea3dcb361cb42e228b27fa" ], "index": "pypi", + "markers": "python_full_version >= '3.7.0'", "version": "==8.7.0" }, "typing-extensions": { @@ -810,11 +828,12 @@ }, "urllib3": { "hashes": [ - "sha256:8d36afa7616d8ab714608411b4a3b13e58f463aee519024578e062e141dce20f", - "sha256:8f135f6502756bde6b2a9b28989df5fbe87c9970cecaa69041edcce7f0589b14" + "sha256:24d6a242c28d29af46c3fae832c36db3bbebcc533dd1bb549172cd739c82df21", + "sha256:94a757d178c9be92ef5539b8840d48dc9cf1b2709c9d6b588232a055c524458b" ], + "index": "pypi", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==1.26.16" + "version": "==1.26.17" }, "whitenoise": { "hashes": [ @@ -822,6 +841,7 @@ "sha256:16468e9ad2189f09f4a8c635a9031cc9bb2cdbc8e5e53365407acf99f7ade9ec" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==6.5.0" }, "yarl": { @@ -940,6 +960,7 @@ "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==23.7.0" }, "click": { @@ -1006,6 +1027,7 @@ "sha256:f4f456590eefb6e1b3c9ea6328c1e9fa0f1006e7481179d749b3376fc793478e" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==7.3.1" }, "django": { @@ -1013,7 +1035,7 @@ "sha256:5e5c1c9548ffb7796b4a8a4782e9a2e5a3df3615259fc1bfd3ebc73b646146c1", "sha256:b6b2b5cae821077f137dc4dade696a1c2aa292f892eca28fa8d7bfdf2608ddd4" ], - "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==4.2.5" }, "django-debug-toolbar": { @@ -1022,6 +1044,7 @@ "sha256:bc7fdaafafcdedefcc67a4a5ad9dac96efd6e41db15bc74d402a54a2ba4854dc" ], "index": "pypi", + "markers": "python_version >= '3.8'", "version": "==4.2.0" }, "factory-boy": { @@ -1046,6 +1069,7 @@ "sha256:ea1b963b993cb9ea195adbd893a48d573fda951b0da64f60883d7e988b606c9f" ], "index": "pypi", + "markers": "python_version >= '3.6'", "version": "==1.2.2" }, "inflection": { @@ -1074,11 +1098,11 @@ }, "packaging": { "hashes": [ - "sha256:994793af429502c4ea2ebf6bf664629d07c1a9fe974af92966e4b8d2df7edc61", - "sha256:a392980d2b6cffa644431898be54b0045151319d1e7ec34f0cfed48767dd334f" + "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", + "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7" ], "markers": "python_version >= '3.7'", - "version": "==23.1" + "version": "==23.2" }, "pathspec": { "hashes": [ @@ -1090,11 +1114,11 @@ }, "platformdirs": { "hashes": [ - "sha256:b45696dab2d7cc691a3226759c0d3b00c47c8b6e293d96f6436f733303f77f6d", - "sha256:d7c24979f292f916dc9cbf8648319032f551ea8c49a4c9bf2fb556a02070ec1d" + "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3", + "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e" ], "markers": "python_version >= '3.7'", - "version": "==3.10.0" + "version": "==3.11.0" }, "pluggy": { "hashes": [ @@ -1110,6 +1134,7 @@ "sha256:460c9a59b14e27c602eb5ece2e47bec99dc5fc5f6513cf924a7d03a578991b1f" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==7.4.1" }, "pytest-cov": { @@ -1118,6 +1143,7 @@ "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==4.1.0" }, "pytest-django": { @@ -1126,6 +1152,7 @@ "sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2" ], "index": "pypi", + "markers": "python_version >= '3.5'", "version": "==4.5.2" }, "pytest-factoryboy": { @@ -1134,6 +1161,7 @@ "sha256:7275a52299b20c0f58b63fdf7326b3fd2b7cbefbdaa90fdcfc776bbe92197484" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==2.5.1" }, "python-dateutil": { @@ -1165,6 +1193,7 @@ "sha256:e9843e5704d4fb44e1a8161b0d31c1a38819723f0942639dfeb53d553be9bfb5" ], "index": "pypi", + "markers": "python_version >= '3.7'", "version": "==0.0.287" }, "six": { diff --git a/back/admin/admin_tasks/migrations/0011_admintask_based_on.py b/back/admin/admin_tasks/migrations/0011_admintask_based_on.py new file mode 100644 index 000000000..da31bd3ff --- /dev/null +++ b/back/admin/admin_tasks/migrations/0011_admintask_based_on.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-09-22 00:24 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("sequences", "0041_condition_condition_admin_tasks_and_more"), + ("admin_tasks", "0010_remove_admintask_slack_user_old"), + ] + + operations = [ + migrations.AddField( + model_name="admintask", + name="based_on", + field=models.ForeignKey( + help_text="If generated through a sequence, then this will be filled", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="sequences.pendingadmintask", + ), + ), + ] diff --git a/back/admin/admin_tasks/models.py b/back/admin/admin_tasks/models.py index 8845c3c9d..67f3ab7ef 100644 --- a/back/admin/admin_tasks/models.py +++ b/back/admin/admin_tasks/models.py @@ -56,7 +56,13 @@ class Notification(models.IntegerChoices): completed = models.BooleanField(verbose_name=_("Completed"), default=False) date = models.DateField(verbose_name=_("Date"), blank=True, null=True) priority = models.IntegerField( - verbose_name=_("Priority"), choices=Priority.choices, default=2 + verbose_name=_("Priority"), choices=Priority.choices, default=Priority.MEDIUM + ) + based_on = models.ForeignKey( + "sequences.PendingAdminTask", + null=True, + on_delete=models.SET_NULL, + help_text="If generated through a sequence, then this will be filled", ) @property @@ -68,10 +74,9 @@ def send_notification_third_party(self): # Only happens when a sequence adds this with "manager" or "buddy" is # choosen option return - if self.option == 1: - # through email + if self.option == AdminTask.Notification.EMAIL: send_email_notification_to_external_person(self) - elif self.option == 2: + elif self.option == AdminTask.Notification.SLACK: blocks = [ paragraph( _( @@ -136,6 +141,37 @@ def send_notification_new_assigned(self): else: send_email_new_assigned_admin(self) + def mark_completed(self): + from admin.sequences.tasks import process_condition + + self.completed = True + self.save() + + # Get conditions with this to do item as (part of the) condition + conditions = self.new_hire.conditions.filter( + condition_admin_tasks=self.based_on + ) + + for condition in conditions: + condition_admin_tasks_id = condition.condition_admin_tasks.values_list( + "id", flat=True + ) + + # Check if all admin to do items have been added to new hire and are + # completed. If not, then we know it should not be triggered yet + completed_tasks = AdminTask.objects.filter( + based_on_id__in=condition_admin_tasks_id, + new_hire=self.new_hire, + completed=True, + ) + + # If the amount matches, then we should process it + if completed_tasks.count() == len(condition_admin_tasks_id): + # Send notification only if user has a slack account + process_condition( + condition.id, self.new_hire.id, self.new_hire.has_slack_account + ) + class Meta: ordering = ["completed", "date"] diff --git a/back/admin/admin_tasks/templates/admin_tasks_detail.html b/back/admin/admin_tasks/templates/admin_tasks_detail.html index 24d59635b..5984ebe51 100644 --- a/back/admin/admin_tasks/templates/admin_tasks_detail.html +++ b/back/admin/admin_tasks/templates/admin_tasks_detail.html @@ -2,16 +2,14 @@ {% load i18n %} {% load crispy_forms_tags %} {% block actions %} -
- {% csrf_token %} - -
+ + +{% endif %} {% endblock %} {% block content %} diff --git a/back/admin/admin_tasks/tests.py b/back/admin/admin_tasks/tests.py index b6911e844..da18e7fad 100644 --- a/back/admin/admin_tasks/tests.py +++ b/back/admin/admin_tasks/tests.py @@ -301,16 +301,8 @@ def test_complete_admin_task(client, admin_factory, admin_task_factory): assert "disabled" in response.content.decode() # Cannot add new comment assert "div_id_content" not in response.content.decode() - # Complete url is still there to make it open again - assert complete_url in response.content.decode() - - assert task1.completed - assert not task2.completed - - url = reverse("admin_tasks:mine") - response = client.get(url) - # Check button is now visible - assert "btn-success" in response.content.decode() + # Complete url is gone + assert complete_url not in response.content.decode() @pytest.mark.django_db @@ -416,3 +408,54 @@ def test_admin_task_comment_on_not_owned_task_slack_message( ], }, ] + + +@pytest.mark.django_db +def test_complete_admin_task_trigger_condition( + client, + admin_factory, + sequence_factory, + condition_admin_task_factory, + pending_admin_task_factory, + new_hire_factory, +): + admin = admin_factory() + client.force_login(admin) + + task_to_complete1 = pending_admin_task_factory(assigned_to=admin) + task_to_complete2 = pending_admin_task_factory(assigned_to=admin) + + # add tasks to sequence to be added to new hire directly + sequence = sequence_factory() + unconditioned_condition = sequence.conditions.first() + unconditioned_condition.admin_tasks.add(task_to_complete1, task_to_complete2) + + # set up condition when both tasks are completed to create a third one + task_to_be_created = pending_admin_task_factory() + admin_task_condition = condition_admin_task_factory() + admin_task_condition.condition_admin_tasks.set( + [task_to_complete1, task_to_complete2] + ) + admin_task_condition.admin_tasks.add(task_to_be_created) + sequence.conditions.add(admin_task_condition) + + new_hire = new_hire_factory() + + new_hire.add_sequences([sequence]) + + assert new_hire.conditions.count() == 1 + + # new hire has now two admin tasks + assert AdminTask.objects.filter(new_hire=new_hire).count() == 2 + + # first task gets completed + AdminTask.objects.get(based_on=task_to_complete1).mark_completed() + + # still two tasks + assert AdminTask.objects.filter(new_hire=new_hire).count() == 2 + + # second task gets completed + AdminTask.objects.get(based_on=task_to_complete2).mark_completed() + + # we now have 3 tasks + assert AdminTask.objects.filter(new_hire=new_hire).count() == 3 diff --git a/back/admin/admin_tasks/urls.py b/back/admin/admin_tasks/urls.py index 43a1ce44d..734ecacc6 100644 --- a/back/admin/admin_tasks/urls.py +++ b/back/admin/admin_tasks/urls.py @@ -9,7 +9,7 @@ path("all/", views.AllAdminTasksListView.as_view(), name="all"), path("/", views.AdminTasksUpdateView.as_view(), name="detail"), path( - "/completed/", views.AdminTaskToggleDoneView.as_view(), name="completed" + "/completed/", views.AdminTaskCompleteView.as_view(), name="completed" ), path( "/comment/", views.AdminTasksCommentCreateView.as_view(), name="comment" diff --git a/back/admin/admin_tasks/views.py b/back/admin/admin_tasks/views.py index 0d2111d45..47ac260b1 100644 --- a/back/admin/admin_tasks/views.py +++ b/back/admin/admin_tasks/views.py @@ -46,13 +46,12 @@ def get_context_data(self, **kwargs): return context -class AdminTaskToggleDoneView(LoginRequiredMixin, ManagerPermMixin, BaseDetailView): +class AdminTaskCompleteView(LoginRequiredMixin, ManagerPermMixin, BaseDetailView): model = AdminTask def post(self, request, *args, **kwargs): admin_task = self.get_object() - admin_task.completed = not admin_task.completed - admin_task.save() + admin_task.mark_completed() return redirect("admin_tasks:detail", pk=admin_task.id) diff --git a/back/admin/integrations/exceptions.py b/back/admin/integrations/exceptions.py index 1ce6a3f2a..9875eea07 100644 --- a/back/admin/integrations/exceptions.py +++ b/back/admin/integrations/exceptions.py @@ -1,4 +1,4 @@ -class GettingUsersError(Exception): +class FailedPaginatedResponseError(Exception): pass diff --git a/back/admin/integrations/factories.py b/back/admin/integrations/factories.py index a1ca0bf45..fef06539b 100644 --- a/back/admin/integrations/factories.py +++ b/back/admin/integrations/factories.py @@ -86,10 +86,8 @@ class CustomIntegrationFactory(IntegrationFactory): class CustomUserImportIntegrationFactory(IntegrationFactory): integration = Integration.Type.CUSTOM - manifest_type = Integration.ManifestType.USER_IMPORT + manifest_type = Integration.ManifestType.SYNC_USERS manifest = { - "form": [], - "type": "import_users", "execute": [ { "url": "http://localhost/api/gateway.php/{{COMPANY_ID}}/v1/reports/{{REPORT_ID}}", diff --git a/back/admin/integrations/forms.py b/back/admin/integrations/forms.py index 5e7cfd2a9..46895710b 100644 --- a/back/admin/integrations/forms.py +++ b/back/admin/integrations/forms.py @@ -3,12 +3,10 @@ from crispy_forms.helper import FormHelper from django import forms from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError from admin.integrations.utils import get_value_from_notation from .models import Integration -from .serializers import ManifestSerializer class IntegrationConfigForm(forms.ModelForm): @@ -131,13 +129,9 @@ class Meta: def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields["manifest_type"].required = True - - def clean_manifest(self): - manifest = self.cleaned_data["manifest"] - manifest_serializer = ManifestSerializer(data=manifest) - if not manifest_serializer.is_valid(): - raise ValidationError(json.dumps(manifest_serializer.errors)) - return manifest + if self.instance.id: + # disable manifest_type when updating field + self.fields["manifest_type"].disabled = True class IntegrationExtraArgsForm(forms.ModelForm): diff --git a/back/admin/integrations/migrations/0021_alter_integration_manifest_type.py b/back/admin/integrations/migrations/0021_alter_integration_manifest_type.py new file mode 100644 index 000000000..cc3eefe43 --- /dev/null +++ b/back/admin/integrations/migrations/0021_alter_integration_manifest_type.py @@ -0,0 +1,24 @@ +# Generated by Django 4.2.5 on 2023-10-10 00:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("integrations", "0020_integration_manifest_type"), + ] + + operations = [ + migrations.AlterField( + model_name="integration", + name="manifest_type", + field=models.IntegerField( + blank=True, + choices=[ + (0, "Provision user accounts or trigger webhooks"), + (1, "Sync users"), + ], + null=True, + ), + ), + ] diff --git a/back/admin/integrations/import_users.py b/back/admin/integrations/mixins.py similarity index 75% rename from back/admin/integrations/import_users.py rename to back/admin/integrations/mixins.py index 8c834baa2..85b0c4ebc 100644 --- a/back/admin/integrations/import_users.py +++ b/back/admin/integrations/mixins.py @@ -1,15 +1,19 @@ -from admin.integrations.exceptions import KeyIsNotInDataError -from django.contrib.auth import get_user_model -from organization.models import Organization +from admin.integrations.exceptions import ( + KeyIsNotInDataError, + FailedPaginatedResponseError, +) from admin.integrations.utils import get_value_from_notation from django.utils.translation import gettext_lazy as _ -from admin.integrations.exceptions import GettingUsersError +import logging -class ImportUser: +logger = logging.getLogger(__name__) + + +class PaginatedResponse: """ - Extension of the `Integration` model. This part is only used to get users + Extension of the `Integration` model. Generic mixin used for extracting data from a third party API endpoint and format them in a way that we can proccess them. """ @@ -17,7 +21,7 @@ class ImportUser: def __init__(self, integration): self.integration = integration - def extract_users_from_list_response(self, response): + def extract_data_from_list_response(self, response): # Building list of users from response. Dig into response to get to the users. data_from = self.integration.manifest["data_from"] @@ -43,7 +47,7 @@ def extract_users_from_list_response(self, response): except KeyError: # This is unlikely to go wrong - only when api changes or when # configs are being setup - raise KeyIsNotInDataError( + logger.info( _("Notation '%(notation)s' not in %(response)s") % { "notation": notation, @@ -88,12 +92,14 @@ def get_next_page(self, response): self.integration.params["NEXT_PAGE_TOKEN"] = token return self.integration._replace_vars(next_page) - def get_import_user_candidates(self, user): - success, response = self.integration.execute(user, {}) + def get_data_from_paginated_response(self): + success, response = self.integration.execute() if not success: - raise GettingUsersError(self.integration.clean_response(response)) + raise FailedPaginatedResponseError( + self.integration.clean_response(response) + ) - users = self.extract_users_from_list_response(response) + users = self.extract_data_from_list_response(response) amount_pages_to_fetch = self.integration.manifest.get( "amount_pages_to_fetch", 5 @@ -109,7 +115,7 @@ def get_import_user_candidates(self, user): {"method": "GET", "url": next_page_url} ) if not success: - raise GettingUsersError( + raise FailedPaginatedResponseError( _("Paginated URL fetch: %(response)s") % {"response": self.integration.clean_response(response)} ) @@ -121,22 +127,7 @@ def get_import_user_candidates(self, user): except KeyError: break - users += self.extract_users_from_list_response(response) + users += self.extract_data_from_list_response(response) fetched_pages += 1 - # Remove users that are already in the system or have been ignored - existing_user_emails = list( - get_user_model().objects.all().values_list("email", flat=True) - ) - ignored_user_emails = Organization.objects.get().ignored_user_emails - excluded_emails = ( - existing_user_emails + ignored_user_emails + ["", None] - ) # also add blank emails to ignore - - user_candidates = [ - user_data - for user_data in users - if user_data.get("email", "") not in excluded_emails - ] - - return user_candidates + return users diff --git a/back/admin/integrations/models.py b/back/admin/integrations/models.py index f2a51d0b4..d95c2bc53 100644 --- a/back/admin/integrations/models.py +++ b/back/admin/integrations/models.py @@ -6,8 +6,12 @@ from datetime import timedelta import requests + from django.conf import settings +from django.core.exceptions import ValidationError from django.db import models +from django.db.models.signals import post_delete +from django.dispatch import receiver from django.template import Context, Template from django.urls import reverse_lazy from django.utils import timezone @@ -31,6 +35,10 @@ from twilio.rest import Client from admin.integrations.utils import get_value_from_notation +from admin.integrations.serializers import ( + WebhookManifestSerializer, + SyncUsersManifestSerializer, +) from misc.fernet_fields import EncryptedTextField from misc.fields import EncryptedJSONField from organization.models import Notification @@ -58,9 +66,13 @@ def account_provision_options(self): def import_users_options(self): # only import user items - return self.get_queryset().filter( - integration=Integration.Type.CUSTOM, - manifest_type=Integration.ManifestType.USER_IMPORT, + return ( + self.get_queryset() + .filter( + integration=Integration.Type.CUSTOM, + manifest_type=Integration.ManifestType.SYNC_USERS, + ) + .exclude(manifest__schedule__isnull=False) ) @@ -75,7 +87,7 @@ class Type(models.IntegerChoices): class ManifestType(models.IntegerChoices): WEBHOOK = 0, _("Provision user accounts or trigger webhooks") - USER_IMPORT = 1, _("Import users") + SYNC_USERS = 1, _("Sync users") name = models.CharField(max_length=300, default="", blank=True) integration = models.IntegerField(choices=Type.choices) @@ -108,8 +120,52 @@ class ManifestType(models.IntegerChoices): bot_id = models.CharField(max_length=100, default="") @property - def has_user_context(self): - return self.manifest_type == Integration.ManifestType.WEBHOOK + def schedule_name(self): + return f"User sync for integration: {self.id}" + + def clean(self): + if not self.manifest: + # ignore field if form doesn't have it + return + + if self.manifest_type == Integration.ManifestType.WEBHOOK: + manifest_serializer = WebhookManifestSerializer(data=self.manifest) + else: + manifest_serializer = SyncUsersManifestSerializer(data=self.manifest) + if not manifest_serializer.is_valid(): + raise ValidationError({"manifest": json.dumps(manifest_serializer.errors)}) + + def save(self, *args, **kwargs): + # avoid circular import + from admin.integrations.tasks import sync_user_info + + super().save(*args, **kwargs) + # update the background job based on the manifest + schedule_cron = self.manifest.get("schedule") + + try: + schedule_obj = Schedule.objects.get(name=self.schedule_name) + except Schedule.DoesNotExist: + # Schedule does not exist yet, so create it if specified + if schedule_cron: + schedule( + sync_user_info, + self.id, + schedule_type=Schedule.CRON, + cron=schedule_cron, + name=self.schedule_name, + ) + return + + # delete if cron was removed + if schedule_cron is None: + schedule_obj.delete() + return + + # if schedule changed, then update + if schedule_obj.cron != schedule_cron: + schedule_obj.cron = schedule_cron + schedule_obj.save() def run_request(self, data): url = self._replace_vars(data["url"]) @@ -291,10 +347,13 @@ def _polling(self, item, response): # if exceeding the max amounts, then fail return False, response - def execute(self, new_hire, params): - self.params = params + def execute(self, new_hire=None, params=None): + self.params = params or {} self.params["responses"] = [] self.params["files"] = {} + self.new_hire = new_hire + self.has_user_context = new_hire is not None + if self.has_user_context: self.params |= new_hire.extra_fields self.new_hire = new_hire @@ -450,3 +509,8 @@ def clean_response(self, response): return response objects = IntegrationManager() + + +@receiver(post_delete, sender=Integration) +def delete_schedule(sender, instance, **kwargs): + Schedule.objects.filter(name=instance.schedule_name).delete() diff --git a/back/admin/integrations/serializers.py b/back/admin/integrations/serializers.py index 44ffc378f..6f7482fb5 100644 --- a/back/admin/integrations/serializers.py +++ b/back/admin/integrations/serializers.py @@ -1,6 +1,8 @@ from django.core.exceptions import ValidationError from rest_framework import serializers +from django_q.models import validate_cron + # Credit: https://stackoverflow.com/a/42432240 class ValidateMixin: @@ -57,7 +59,7 @@ class ManifestExistSerializer(ValidateMixin, serializers.Serializer): class ManifestExecuteSerializer(ValidateMixin, serializers.Serializer): url = serializers.CharField() data = serializers.JSONField(required=False, default=dict) - headers = serializers.JSONField(required=False, default=dict) + headers = serializers.DictField(child=serializers.CharField(), default=dict) store_data = serializers.DictField(child=serializers.CharField(), default=dict) method = serializers.ChoiceField( [ @@ -120,26 +122,29 @@ class ManifestOauthSerializer(ValidateMixin, serializers.Serializer): without_code = serializers.BooleanField(required=False) -class ManifestSerializer(ValidateMixin, serializers.Serializer): - type = serializers.ChoiceField( - [ - ("import_users", "imports users from endpoint"), - ], - required=False, - ) +class WebhookManifestSerializer(ValidateMixin, serializers.Serializer): form = ManifestFormSerializer(required=False, many=True) - data_from = serializers.CharField(required=False) - data_structure = serializers.JSONField(required=False) exists = ManifestExistSerializer(required=False) execute = ManifestExecuteSerializer(many=True) - next_page_token_from = serializers.CharField(required=False) - next_page = serializers.CharField(required=False) - next_page_from = serializers.CharField(required=False) - amount_pages_to_fetch = serializers.IntegerField(required=False) post_execute_notification = ManifestPostExecuteNotificationSerializer( many=True, required=False ) initial_data_form = ManifestInitialDataFormSerializer(many=True, required=False) extra_user_info = ManifestExtraUserInfoFormSerializer(many=True, required=False) - headers = serializers.JSONField(required=False) + headers = serializers.DictField(child=serializers.CharField(), default=dict) + oauth = ManifestOauthSerializer(required=False) + + +class SyncUsersManifestSerializer(ValidateMixin, serializers.Serializer): + data_from = serializers.CharField(required=False) + data_structure = serializers.DictField(child=serializers.CharField()) + execute = ManifestExecuteSerializer(many=True) + next_page_token_from = serializers.CharField(required=False) + next_page = serializers.CharField(required=False) + next_page_from = serializers.CharField(required=False) + schedule = serializers.CharField(required=False, validators=[validate_cron]) + action = serializers.ChoiceField([("create", "create"), ("update", "update")]) + amount_pages_to_fetch = serializers.IntegerField(required=False) + initial_data_form = ManifestInitialDataFormSerializer(many=True, required=False) + headers = serializers.DictField(child=serializers.CharField(), default=dict) oauth = ManifestOauthSerializer(required=False) diff --git a/back/admin/integrations/sync_userinfo.py b/back/admin/integrations/sync_userinfo.py new file mode 100644 index 000000000..775dad53d --- /dev/null +++ b/back/admin/integrations/sync_userinfo.py @@ -0,0 +1,84 @@ +from django.contrib.auth import get_user_model + +from admin.integrations.mixins import PaginatedResponse +from admin.people.serializers import UserImportSerializer +from organization.models import Organization + +import logging + +logger = logging.getLogger(__name__) + + +class SyncUsers(PaginatedResponse): + """ + Syncing can be done in two ways: + 1. Creating new users. + 2. Updating the users with a specific value. + These two options can be available through the same manifest and can be scheduled. + Paginated response is supported. + """ + + def __init__(self, integration): + super().__init__(integration) + self.users = self.get_data_from_paginated_response() + + def run(self): + action = self.integration.manifest.get("action", "create") + if action == "create": + new_users = self.get_import_user_candidates() + self.create_users(new_users) + + elif action == "update": + self.update_users() + + def update_users(self): + # Email param is currently hardcoded, no way to change + users_dict = {u["email"]: u for u in self.users} + emails = list(users_dict.keys()) + + user_objects = get_user_model().objects.filter(email__in=emails) + for user in user_objects: + user_info = users_dict.get(user.email) + # remove user email attr as we already have that on the instance + user_info.pop("email") + user.extra_fields.update(user_info) + + get_user_model().objects.bulk_update(user_objects, ["extra_fields"]) + + def create_users(self, new_users): + serializer = UserImportSerializer(data=new_users, many=True) + valid_ones = [] + + if serializer.is_valid(): + serializer.save(is_active=False) + else: + # if we have errors, then only get the valid ones + for idx, error in enumerate(serializer.errors): + if not len(error): + valid_ones.append(new_users[idx]) + else: + logger.info( + f"Couldn't save {new_users[idx]['email']} due to {error}" + ) + + # push them again through the function to save the users + if len(valid_ones): + self.create_users(valid_ones) + + def get_import_user_candidates(self): + # Remove users that are already in the system or have been ignored + existing_user_emails = list( + get_user_model().objects.all().values_list("email", flat=True) + ) + ignored_user_emails = Organization.objects.get().ignored_user_emails + excluded_emails = ( + existing_user_emails + ignored_user_emails + ["", None] + ) # also add blank emails to ignore + + user_candidates = [ + user_data + for user_data in self.users + if user_data.get("email", "") not in excluded_emails + ] + + return user_candidates diff --git a/back/admin/integrations/tasks.py b/back/admin/integrations/tasks.py index c6259b751..bcb9776ba 100644 --- a/back/admin/integrations/tasks.py +++ b/back/admin/integrations/tasks.py @@ -1,9 +1,17 @@ from django.contrib.auth import get_user_model from admin.integrations.models import Integration +from admin.integrations.sync_userinfo import SyncUsers def retry_integration(new_hire_id, integration_id, params): integration = Integration.objects.get(id=integration_id) new_hire = get_user_model().objects.get(id=new_hire_id) integration.execute(new_hire, params) + + +def sync_user_info(integration_id): + # Depending on the manifest, we wil either sync specific info with the current + # users or we will add new users. This is done in the background. + integration = Integration.objects.get(id=integration_id) + SyncUsers(integration).run() diff --git a/back/admin/integrations/tests.py b/back/admin/integrations/tests.py index dd0f5b487..67811f24c 100644 --- a/back/admin/integrations/tests.py +++ b/back/admin/integrations/tests.py @@ -6,7 +6,9 @@ from django.contrib.auth import get_user_model from django.urls import reverse from django.utils import timezone +from django_q.models import Schedule +from admin.integrations.sync_userinfo import SyncUsers from admin.integrations.utils import get_value_from_notation from admin.integrations.models import Integration from organization.models import Notification @@ -33,7 +35,7 @@ def test_create_integration(client, django_user_model): url, { "name": "test", - "manifest": '{"execute": []}', + "manifest": '{"form": [],"execute": []}', "manifest_type": Integration.ManifestType.WEBHOOK, }, ) @@ -42,6 +44,96 @@ def test_create_integration(client, django_user_model): assert Integration.objects.filter(integration=Integration.Type.CUSTOM).count() == 1 +@pytest.mark.django_db +def test_create_update_sync_integration(client, django_user_model): + create_url = reverse("integrations:create") + + client.force_login( + django_user_model.objects.create(role=get_user_model().Role.ADMIN) + ) + + client.post( + create_url, + { + "name": "test", + "manifest": '{"action": "create","execute": [],"data_structure": {"first_name": "first_name" }}', # noqa + "manifest_type": Integration.ManifestType.SYNC_USERS, + }, + ) + + integration = Integration.objects.first() + + update_url = reverse("integrations:update", args=[integration.id]) + + assert ( + Integration.objects.filter( + manifest_type=Integration.ManifestType.SYNC_USERS + ).count() + == 1 + ) + schedule = Schedule.objects.filter( + name=f"User sync for integration: {integration.id}" + ).first() + + # schedule is not there, since there was no schedule provided + assert schedule is None + + # add schedule + client.post( + update_url, + { + "name": "test", + "manifest": '{"action": "create","execute": [],"data_structure": {"first_name": "first_name" }, "schedule": "* * * * *"}', # noqa + "manifest_type": Integration.ManifestType.SYNC_USERS, + }, + ) + + schedule = Schedule.objects.filter( + name=f"User sync for integration: {integration.id}" + ).first() + assert schedule is not None + assert schedule.cron == "* * * * *" + + # remove schedule + client.post( + update_url, + { + "name": "test", + "manifest": '{"action": "create","execute": [],"data_structure": {"first_name": "first_name" }}', # noqa + "manifest_type": Integration.ManifestType.SYNC_USERS, + }, + ) + + schedule = Schedule.objects.filter( + name=f"User sync for integration: {integration.id}" + ).first() + assert schedule is None + + # update to change cron + client.post( + update_url, + { + "name": "test", + "manifest": '{"action": "create","execute": [],"data_structure": {"first_name": "first_name" }, "schedule": "* 1 * * *"}', # noqa + "manifest_type": Integration.ManifestType.SYNC_USERS, + }, + ) + + schedule = Schedule.objects.filter( + name=f"User sync for integration: {integration.id}" + ).first() + assert schedule is not None + assert schedule.cron == "* 1 * * *" + + # delete to test if schedule is gone + url = reverse("integrations:delete", args=[integration.id]) + client.post(url, follow=True) + + assert not Schedule.objects.filter( + name=f"User sync for integration: {integration.id}" + ).exists() + + @pytest.mark.django_db def test_update_integration(client, django_user_model, custom_integration_factory): client.force_login( @@ -285,6 +377,7 @@ def test_integration_refresh_token( Mock(return_value=(False, Mock(text="[{'error': 'not_found'}]"))), ): integration.new_hire = new_hire + integration.has_user_context = True integration.renew_key() assert ( @@ -821,3 +914,143 @@ def test_integration_reuse_data_from_previous_request( assert ( integration._replace_vars("test {{responses.0.details}}") == "test DOSOMETHING#" ) + + +@pytest.mark.django_db +@patch( + "admin.integrations.models.Integration.run_request", + Mock( + return_value=( + True, + Mock( + json=lambda: [ + {"email": "test1@chiefonboarding.com", "external_id": 123}, + {"email": "test2@chiefonboarding.com", "external_id": 344}, + {"email": "test3@chiefonboarding.com", "external_id": 334}, + {"email": "test4@chiefonboarding.com", "external_id": 335}, + {"email": "test5@chiefonboarding.com"}, + ] + ), + ) + ), +) +def test_integration_sync_data(new_hire_factory, custom_integration_factory): + new_hire1 = new_hire_factory(email="test1@chiefonboarding.com") + new_hire2 = new_hire_factory(email="test2@chiefonboarding.com") + new_hire3 = new_hire_factory(email="test5@chiefonboarding.com") + new_hire4 = new_hire_factory(email="test6@chiefonboarding.com") + + assert new_hire1.extra_fields == {} + assert new_hire2.extra_fields == {} + assert new_hire3.extra_fields == {} + assert new_hire4.extra_fields == {} + + integration = custom_integration_factory( + manifest_type=Integration.ManifestType.SYNC_USERS, + manifest={ + "execute": [ + { + "url": "http://localhost/", + } + ], + "data_from": "", + "action": "update", + "data_structure": {"email": "email", "EXT_ID": "external_id"}, + }, + ) + + SyncUsers(integration).run() + + new_hire1.refresh_from_db() + assert new_hire1.extra_fields == {"EXT_ID": 123} + + new_hire2.refresh_from_db() + assert new_hire2.extra_fields == {"EXT_ID": 344} + + # no `external_id` data, so blank + new_hire3.refresh_from_db() + assert new_hire3.extra_fields == {} + + # not in dataset from the API + new_hire4.refresh_from_db() + assert new_hire4.extra_fields == {} + + +@pytest.mark.django_db +@patch( + "admin.integrations.models.Integration.run_request", + Mock( + return_value=( + True, + Mock( + json=lambda: [ + { + "email": "test1@chiefonboarding.com", + "firstName": "test1", + "lastName": "1", + }, + { + "email": "test2@chiefonboarding.com", + "firstName": "test2", + "lastName": "2", + }, + { + "email": "test3@chiefonboarding.com", + "firstName": "test3", + "lastName": "3", + }, + { + "email": "test4@chiefonboarding.com", + "firstName": "test4", + "lastName": "4", + }, + {"email": "test5@chiefonboarding.com"}, + ] + ), + ) + ), +) +def test_integration_sync_data_create_users( + new_hire_factory, custom_integration_factory +): + new_hire1 = new_hire_factory(email="test1@chiefonboarding.com") + + assert new_hire1.extra_fields == {} + + integration = custom_integration_factory( + manifest_type=Integration.ManifestType.SYNC_USERS, + manifest={ + "execute": [ + { + "url": "http://localhost/", + } + ], + "data_from": "", + "action": "create", + "data_structure": { + "first_name": "firstName", + "last_name": "lastName", + "email": "email", + }, + }, + ) + + SyncUsers(integration).run() + + new_hire1.refresh_from_db() + # didn't do anything with newhire1 as it only creates users + assert new_hire1.extra_fields == {} + + assert get_user_model().objects.all().count() == 4 + # randomly checking users to make sure their data is correct + assert get_user_model().objects.filter(email="test2@chiefonboarding.com").exists() + assert ( + get_user_model().objects.get(email="test3@chiefonboarding.com").first_name + == "test3" + ) + assert ( + get_user_model().objects.get(email="test4@chiefonboarding.com").last_name == "4" + ) + assert ( + not get_user_model().objects.filter(email="test5@chiefonboarding.com").exists() + ) diff --git a/back/admin/people/tests.py b/back/admin/people/tests.py index bb03c9544..e7c19d3e8 100644 --- a/back/admin/people/tests.py +++ b/back/admin/people/tests.py @@ -2533,7 +2533,6 @@ def test_fetching_employees( integration = custom_user_import_integration_factory( manifest={ - "type": "import_users", "execute": [ {"url": "http://localhost:8000/test_api/users", "method": "GET"} ], @@ -2639,7 +2638,6 @@ def test_fetching_employees_paginated_response( integration = custom_user_import_integration_factory( manifest={ - "type": "import_users", "execute": [{"url": "http://localhost/test_api/users", "method": "GET"}], "data_from": "directory.employees", "data_structure": { @@ -2776,9 +2774,9 @@ def test_fetching_employees_paginated_response_max_pages( integration = custom_user_import_integration_factory( manifest={ - "type": "import_users", "execute": [{"url": "http://localhost/test_api/users", "method": "GET"}], "data_from": "employees", + "action": "create", "data_structure": { "email": "workEmail", "last_name": "lastName", @@ -2815,7 +2813,6 @@ def test_fetching_employees_incorrect_notation( integration = custom_user_import_integration_factory( manifest={ - "type": "import_users", "execute": [ {"url": "http://localhost:8000/test_api/users", "method": "GET"} ], diff --git a/back/admin/people/views.py b/back/admin/people/views.py index 30c37d83a..398c9e3ac 100644 --- a/back/admin/people/views.py +++ b/back/admin/people/views.py @@ -13,9 +13,12 @@ from rest_framework import generics from rest_framework.authentication import SessionAuthentication -from admin.integrations.exceptions import GettingUsersError, KeyIsNotInDataError +from admin.integrations.exceptions import ( + FailedPaginatedResponseError, + KeyIsNotInDataError, +) from admin.integrations.models import Integration -from admin.integrations.import_users import ImportUser +from admin.integrations.sync_userinfo import SyncUsers from admin.people.serializers import UserImportSerializer from admin.resources.models import Resource from api.permissions import AdminPermission @@ -305,10 +308,8 @@ def get(self, request, pk, *args, **kwargs): try: # we are passing in the user who is requesting it, but we likely don't need # them. - users = ImportUser(integration).get_import_user_candidates( - self.request.user - ) - except (KeyIsNotInDataError, GettingUsersError) as e: + users = SyncUsers(integration).get_import_user_candidates() + except (KeyIsNotInDataError, FailedPaginatedResponseError) as e: return render(request, "_import_user_table.html", {"error": e}) return render( diff --git a/back/admin/sequences/factories.py b/back/admin/sequences/factories.py index 02cea3971..4e66de304 100644 --- a/back/admin/sequences/factories.py +++ b/back/admin/sequences/factories.py @@ -24,6 +24,7 @@ class PendingAdminTaskFactory(factory.django.DjangoModelFactory): assigned_to = factory.SubFactory(AdminFactory) slack_user = factory.SubFactory(EmployeeFactory) + person_type = PendingAdminTask.PersonType.CUSTOM class Meta: model = PendingAdminTask @@ -162,6 +163,13 @@ def condition_to_do(obj, create, extracted, **kwargs): obj.condition_to_do.add(ToDoFactory()) +class ConditionAdminTaskFactory(factory.django.DjangoModelFactory): + condition_type = Condition.Type.ADMIN_TASK + + class Meta: + model = Condition + + class ConditionTimedFactory(factory.django.DjangoModelFactory): condition_type = Condition.Type.AFTER time = "10:00" diff --git a/back/admin/sequences/forms.py b/back/admin/sequences/forms.py index db3b48c21..d248b7411 100644 --- a/back/admin/sequences/forms.py +++ b/back/admin/sequences/forms.py @@ -24,6 +24,11 @@ class ConditionCreateForm(forms.ModelForm): to_field_name="id", required=False, ) + condition_admin_tasks = forms.ModelMultipleChoiceField( + queryset=PendingAdminTask.objects.all(), + to_field_name="id", + required=False, + ) def _get_save_button(self): return ( @@ -37,29 +42,31 @@ def _get_save_button(self): ) def __init__(self, *args, **kwargs): + sequence = kwargs.pop("sequence") super().__init__(*args, **kwargs) self.helper = FormHelper() - is_time_condition = ( - self.instance.condition_type - in [Condition.Type.AFTER, Condition.Type.BEFORE] - or self.instance is None - ) self.helper.layout = Layout( Field("condition_type"), Div( MultiSelectField("condition_to_do"), - css_class="d-none" if is_time_condition else "", + css_class="" if self.instance.based_on_to_do else "d-none", ), Div( Field("days"), Field("time"), - css_class="" if is_time_condition else "d-none", + css_class="" if self.instance.based_on_time else "d-none", + ), + Div( + Field("condition_admin_tasks"), + css_class="" if self.instance.based_on_admin_task else "d-none", ), HTML(self._get_save_button()), ) self.fields["time"].required = False self.fields["days"].required = False self.fields["condition_to_do"].required = False + pending_tasks = PendingAdminTask.objects.filter(condition__sequence=sequence) + self.fields["condition_admin_tasks"].queryset = pending_tasks # Remove last option, which will only be one of self.fields["condition_type"].choices = tuple( x for x in Condition.Type.choices if x[0] != 3 @@ -67,7 +74,13 @@ def __init__(self, *args, **kwargs): class Meta: model = Condition - fields = ["condition_type", "days", "time", "condition_to_do"] + fields = [ + "condition_type", + "days", + "time", + "condition_to_do", + "condition_admin_tasks", + ] widgets = { "time": forms.TimeInput(attrs={"type": "time", "step": 300}), } @@ -119,10 +132,15 @@ def clean(self): time = cleaned_data.get("time", None) days = cleaned_data.get("days", None) condition_to_do = cleaned_data.get("condition_to_do", None) + condition_admin_tasks = cleaned_data.get("condition_admin_tasks", None) if condition_type == Condition.Type.TODO and ( condition_to_do is None or len(condition_to_do) == 0 ): raise ValidationError(_("You must add at least one to do item")) + if condition_type == Condition.Type.ADMIN_TASK and ( + condition_admin_tasks is None or len(condition_admin_tasks) == 0 + ): + raise ValidationError(_("You must add at least one admin task")) if condition_type in [Condition.Type.AFTER, Condition.Type.BEFORE] and ( time is None or days is None ): diff --git a/back/admin/sequences/migrations/0041_condition_condition_admin_tasks_and_more.py b/back/admin/sequences/migrations/0041_condition_condition_admin_tasks_and_more.py new file mode 100644 index 000000000..c1d23b207 --- /dev/null +++ b/back/admin/sequences/migrations/0041_condition_condition_admin_tasks_and_more.py @@ -0,0 +1,49 @@ +# Generated by Django 4.2.5 on 2023-09-22 00:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("sequences", "0040_auto_20220811_2150"), + ] + + operations = [ + migrations.AddField( + model_name="condition", + name="condition_admin_tasks", + field=models.ManyToManyField( + related_name="condition_triggers", + to="sequences.pendingadmintask", + verbose_name=( + "Trigger after these admin todo items have been completed:" + ), + ), + ), + migrations.AddField( + model_name="pendingadmintask", + name="template", + field=models.BooleanField( + default=False, + help_text=( + "Should always be False, for now it's just here to comply with " + "other functions (like duplicate)" + ), + ), + ), + migrations.AlterField( + model_name="condition", + name="condition_type", + field=models.IntegerField( + choices=[ + (0, "After new hire has started"), + (1, "Based on one or more to do item(s)"), + (2, "Before the new hire has started"), + (3, "Without trigger"), + (4, "Based on one or more admin tasks"), + ], + default=0, + verbose_name="Block type", + ), + ), + ] diff --git a/back/admin/sequences/models.py b/back/admin/sequences/models.py index 86151bfa6..3d5e3424f 100644 --- a/back/admin/sequences/models.py +++ b/back/admin/sequences/models.py @@ -43,8 +43,10 @@ def duplicate(self): self.name = _("%(name)s (duplicate)") % {"name": self.name} self.auto_add = False self.save() + + admin_tasks = {} for condition in old_sequence.conditions.all(): - new_condition = condition.duplicate() + new_condition, admin_tasks = condition.duplicate(admin_tasks=admin_tasks) self.conditions.add(new_condition) return self @@ -90,6 +92,35 @@ def assign_to_user(self, user): # We found our match. Amount matches AND the todos match user_condition = condition break + + elif sequence_condition.condition_type == Condition.Type.ADMIN_TASK: + # For admin to do items, filter all condition items to find if one + # matches. Both the amount and the admin to do itself need to match + # exactly + conditions = user.conditions.filter( + condition_type=Condition.Type.ADMIN_TASK + ) + original_condition_admin_tasks_ids = ( + sequence_condition.condition_admin_tasks.all().values_list( + "id", flat=True + ) + ) + + for condition in conditions: + # Quickly check if the amount of items match - if not match, drop + if condition.condition_admin_tasks.all().count() != len( # noqa + original_condition_admin_tasks_ids + ): + continue + + found_admin_tasks = condition.condition_admin_tasks.filter( + id__in=original_condition_admin_tasks_ids + ).count() + + if found_admin_tasks == len(original_condition_admin_tasks_ids): + # We found our match. Amount matches AND the admin_tasks match + user_condition = condition + break else: # Condition (always just one) that will be assigned directly (type == 3) # Just run the condition with the new hire @@ -112,9 +143,12 @@ def assign_to_user(self, user): sequence_condition.save() # Add condition to_dos - for condition_to_do in old_condition.condition_to_do.all(): - sequence_condition.condition_to_do.add(condition_to_do) + sequence_condition.condition_to_do.set( + old_condition.condition_to_do.all() + ) + for condition_admin_task in old_condition.condition_admin_tasks.all(): + sequence_condition.condition_admin_tasks.add(condition_admin_task) # Add all the things that get triggered sequence_condition.include_other_condition(old_condition) @@ -171,7 +205,7 @@ def remove_from_user(self, new_hire): for condition in new_hire.conditions.all(): for field in condition._meta.many_to_many: # We only want to remove assigned items, not triggers - if field.name == "condition_to_do": + if field.name in ("condition_to_do", "condition_admin_tasks"): continue getattr(condition, field.name).remove(*items[field.name]) @@ -428,6 +462,16 @@ class Notification(models.IntegerChoices): choices=AdminTask.Priority.choices, default=AdminTask.Priority.MEDIUM, ) + template = models.BooleanField( + default=False, + help_text=( + "Should always be False, for now it's just here to comply with other " + "functions (like duplicate)" + ), + ) + + def __str__(self): + return self.name def get_user(self, new_hire): if self.person_type == PendingAdminTask.PersonType.NEWHIRE: @@ -442,24 +486,26 @@ def get_user(self, new_hire): def execute(self, user): from admin.admin_tasks.models import AdminTask, AdminTaskComment - admin_task, created = AdminTask.objects.get_or_create( + if AdminTask.objects.filter(new_hire=user, based_on=self).exists(): + # if a task already exists, then skip + return + + admin_task = AdminTask.objects.create( new_hire=user, assigned_to=self.get_user(user), name=self.name, - defaults={ - "option": self.option, - "slack_user": self.slack_user, - "email": self.email, - "date": self.date, - "priority": self.priority, - }, + option=self.option, + slack_user=self.slack_user, + email=self.email, + date=self.date, + priority=self.priority, + based_on=self, + ) + AdminTaskComment.objects.create( + content=self.comment, + comment_by=admin_task.assigned_to, + admin_task=admin_task, ) - if created and self.comment != "": - AdminTaskComment.objects.create( - content=self.comment, - comment_by=admin_task.assigned_to, - admin_task=admin_task, - ) admin_task.send_notification_new_assigned() admin_task.send_notification_third_party() @@ -552,6 +598,7 @@ class Type(models.IntegerChoices): TODO = 1, _("Based on one or more to do item(s)") BEFORE = 2, _("Before the new hire has started") WITHOUT = 3, _("Without trigger") + ADMIN_TASK = 4, _("Based on one or more admin tasks") sequence = models.ForeignKey( Sequence, on_delete=models.CASCADE, null=True, related_name="conditions" @@ -568,6 +615,11 @@ class Type(models.IntegerChoices): verbose_name=_("Trigger after these to do items have been completed:"), related_name="condition_to_do", ) + condition_admin_tasks = models.ManyToManyField( + PendingAdminTask, + verbose_name=_("Trigger after these admin todo items have been completed:"), + related_name="condition_triggers", + ) to_do = models.ManyToManyField(ToDo) badges = models.ManyToManyField(Badge) resources = models.ManyToManyField(Resource) @@ -594,6 +646,18 @@ def is_empty(self): or self.integration_configs.exists() ) + @property + def based_on_to_do(self): + return self.condition_type == Condition.Type.TODO + + @property + def based_on_admin_task(self): + return self.condition_type == Condition.Type.ADMIN_TASK + + @property + def based_on_time(self): + return self.condition_type in [Condition.Type.AFTER, Condition.Type.BEFORE] + def remove_item(self, model_item): # If any of the external messages, then get the root one if type(model_item)._meta.model_name in [ @@ -605,7 +669,7 @@ def remove_item(self, model_item): # model_item is a template item. I.e. a ToDo object. for field in self._meta.many_to_many: # We only want to remove assigned items, not triggers - if field.name == "condition_to_do": + if field.name in ("condition_to_do", "condition_admin_tasks"): continue if ( field.related_model._meta.model_name @@ -617,7 +681,7 @@ def add_item(self, model_item): # model_item is a template item. I.e. a ToDo object. for field in self._meta.many_to_many: # We only want to add assigned items, not triggers - if field.name == "condition_to_do": + if field.name in ("condition_to_do", "condition_admin_tasks"): continue if ( field.related_model._meta.model_name @@ -629,26 +693,29 @@ def include_other_condition(self, condition): # this will put another condition into this one for field in self._meta.many_to_many: # We only want to add assigned items, not triggers - if field.name == "condition_to_do": + if field.name in ("condition_to_do", "condition_admin_tasks"): continue condition_field = getattr(condition, field.name) for item in condition_field.all(): getattr(self, field.name).add(item) - def duplicate(self): + def duplicate(self, admin_tasks): old_condition = Condition.objects.get(id=self.id) self.pk = None self.save() + # This function is not being used except for duplicating sequences # It can't be triggered standalone (for now) for field in old_condition._meta.many_to_many: if field.name not in [ "admin_tasks", + "condition_admin_tasks", "external_messages", "integration_configs", ]: - # Duplicate old ones + # Duplicate template items that have been customized. Those should be + # unique again. (only items that have a `template` flag on the model) items = [] old_custom_templates = getattr(old_condition, field.name).filter( template=False @@ -657,8 +724,8 @@ def duplicate(self): dup = old.duplicate(change_name=False) items.append(dup) - # Only using set() for template items. The other ones need to be - # duplicated as they are unique to the condition + # Reassign items that are still unchanged templates, they should connect + # to the same item old_templates = getattr(old_condition, field.name).filter(template=True) getattr(self, field.name).add(*old_templates, *items) @@ -666,13 +733,26 @@ def duplicate(self): # For items that do not have templates, just duplicate them items = [] old_custom_templates = getattr(old_condition, field.name).all() - for old in old_custom_templates: - dup = old.duplicate(change_name=False) - items.append(dup) + # exception for condition_admin_tasks, those should be linked to + # previously created items, so link old id to new object, for + # future lookup + if field.name == "condition_admin_tasks": + for item in old_custom_templates: + items.append(admin_tasks[item.id]) + + else: + for old in old_custom_templates: + old_id = old.id + dup = old.duplicate(change_name=False) + items.append(dup) + if field.name == "admin_tasks": + # lookup old id to get newly created object + admin_tasks[old_id] = dup + getattr(self, field.name).add(*items) # returning the new item - return self + return self, admin_tasks def process_condition(self, user, skip_notification=False): # Loop over all m2m fields and add the ones that can be easily added diff --git a/back/admin/sequences/templates/_sequence_condition.html b/back/admin/sequences/templates/_sequence_condition.html index 8a6a2c5f3..ad7547554 100644 --- a/back/admin/sequences/templates/_sequence_condition.html +++ b/back/admin/sequences/templates/_sequence_condition.html @@ -23,6 +23,13 @@

{{ todo.name }} {% endfor %} + {% elif condition.condition_type == ConditionType.ADMIN_TASK %} + {% translate "When these admin tasks are completed:" %} +
+ {% for admin_task in condition.condition_admin_tasks.all %} + {{ admin_task.name }} + {% endfor %} +
{% elif condition.condition_type == ConditionType.BEFORE %} {% blocktranslate with days=condition.days time=condition.time %}{{ days }} days before starting at {{ time }}{% endblocktranslate %} {% else %} diff --git a/back/admin/sequences/templates/_sequence_timeline.html b/back/admin/sequences/templates/_sequence_timeline.html index d9b233051..20daf477b 100644 --- a/back/admin/sequences/templates/_sequence_timeline.html +++ b/back/admin/sequences/templates/_sequence_timeline.html @@ -40,7 +40,19 @@ {% for condition in conditions %} {% if condition.condition_type == ConditionType.TODO %} -
  • +
  • +
    +
    +
    + {% include '_sequence_condition.html' %} +
    +
  • + {% endif %} + {% endfor %} + + {% for condition in conditions %} + {% if condition.condition_type == ConditionType.ADMIN_TASK %} +
  • diff --git a/back/admin/sequences/templates/sequence.html b/back/admin/sequences/templates/sequence.html index 217045850..c64bd3471 100644 --- a/back/admin/sequences/templates/sequence.html +++ b/back/admin/sequences/templates/sequence.html @@ -183,16 +183,6 @@ htmx.ajax('GET', "{% url 'sequences:timeline' object.id %}", '#timeline') }) -$("select#id_condition_type").on('change', function() { - // Change things to fill in when condition_type changes - if ($(this).val() == 1){ - $("#add-condition-form #div_id_condition_to_do").parent().removeClass("d-none") - $("#div_id_days").parent().addClass("d-none") - } else { - $("#add-condition-form #div_id_condition_to_do").parent().addClass("d-none") - $("#div_id_days").parent().removeClass("d-none") - } -}) function editCondition(id) { htmx.ajax('GET', `/admin/sequences/{{ object.id }}/condition/${id}/`, `#condition_form`) $('#modal-block').modal('show') @@ -201,6 +191,7 @@ document.getElementById("condition_form").addEventListener('htmx:load', function(evt) { $("#id_condition_to_do").selectize() $("#id_condition_type").selectize() + $("#id_condition_admin_tasks").selectize() });