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 %}
-
+{% 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 @@
{% translate "Item" %}
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 @@ {% translate "Item" %}
document.getElementById("condition_form").addEventListener('htmx:load', function(evt) {
$("#id_condition_to_do").selectize()
$("#id_condition_type").selectize()
+ $("#id_condition_admin_tasks").selectize()
});