diff --git a/.devcontainer.json b/.devcontainer.json index 51c81e468..3c48bd7f3 100644 --- a/.devcontainer.json +++ b/.devcontainer.json @@ -30,7 +30,8 @@ "ghcr.io/devcontainers/features/python:1": { "installTools": false, "version": "3.8" - } + }, + "ghcr.io/kreemer/features/chrometesting:1": {} }, "name": "portal", "postCreateCommand": "pipenv install --dev", diff --git a/.vscode/settings.json b/.vscode/settings.json index c6a4edd52..9e1e8e984 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -67,5 +67,6 @@ "." ], "python.testing.pytestEnabled": true, - "python.testing.unittestEnabled": false + "python.testing.unittestEnabled": false, + "python.analysis.extraPaths": ["./cfl_common"] } \ No newline at end of file diff --git a/Pipfile b/Pipfile index 5d3ecb750..b3ed14a7f 100644 --- a/Pipfile +++ b/Pipfile @@ -23,6 +23,7 @@ pyvirtualdisplay = "*" pytest-mock = "*" PyPDF2 = "==2.10.6" black = "*" +isort = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 3f69889da..af4e0e241 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "b32e10e75dcbe40b70f7f4bbb3d24994c11d9064f36389ecc3a09912deeeb7a2" + "sha256": "c122c04c4bd389f6586859cff58f2a7048fc67ca35767bbd09df6907396c79a7" }, "pipfile-spec": 6, "requires": { @@ -18,18 +18,18 @@ "default": { "aimmo": { "hashes": [ - "sha256:104c52376867cd86e319b5b706012a8235c55bdb262ab17feebc39961c024cbd", - "sha256:749368fc0ff7358208459ec0fa129c2d44e972ac1b99452fa8b93ea8a233f1c5" + "sha256:58b90da42da179fbbeea141f6dbaff1cf5a81bfa06ec8a0edc1021da91bafda4", + "sha256:6e06d26335d76667c366e4bf7b8dc2edb3923af30fa59aaa70365cafe20efab2" ], - "version": "==2.11.1" + "version": "==2.11.2" }, "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "attrs": { "hashes": [ @@ -177,11 +177,11 @@ }, "django": { "hashes": [ - "sha256:5dd5b787c3ba39637610fe700f54bf158e33560ea0dba600c19921e7ff926ec5", - "sha256:aaee9fb0fb4ebd4311520887ad2e33313d368846607f82a9a0ed461cd4c35b18" + "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777", + "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38" ], "markers": "python_version >= '3.6'", - "version": "==3.2.24" + "version": "==3.2.25" }, "django-classy-tags": { "hashes": [ @@ -213,11 +213,11 @@ }, "django-import-export": { "hashes": [ - "sha256:39a4216c26a2dba6429b64c68b3fe282a6279bb71afb4015c13df0696bdbb4cd", - "sha256:dffedd53bed33cfcceb3b2f13d4fd93a21826f9a2ae37b9926a1e1f4be24bcb9" + "sha256:2eac09e8cec8670f36e24314760448011ad23c51e8fb930d55f50d0c3c926da0", + "sha256:4deabc557801d368093608c86fd0f4831bc9540e2ea41ca2f023e2efb3eb6f48" ], "markers": "python_version >= '3.8'", - "version": "==3.3.7" + "version": "==3.3.8" }, "django-js-reverse": { "hashes": [ @@ -321,11 +321,11 @@ }, "google-auth": { "hashes": [ - "sha256:80b8b4969aa9ed5938c7828308f20f035bc79f9d8fb8120bf9dc8db20b41ba30", - "sha256:9fd67bbcd40f16d9d42f950228e9cf02a2ded4ae49198b27432d0cded5a74c38" + "sha256:672dff332d073227550ffc7457868ac4218d6c500b155fe6cc17d2b13602c360", + "sha256:d452ad095688cd52bae0ad6fafe027f6a6d6f560e810fec20914e17a09526415" ], "markers": "python_version >= '3.7'", - "version": "==2.28.2" + "version": "==2.29.0" }, "greenlet": { "hashes": [ @@ -553,93 +553,94 @@ }, "pillow": { "hashes": [ - "sha256:0304004f8067386b477d20a518b50f3fa658a28d44e4116970abfcd94fac34a8", - "sha256:0689b5a8c5288bc0504d9fcee48f61a6a586b9b98514d7d29b840143d6734f39", - "sha256:0eae2073305f451d8ecacb5474997c08569fb4eb4ac231ffa4ad7d342fdc25ac", - "sha256:0fb3e7fc88a14eacd303e90481ad983fd5b69c761e9e6ef94c983f91025da869", - "sha256:11fa2e5984b949b0dd6d7a94d967743d87c577ff0b83392f17cb3990d0d2fd6e", - "sha256:127cee571038f252a552760076407f9cff79761c3d436a12af6000cd182a9d04", - "sha256:154e939c5f0053a383de4fd3d3da48d9427a7e985f58af8e94d0b3c9fcfcf4f9", - "sha256:15587643b9e5eb26c48e49a7b33659790d28f190fc514a322d55da2fb5c2950e", - "sha256:170aeb00224ab3dc54230c797f8404507240dd868cf52066f66a41b33169bdbe", - "sha256:1b5e1b74d1bd1b78bc3477528919414874748dd363e6272efd5abf7654e68bef", - "sha256:1da3b2703afd040cf65ec97efea81cfba59cdbed9c11d8efc5ab09df9509fc56", - "sha256:1e23412b5c41e58cec602f1135c57dfcf15482013ce6e5f093a86db69646a5aa", - "sha256:2247178effb34a77c11c0e8ac355c7a741ceca0a732b27bf11e747bbc950722f", - "sha256:257d8788df5ca62c980314053197f4d46eefedf4e6175bc9412f14412ec4ea2f", - "sha256:3031709084b6e7852d00479fd1d310b07d0ba82765f973b543c8af5061cf990e", - "sha256:322209c642aabdd6207517e9739c704dc9f9db943015535783239022002f054a", - "sha256:322bdf3c9b556e9ffb18f93462e5f749d3444ce081290352c6070d014c93feb2", - "sha256:33870dc4653c5017bf4c8873e5488d8f8d5f8935e2f1fb9a2208c47cdd66efd2", - "sha256:35bb52c37f256f662abdfa49d2dfa6ce5d93281d323a9af377a120e89a9eafb5", - "sha256:3c31822339516fb3c82d03f30e22b1d038da87ef27b6a78c9549888f8ceda39a", - "sha256:3eedd52442c0a5ff4f887fab0c1c0bb164d8635b32c894bc1faf4c618dd89df2", - "sha256:3ff074fc97dd4e80543a3e91f69d58889baf2002b6be64347ea8cf5533188213", - "sha256:47c0995fc4e7f79b5cfcab1fc437ff2890b770440f7696a3ba065ee0fd496563", - "sha256:49d9ba1ed0ef3e061088cd1e7538a0759aab559e2e0a80a36f9fd9d8c0c21591", - "sha256:51f1a1bffc50e2e9492e87d8e09a17c5eea8409cda8d3f277eb6edc82813c17c", - "sha256:52a50aa3fb3acb9cf7213573ef55d31d6eca37f5709c69e6858fe3bc04a5c2a2", - "sha256:54f1852cd531aa981bc0965b7d609f5f6cc8ce8c41b1139f6ed6b3c54ab82bfb", - "sha256:609448742444d9290fd687940ac0b57fb35e6fd92bdb65386e08e99af60bf757", - "sha256:69ffdd6120a4737710a9eee73e1d2e37db89b620f702754b8f6e62594471dee0", - "sha256:6fad5ff2f13d69b7e74ce5b4ecd12cc0ec530fcee76356cac6742785ff71c452", - "sha256:7049e301399273a0136ff39b84c3678e314f2158f50f517bc50285fb5ec847ad", - "sha256:70c61d4c475835a19b3a5aa42492409878bbca7438554a1f89d20d58a7c75c01", - "sha256:716d30ed977be8b37d3ef185fecb9e5a1d62d110dfbdcd1e2a122ab46fddb03f", - "sha256:753cd8f2086b2b80180d9b3010dd4ed147efc167c90d3bf593fe2af21265e5a5", - "sha256:773efe0603db30c281521a7c0214cad7836c03b8ccff897beae9b47c0b657d61", - "sha256:7823bdd049099efa16e4246bdf15e5a13dbb18a51b68fa06d6c1d4d8b99a796e", - "sha256:7c8f97e8e7a9009bcacbe3766a36175056c12f9a44e6e6f2d5caad06dcfbf03b", - "sha256:823ef7a27cf86df6597fa0671066c1b596f69eba53efa3d1e1cb8b30f3533068", - "sha256:8373c6c251f7ef8bda6675dd6d2b3a0fcc31edf1201266b5cf608b62a37407f9", - "sha256:83b2021f2ade7d1ed556bc50a399127d7fb245e725aa0113ebd05cfe88aaf588", - "sha256:870ea1ada0899fd0b79643990809323b389d4d1d46c192f97342eeb6ee0b8483", - "sha256:8d12251f02d69d8310b046e82572ed486685c38f02176bd08baf216746eb947f", - "sha256:9c23f307202661071d94b5e384e1e1dc7dfb972a28a2310e4ee16103e66ddb67", - "sha256:9d189550615b4948f45252d7f005e53c2040cea1af5b60d6f79491a6e147eef7", - "sha256:a086c2af425c5f62a65e12fbf385f7c9fcb8f107d0849dba5839461a129cf311", - "sha256:a2b56ba36e05f973d450582fb015594aaa78834fefe8dfb8fcd79b93e64ba4c6", - "sha256:aebb6044806f2e16ecc07b2a2637ee1ef67a11840a66752751714a0d924adf72", - "sha256:b1b3020d90c2d8e1dae29cf3ce54f8094f7938460fb5ce8bc5c01450b01fbaf6", - "sha256:b4b6b1e20608493548b1f32bce8cca185bf0480983890403d3b8753e44077129", - "sha256:b6f491cdf80ae540738859d9766783e3b3c8e5bd37f5dfa0b76abdecc5081f13", - "sha256:b792a349405fbc0163190fde0dc7b3fef3c9268292586cf5645598b48e63dc67", - "sha256:b7c2286c23cd350b80d2fc9d424fc797575fb16f854b831d16fd47ceec078f2c", - "sha256:babf5acfede515f176833ed6028754cbcd0d206f7f614ea3447d67c33be12516", - "sha256:c365fd1703040de1ec284b176d6af5abe21b427cb3a5ff68e0759e1e313a5e7e", - "sha256:c4225f5220f46b2fde568c74fca27ae9771536c2e29d7c04f4fb62c83275ac4e", - "sha256:c570f24be1e468e3f0ce7ef56a89a60f0e05b30a3669a459e419c6eac2c35364", - "sha256:c6dafac9e0f2b3c78df97e79af707cdc5ef8e88208d686a4847bab8266870023", - "sha256:c8de2789052ed501dd829e9cae8d3dcce7acb4777ea4a479c14521c942d395b1", - "sha256:cb28c753fd5eb3dd859b4ee95de66cc62af91bcff5db5f2571d32a520baf1f04", - "sha256:cb4c38abeef13c61d6916f264d4845fab99d7b711be96c326b84df9e3e0ff62d", - "sha256:d1b35bcd6c5543b9cb547dee3150c93008f8dd0f1fef78fc0cd2b141c5baf58a", - "sha256:d8e6aeb9201e655354b3ad049cb77d19813ad4ece0df1249d3c793de3774f8c7", - "sha256:d8ecd059fdaf60c1963c58ceb8997b32e9dc1b911f5da5307aab614f1ce5c2fb", - "sha256:da2b52b37dad6d9ec64e653637a096905b258d2fc2b984c41ae7d08b938a67e4", - "sha256:e87f0b2c78157e12d7686b27d63c070fd65d994e8ddae6f328e0dcf4a0cd007e", - "sha256:edca80cbfb2b68d7b56930b84a0e45ae1694aeba0541f798e908a49d66b837f1", - "sha256:f379abd2f1e3dddb2b61bc67977a6b5a0a3f7485538bcc6f39ec76163891ee48", - "sha256:fe4c15f6c9285dc54ce6553a3ce908ed37c8f3825b5a51a15c91442bb955b868" + "sha256:048ad577748b9fa4a99a0548c64f2cb8d672d5bf2e643a739ac8faff1164238c", + "sha256:048eeade4c33fdf7e08da40ef402e748df113fd0b4584e32c4af74fe78baaeb2", + "sha256:0ba26351b137ca4e0db0342d5d00d2e355eb29372c05afd544ebf47c0956ffeb", + "sha256:0ea2a783a2bdf2a561808fe4a7a12e9aa3799b701ba305de596bc48b8bdfce9d", + "sha256:1530e8f3a4b965eb6a7785cf17a426c779333eb62c9a7d1bbcf3ffd5bf77a4aa", + "sha256:16563993329b79513f59142a6b02055e10514c1a8e86dca8b48a893e33cf91e3", + "sha256:19aeb96d43902f0a783946a0a87dbdad5c84c936025b8419da0a0cd7724356b1", + "sha256:1a1d1915db1a4fdb2754b9de292642a39a7fb28f1736699527bb649484fb966a", + "sha256:1b87bd9d81d179bd8ab871603bd80d8645729939f90b71e62914e816a76fc6bd", + "sha256:1dfc94946bc60ea375cc39cff0b8da6c7e5f8fcdc1d946beb8da5c216156ddd8", + "sha256:2034f6759a722da3a3dbd91a81148cf884e91d1b747992ca288ab88c1de15999", + "sha256:261ddb7ca91fcf71757979534fb4c128448b5b4c55cb6152d280312062f69599", + "sha256:2ed854e716a89b1afcedea551cd85f2eb2a807613752ab997b9974aaa0d56936", + "sha256:3102045a10945173d38336f6e71a8dc71bcaeed55c3123ad4af82c52807b9375", + "sha256:339894035d0ede518b16073bdc2feef4c991ee991a29774b33e515f1d308e08d", + "sha256:412444afb8c4c7a6cc11a47dade32982439925537e483be7c0ae0cf96c4f6a0b", + "sha256:4203efca580f0dd6f882ca211f923168548f7ba334c189e9eab1178ab840bf60", + "sha256:45ebc7b45406febf07fef35d856f0293a92e7417ae7933207e90bf9090b70572", + "sha256:4b5ec25d8b17217d635f8935dbc1b9aa5907962fae29dff220f2659487891cd3", + "sha256:4c8e73e99da7db1b4cad7f8d682cf6abad7844da39834c288fbfa394a47bbced", + "sha256:4e6f7d1c414191c1199f8996d3f2282b9ebea0945693fb67392c75a3a320941f", + "sha256:4eaa22f0d22b1a7e93ff0a596d57fdede2e550aecffb5a1ef1106aaece48e96b", + "sha256:50b8eae8f7334ec826d6eeffaeeb00e36b5e24aa0b9df322c247539714c6df19", + "sha256:50fd3f6b26e3441ae07b7c979309638b72abc1a25da31a81a7fbd9495713ef4f", + "sha256:51243f1ed5161b9945011a7360e997729776f6e5d7005ba0c6879267d4c5139d", + "sha256:5d512aafa1d32efa014fa041d38868fda85028e3f930a96f85d49c7d8ddc0383", + "sha256:5f77cf66e96ae734717d341c145c5949c63180842a545c47a0ce7ae52ca83795", + "sha256:6b02471b72526ab8a18c39cb7967b72d194ec53c1fd0a70b050565a0f366d355", + "sha256:6fb1b30043271ec92dc65f6d9f0b7a830c210b8a96423074b15c7bc999975f57", + "sha256:7161ec49ef0800947dc5570f86568a7bb36fa97dd09e9827dc02b718c5643f09", + "sha256:72d622d262e463dfb7595202d229f5f3ab4b852289a1cd09650362db23b9eb0b", + "sha256:74d28c17412d9caa1066f7a31df8403ec23d5268ba46cd0ad2c50fb82ae40462", + "sha256:78618cdbccaa74d3f88d0ad6cb8ac3007f1a6fa5c6f19af64b55ca170bfa1edf", + "sha256:793b4e24db2e8742ca6423d3fde8396db336698c55cd34b660663ee9e45ed37f", + "sha256:798232c92e7665fe82ac085f9d8e8ca98826f8e27859d9a96b41d519ecd2e49a", + "sha256:81d09caa7b27ef4e61cb7d8fbf1714f5aec1c6b6c5270ee53504981e6e9121ad", + "sha256:8ab74c06ffdab957d7670c2a5a6e1a70181cd10b727cd788c4dd9005b6a8acd9", + "sha256:8eb0908e954d093b02a543dc963984d6e99ad2b5e36503d8a0aaf040505f747d", + "sha256:90b9e29824800e90c84e4022dd5cc16eb2d9605ee13f05d47641eb183cd73d45", + "sha256:9797a6c8fe16f25749b371c02e2ade0efb51155e767a971c61734b1bf6293994", + "sha256:9d2455fbf44c914840c793e89aa82d0e1763a14253a000743719ae5946814b2d", + "sha256:9d3bea1c75f8c53ee4d505c3e67d8c158ad4df0d83170605b50b64025917f338", + "sha256:9e2ec1e921fd07c7cda7962bad283acc2f2a9ccc1b971ee4b216b75fad6f0463", + "sha256:9e91179a242bbc99be65e139e30690e081fe6cb91a8e77faf4c409653de39451", + "sha256:a0eaa93d054751ee9964afa21c06247779b90440ca41d184aeb5d410f20ff591", + "sha256:a2c405445c79c3f5a124573a051062300936b0281fee57637e706453e452746c", + "sha256:aa7e402ce11f0885305bfb6afb3434b3cd8f53b563ac065452d9d5654c7b86fd", + "sha256:aff76a55a8aa8364d25400a210a65ff59d0168e0b4285ba6bf2bd83cf675ba32", + "sha256:b09b86b27a064c9624d0a6c54da01c1beaf5b6cadfa609cf63789b1d08a797b9", + "sha256:b14f16f94cbc61215115b9b1236f9c18403c15dd3c52cf629072afa9d54c1cbf", + "sha256:b50811d664d392f02f7761621303eba9d1b056fb1868c8cdf4231279645c25f5", + "sha256:b7bc2176354defba3edc2b9a777744462da2f8e921fbaf61e52acb95bafa9828", + "sha256:c78e1b00a87ce43bb37642c0812315b411e856a905d58d597750eb79802aaaa3", + "sha256:c83341b89884e2b2e55886e8fbbf37c3fa5efd6c8907124aeb72f285ae5696e5", + "sha256:ca2870d5d10d8726a27396d3ca4cf7976cec0f3cb706debe88e3a5bd4610f7d2", + "sha256:ccce24b7ad89adb5a1e34a6ba96ac2530046763912806ad4c247356a8f33a67b", + "sha256:cd5e14fbf22a87321b24c88669aad3a51ec052eb145315b3da3b7e3cc105b9a2", + "sha256:ce49c67f4ea0609933d01c0731b34b8695a7a748d6c8d186f95e7d085d2fe475", + "sha256:d33891be6df59d93df4d846640f0e46f1a807339f09e79a8040bc887bdcd7ed3", + "sha256:d3b2348a78bc939b4fed6552abfd2e7988e0f81443ef3911a4b8498ca084f6eb", + "sha256:d886f5d353333b4771d21267c7ecc75b710f1a73d72d03ca06df49b09015a9ef", + "sha256:d93480005693d247f8346bc8ee28c72a2191bdf1f6b5db469c096c0c867ac015", + "sha256:dc1a390a82755a8c26c9964d457d4c9cbec5405896cba94cf51f36ea0d855002", + "sha256:dd78700f5788ae180b5ee8902c6aea5a5726bac7c364b202b4b3e3ba2d293170", + "sha256:e46f38133e5a060d46bd630faa4d9fa0202377495df1f068a8299fd78c84de84", + "sha256:e4b878386c4bf293578b48fc570b84ecfe477d3b77ba39a6e87150af77f40c57", + "sha256:f0d0591a0aeaefdaf9a5e545e7485f89910c977087e7de2b6c388aec32011e9f", + "sha256:fdcbb4068117dfd9ce0138d068ac512843c52295ed996ae6dd1faf537b6dbc27", + "sha256:ff61bfd9253c3915e6d41c651d5f962da23eda633cf02262990094a18a55371a" ], "markers": "python_version >= '3.8'", - "version": "==10.2.0" + "version": "==10.3.0" }, "pyasn1": { "hashes": [ - "sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58", - "sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c" + "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c", + "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==0.5.1" + "markers": "python_version >= '3.8'", + "version": "==0.6.0" }, "pyasn1-modules": { "hashes": [ - "sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c", - "sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d" + "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6", + "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", - "version": "==0.3.0" + "markers": "python_version >= '3.8'", + "version": "==0.4.0" }, "pyhamcrest": { "hashes": [ @@ -669,7 +670,7 @@ "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.9.0.post0" }, "pytz": { @@ -724,10 +725,10 @@ }, "rapid-router": { "hashes": [ - "sha256:21a4e8b5becccfa2520b04d905c29bc252831e5657ff9122a44858cde9cb4779", - "sha256:ab9cb6bf1436cf042543b4f305c7e5c825fe6a4bd646a99fe4f80bbdd70a40f4" + "sha256:0beb0b7892f1b3955ed550290f6e8f4de46ea309d015696b699ffafced6180df", + "sha256:71aa0681cb293002bbd214c5bc3b24ef44f55e85e629a6fa9022e4aefbbfb094" ], - "version": "==5.16.19" + "version": "==5.16.21" }, "reportlab": { "hashes": [ @@ -790,11 +791,11 @@ }, "requests-oauthlib": { "hashes": [ - "sha256:7a3130d94a17520169e38db6c8d75f2c974643788465ecc2e4b36d288bf13033", - "sha256:acee623221e4a39abcbb919312c8ff04bd44e7e417087fb4bd5e2a2f53d5e79a" + "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", + "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.0" + "markers": "python_version >= '3.4'", + "version": "==2.0.0" }, "rsa": { "hashes": [ @@ -817,7 +818,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "sortedcontainers": { @@ -852,11 +853,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", - "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.10.0" + "version": "==4.11.0" }, "tzdata": { "hashes": [ @@ -898,21 +899,21 @@ }, "zipp": { "hashes": [ - "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31", - "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0" + "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b", + "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715" ], "markers": "python_version >= '3.8'", - "version": "==3.17.0" + "version": "==3.18.1" } }, "develop": { "asgiref": { "hashes": [ - "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e", - "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed" + "sha256:3e1e3ecc849832fe52ccf2cb6686b7a55f82bb1d6aee72a58826471390335e47", + "sha256:c343bd80a0bec947a9860adb4c432ffa7db769836c64238fc34bdc3fec84d590" ], - "markers": "python_version >= '3.7'", - "version": "==3.7.2" + "markers": "python_version >= '3.8'", + "version": "==3.8.1" }, "attrs": { "hashes": [ @@ -1068,61 +1069,61 @@ "toml" ], "hashes": [ - "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa", - "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003", - "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f", - "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c", - "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e", - "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0", - "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9", - "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52", - "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e", - "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454", - "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0", - "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079", - "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352", - "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f", - "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30", - "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe", - "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113", - "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765", - "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc", - "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e", - "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501", - "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7", - "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2", - "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f", - "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4", - "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524", - "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c", - "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51", - "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840", - "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6", - "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee", - "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e", - "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45", - "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba", - "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d", - "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3", - "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10", - "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e", - "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb", - "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9", - "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a", - "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47", - "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1", - "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3", - "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914", - "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328", - "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6", - "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d", - "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0", - "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94", - "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc", - "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2" + "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c", + "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63", + "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7", + "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f", + "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8", + "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf", + "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0", + "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384", + "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76", + "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7", + "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d", + "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70", + "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f", + "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818", + "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b", + "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d", + "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec", + "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083", + "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2", + "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9", + "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd", + "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade", + "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e", + "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a", + "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227", + "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87", + "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c", + "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e", + "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c", + "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e", + "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd", + "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec", + "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562", + "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8", + "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677", + "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357", + "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c", + "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd", + "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49", + "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286", + "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1", + "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf", + "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51", + "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409", + "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384", + "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e", + "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978", + "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57", + "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e", + "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2", + "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48", + "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4" ], "markers": "python_version >= '3.8'", - "version": "==7.4.3" + "version": "==7.4.4" }, "defusedxml": { "hashes": [ @@ -1142,19 +1143,19 @@ }, "django": { "hashes": [ - "sha256:5dd5b787c3ba39637610fe700f54bf158e33560ea0dba600c19921e7ff926ec5", - "sha256:aaee9fb0fb4ebd4311520887ad2e33313d368846607f82a9a0ed461cd4c35b18" + "sha256:7ca38a78654aee72378594d63e51636c04b8e28574f5505dff630895b5472777", + "sha256:a52ea7fcf280b16f7b739cec38fa6d3f8953a5456986944c3ca97e79882b4e38" ], "markers": "python_version >= '3.6'", - "version": "==3.2.24" + "version": "==3.2.25" }, "django-import-export": { "hashes": [ - "sha256:39a4216c26a2dba6429b64c68b3fe282a6279bb71afb4015c13df0696bdbb4cd", - "sha256:dffedd53bed33cfcceb3b2f13d4fd93a21826f9a2ae37b9926a1e1f4be24bcb9" + "sha256:2eac09e8cec8670f36e24314760448011ad23c51e8fb930d55f50d0c3c926da0", + "sha256:4deabc557801d368093608c86fd0f4831bc9540e2ea41ca2f023e2efb3eb6f48" ], "markers": "python_version >= '3.8'", - "version": "==3.3.7" + "version": "==3.3.8" }, "django-selenium-clean": { "hashes": [ @@ -1191,11 +1192,11 @@ }, "execnet": { "hashes": [ - "sha256:88256416ae766bc9e8895c76a87928c0012183da3cc4fc18016e6f050e025f41", - "sha256:cc59bc4423742fd71ad227122eb0dd44db51efb3dc4095b45ac9a08c770096af" + "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", + "sha256:5189b52c6121c24feae288166ab41b32549c7e2348652736540b9e6e7d4e72e3" ], - "markers": "python_version >= '3.7'", - "version": "==2.0.2" + "markers": "python_version >= '3.8'", + "version": "==2.1.1" }, "fastdiff": { "hashes": [ @@ -1228,6 +1229,15 @@ "markers": "python_version >= '3.7'", "version": "==2.0.0" }, + "isort": { + "hashes": [ + "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", + "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.0'", + "version": "==5.13.2" + }, "markuppy": { "hashes": [ "sha256:1adee2c0a542af378fe84548ff6f6b0168f3cb7f426b46961038a2bcfaad0d5f" @@ -1323,12 +1333,12 @@ }, "pytest-cov": { "hashes": [ - "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6", - "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a" + "sha256:4f0764a1219df53214206bf1feea4633c3b558a2925c8b59f144f682861ce652", + "sha256:5837b58e9f6ebd335b0f8060eecce69b662415b16dc503883a02f45dfeb14857" ], "index": "pypi", - "markers": "python_version >= '3.7'", - "version": "==4.1.0" + "markers": "python_version >= '3.8'", + "version": "==5.0.0" }, "pytest-django": { "hashes": [ @@ -1341,21 +1351,21 @@ }, "pytest-mock": { "hashes": [ - "sha256:0972719a7263072da3a21c7f4773069bcc7486027d7e8e1f81d98a47e701bc4f", - "sha256:31a40f038c22cad32287bb43932054451ff5583ff094bca6f675df2f8bc1a6e9" + "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f", + "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0" ], "index": "pypi", "markers": "python_version >= '3.8'", - "version": "==3.12.0" + "version": "==3.14.0" }, "pytest-order": { "hashes": [ - "sha256:944f86b6d441aa7b1da80f801c6ab65b84bbeba472d0a7a12eb43ba26650101a", - "sha256:9d65c3b6dc6d6ee984d6ae2c6c4aa4f1331e5b915116219075c888c8bcbb93b8" + "sha256:4451bd8821ba4fa2109455a2fcc882af60ef8e53e09d244d67674be08f56eac3", + "sha256:c3082fc73f9ddcf13e4a22dda9bbcc2f39865bf537438a1d50fa241e028dd743" ], "index": "pypi", "markers": "python_version >= '3.6'", - "version": "==1.2.0" + "version": "==1.2.1" }, "pytest-xdist": { "hashes": [ @@ -1447,7 +1457,7 @@ "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.16.0" }, "snapshottest": { @@ -1514,11 +1524,11 @@ }, "trio": { "hashes": [ - "sha256:c3bd3a4e3e3025cd9a2241eae75637c43fe0b9e88b4c97b9161a55b9e54cd72c", - "sha256:ffa09a74a6bf81b84f8613909fb0beaee84757450183a7a2e0b47b455c0cac5d" + "sha256:9b41f5993ad2c0e5f62d0acca320ec657fdb6b2a2c22b8c7aed6caf154475c4e", + "sha256:e6458efe29cc543e557a91e614e2b51710eba2961669329ce9c862d50c6e8e81" ], "markers": "python_version >= '3.8'", - "version": "==0.24.0" + "version": "==0.25.0" }, "trio-websocket": { "hashes": [ @@ -1530,11 +1540,11 @@ }, "typing-extensions": { "hashes": [ - "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", - "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" + "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0", + "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a" ], "markers": "python_version >= '3.8'", - "version": "==4.10.0" + "version": "==4.11.0" }, "urllib3": { "hashes": [ diff --git a/cfl_common/common/app_settings.py b/cfl_common/common/app_settings.py index 0f04ee221..035be8623 100644 --- a/cfl_common/common/app_settings.py +++ b/cfl_common/common/app_settings.py @@ -3,6 +3,9 @@ # Email address to source notifications from EMAIL_ADDRESS = getattr(settings, "EMAIL_ADDRESS", "no-reply@codeforlife.education") +# Dotdigital authorization details +DOTDIGITAL_AUTH = getattr(settings, "DOTDIGITAL_AUTH", "") + # Dotmailer URLs for adding users to the newsletter address book DOTMAILER_CREATE_CONTACT_URL = getattr(settings, "DOTMAILER_CREATE_CONTACT_URL", "") DOTMAILER_MAIN_ADDRESS_BOOK_URL = getattr(settings, "DOTMAILER_MAIN_ADDRESS_BOOK_URL", "") diff --git a/cfl_common/common/email_messages.py b/cfl_common/common/email_messages.py index c6b70c968..4a3e6ab86 100644 --- a/cfl_common/common/email_messages.py +++ b/cfl_common/common/email_messages.py @@ -14,64 +14,6 @@ def resetEmailPasswordMessage(request, domain, uid, token, protocol): } -def emailVerificationNeededEmail(request, token): - url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}" - privacy_notice_url = f"{request.build_absolute_uri(reverse('privacy_notice'))}" - terms_url = f"{request.build_absolute_uri(reverse('terms'))}" - return { - "subject": f"Email verification ", - "message": ( - f"Please go to {url} to verify your email address.\n\nBy activating the account you confirm that you have " - f"read and agreed to our terms ({terms_url}) and our privacy notice ({privacy_notice_url})." - ), - "url": {"verify_url": url}, - } - - -def parentsEmailVerificationNeededEmail(request, user, token): - url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}" - privacy_notice_url = f"{request.build_absolute_uri(reverse('privacy_notice'))}" - terms_url = f"{request.build_absolute_uri(reverse('terms'))}" - return { - "subject": f"Code for Life account request", - "message": ( - f"{user.first_name} has requested to create a Code for Life account so that they can learn how to code for " - f"FREE! 🎉\n\n" - f"{user.first_name} provided your email address as a guardian that is able to read the privacy notice " - f"documents and agree to the terms and conditions related to our website on their behalf.\n\n" - f"If you also wish to receive communication from us, you can sign up for newsletters on our website here. 📧\n\n" - f"Please activate the account for {user.first_name} by following this link: {url}.\n\nBy activating the " - f"account you confirm that you have read and agreed to our terms ({terms_url}) and our privacy notice " - f"({privacy_notice_url})." - ), - "url": {"verify_url": url}, - } - - -def emailChangeVerificationEmail(request, token): - url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': token}))}" - return { - "subject": f"Email verification needed", - "message": ( - f"You are changing your email, please go to " - f"{url} " - f"to verify your new email address. If you are not part of Code for Life " - f"then please ignore this email." - ), - "url": {"verify_url": url}, - } - - -def emailChangeNotificationEmail(request, new_email_address): - return { - "subject": f"Email address update", - "message": ( - f"There is a request to change the email address of your account to " - f"{new_email_address}. If this was not you, please get in contact with us." - ), - } - - def userAlreadyRegisteredEmail(request, email, is_independent_student=False): if is_independent_student: login_url = reverse("independent_student_login") diff --git a/cfl_common/common/helpers/data_migration_loader.py b/cfl_common/common/helpers/data_migration_loader.py index f5cfbb2a2..8f00d433f 100644 --- a/cfl_common/common/helpers/data_migration_loader.py +++ b/cfl_common/common/helpers/data_migration_loader.py @@ -10,7 +10,8 @@ def load_data_from_file(file_name) -> Callable: For use with migrations.RunPython Args: - file_name (str): The name of the file containing the data you want to load. Include `.json` at the end. The file must be in the fixtures directory. + file_name (str): The name of the file containing the data you want to load. Include `.json` at the end. + The file must be in the fixtures directory. """ absolute_file_path = Path(__file__).resolve().parent.parent / "fixtures" / file_name @@ -26,9 +27,7 @@ def _get_model(model_identifier): try: return apps.get_model(model_identifier) except (LookupError, TypeError): - raise base.DeserializationError( - "Invalid model identifier: '%s'" % model_identifier - ) + raise base.DeserializationError("Invalid model identifier: '%s'" % model_identifier) # Replace the _get_model() function on the module, so loaddata can utilize it. python._get_model = _get_model diff --git a/cfl_common/common/helpers/emails.py b/cfl_common/common/helpers/emails.py index 0383e698d..8d1b5ef9f 100644 --- a/cfl_common/common/helpers/emails.py +++ b/cfl_common/common/helpers/emails.py @@ -7,20 +7,16 @@ import jwt from common import app_settings from common.app_settings import domain -from common.email_messages import ( - emailChangeNotificationEmail, - emailChangeVerificationEmail, - emailVerificationNeededEmail, - parentsEmailVerificationNeededEmail, -) -from common.models import Teacher, Student +from common.mail import campaign_ids, send_dotdigital_email +from common.models import Student, Teacher from django.conf import settings from django.contrib.auth.models import User from django.core.mail import EmailMultiAlternatives from django.http import HttpResponse from django.template import loader +from django.urls import reverse from django.utils import timezone -from requests import post, get, put, delete +from requests import delete, get, post, put from requests.exceptions import RequestException NOTIFICATION_EMAIL = "Code For Life Notification <" + app_settings.EMAIL_ADDRESS + ">" @@ -123,14 +119,10 @@ def send_verification_email(request, user, data, new_email=None, age=None): # if the user is a teacher if age is None: - message = emailVerificationNeededEmail(request, verification) - send_email( - VERIFICATION_EMAIL, - [user.email], - message["subject"], - message["message"], - message["subject"], - replace_url=message["url"], + url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}" + + send_dotdigital_email( + campaign_ids["verify_new_user"], [user.email], personalization_values={"VERIFICATION_LINK": url} ) if _newsletter_ticked(data): @@ -138,24 +130,16 @@ def send_verification_email(request, user, data, new_email=None, age=None): # if the user is an independent student else: if age < 13: - message = parentsEmailVerificationNeededEmail(request, user, verification) - send_email( - VERIFICATION_EMAIL, + url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}" + send_dotdigital_email( + campaign_ids["verify_new_user_via_parent"], [user.email], - message["subject"], - message["message"], - message["subject"], - replace_url=message["url"], + personalization_values={"FIRST_NAME": user.first_name, "ACTIVATION_LINK": url}, ) else: - message = emailVerificationNeededEmail(request, verification) - send_email( - VERIFICATION_EMAIL, - [user.email], - message["subject"], - message["message"], - message["subject"], - replace_url=message["url"], + url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}" + send_dotdigital_email( + campaign_ids["verify_new_user"], [user.email], personalization_values={"VERIFICATION_LINK": url} ) if _newsletter_ticked(data): @@ -163,15 +147,9 @@ def send_verification_email(request, user, data, new_email=None, age=None): # verifying change of email address. else: verification = generate_token(user, new_email) - - message = emailChangeVerificationEmail(request, verification) - send_email( - VERIFICATION_EMAIL, - [new_email], - message["subject"], - message["message"], - message["subject"], - replace_url=message["url"], + url = f"{request.build_absolute_uri(reverse('verify_email', kwargs={'token': verification}))}" + send_dotdigital_email( + campaign_ids["email_change_verification"], [new_email], personalization_values={"VERIFICATION_LINK": url} ) @@ -281,8 +259,11 @@ def update_indy_email(user, request, data): changing_email = True users_with_email = User.objects.filter(email=new_email) - message = emailChangeNotificationEmail(request, new_email) - send_email(VERIFICATION_EMAIL, [user.email], message["subject"], message["message"], message["subject"]) + send_dotdigital_email( + campaign_ids["email_change_notification"], + [user.email], + personalization_values={"NEW_EMAIL_ADDRESS": new_email}, + ) # email is available if not users_with_email.exists(): @@ -299,9 +280,10 @@ def update_email(user: Teacher or Student, request, data): changing_email = True users_with_email = User.objects.filter(email=new_email) - message = emailChangeNotificationEmail(request, new_email) - send_email( - VERIFICATION_EMAIL, [user.new_user.email], message["subject"], message["message"], message["subject"] + send_dotdigital_email( + campaign_ids["email_change_notification"], + [user.new_user.email], + personalization_values={"NEW_EMAIL_ADDRESS": new_email}, ) # email is available diff --git a/cfl_common/common/helpers/generators.py b/cfl_common/common/helpers/generators.py index e4506b64e..a890e0503 100644 --- a/cfl_common/common/helpers/generators.py +++ b/cfl_common/common/helpers/generators.py @@ -1,6 +1,6 @@ +import hashlib import random import string -import hashlib from builtins import range, str from uuid import uuid4 diff --git a/cfl_common/common/mail.py b/cfl_common/common/mail.py new file mode 100644 index 000000000..18af3eea1 --- /dev/null +++ b/cfl_common/common/mail.py @@ -0,0 +1,116 @@ +import typing as t +from dataclasses import dataclass + +import requests +from common import app_settings + +campaign_ids = { + "email_change_notification": 1551600, + "email_change_verification": 1551594, + "verify_new_user": 1551577, + "verify_new_user_first_reminder": 1557170, + "verify_new_user_second_reminder": 1557173, + "verify_new_user_via_parent": 1551587, +} + + +def add_contact(email: str): + """Add a new contact to Dotdigital.""" + # TODO: implement + + +def remove_contact(email: str): + """Remove an existing contact from Dotdigital.""" + # TODO: implement + + +@dataclass +class EmailAttachment: + """An email attachment for a Dotdigital triggered campaign.""" + + file_name: str + mime_type: str + content: str + + +# pylint: disable-next=too-many-arguments +def send_dotdigital_email( + campaign_id: int, + to_addresses: t.List[str], + cc_addresses: t.Optional[t.List[str]] = None, + bcc_addresses: t.Optional[t.List[str]] = None, + from_address: t.Optional[str] = None, + personalization_values: t.Optional[t.Dict[str, str]] = None, + metadata: t.Optional[str] = None, + attachments: t.Optional[t.List[EmailAttachment]] = None, + region: str = "r1", + auth: t.Optional[str] = None, + timeout: int = 30, +): + # pylint: disable=line-too-long + """Send a triggered email campaign using DotDigital's API. + + https://developer.dotdigital.com/reference/send-transactional-email-using-a-triggered-campaign + + Args: + campaign_id: The ID of the triggered campaign, which needs to be included within the request body. + to_addresses: The email address(es) to send to. + cc_addresses: The CC email address or address to to send to. separate email addresses with a comma. Maximum: 100. + bcc_addresses: The BCC email address or address to to send to. separate email addresses with a comma. Maximum: 100. + from_address: The From address for your email. Note: The From address must already be added to your account. Otherwise, your account's default From address is used. + personalization_values: Each personalisation value is a key-value pair; the placeholder name of the personalization value needs to be included in the request body. + metadata: The metadata for your email. It can be either a single value or a series of values in a JSON object. + attachments: A Base64 encoded string. All attachment types are supported. Maximum file size: 15 MB. + region: The Dotdigital region id your account belongs to e.g. r1, r2 or r3. + auth: The authorization header used to enable API access. If None, the value will be retrieved from the DOTDIGITAL_AUTH environment variable. + timeout: Send timeout to avoid hanging. + + Raises: + AssertionError: If failed to send email. + """ + # pylint: enable=line-too-long + + if auth is None: + auth = app_settings.DOTDIGITAL_AUTH + + body = { + "campaignId": campaign_id, + "toAddresses": to_addresses, + } + if cc_addresses is not None: + body["ccAddresses"] = cc_addresses + if bcc_addresses is not None: + body["bccAddresses"] = bcc_addresses + if from_address is not None: + body["fromAddress"] = from_address + if personalization_values is not None: + body["personalizationValues"] = [ + { + "name": key, + "value": value, + } + for key, value in personalization_values.items() + ] + if metadata is not None: + body["metadata"] = metadata + if attachments is not None: + body["attachments"] = [ + { + "fileName": attachment.file_name, + "mimeType": attachment.mime_type, + "content": attachment.content, + } + for attachment in attachments + ] + + response = requests.post( + url=f"https://{region}-api.dotdigital.com/v2/email/triggered-campaign", + json=body, + headers={ + "accept": "text/plain", + "authorization": auth, + }, + timeout=timeout, + ) + + assert response.ok, "Failed to send email." f" Reason: {response.reason}." f" Text: {response.text}." diff --git a/cfl_common/common/migrations/0002_emailverification.py b/cfl_common/common/migrations/0002_emailverification.py index 9c0c6751b..384a401a3 100644 --- a/cfl_common/common/migrations/0002_emailverification.py +++ b/cfl_common/common/migrations/0002_emailverification.py @@ -32,9 +32,7 @@ class Migration(migrations.Migration): ("token", models.CharField(max_length=30)), ( "email", - models.CharField( - blank=True, default=None, max_length=200, null=True - ), + models.CharField(blank=True, default=None, max_length=200, null=True), ), ("expiry", models.DateTimeField()), ("verified", models.BooleanField(default=False)), diff --git a/cfl_common/common/migrations/0005_add_worksheets.py b/cfl_common/common/migrations/0005_add_worksheets.py index a0949a223..856fcc8f3 100644 --- a/cfl_common/common/migrations/0005_add_worksheets.py +++ b/cfl_common/common/migrations/0005_add_worksheets.py @@ -9,8 +9,4 @@ class Migration(migrations.Migration): ("aimmo", "0020_add_info_to_worksheet"), ] - operations = [ - migrations.RunPython( - migrations.RunPython.noop, reverse_code=migrations.RunPython.noop - ) - ] + operations = [migrations.RunPython(migrations.RunPython.noop, reverse_code=migrations.RunPython.noop)] diff --git a/cfl_common/common/migrations/0007_add_pdf_names_to_first_two_worksheets.py b/cfl_common/common/migrations/0007_add_pdf_names_to_first_two_worksheets.py index b582f33b8..7af0cde05 100644 --- a/cfl_common/common/migrations/0007_add_pdf_names_to_first_two_worksheets.py +++ b/cfl_common/common/migrations/0007_add_pdf_names_to_first_two_worksheets.py @@ -9,8 +9,4 @@ class Migration(migrations.Migration): ("aimmo", "0021_add_pdf_names_to_worksheet"), ] - operations = [ - migrations.RunPython( - migrations.RunPython.noop, reverse_code=migrations.RunPython.noop - ) - ] + operations = [migrations.RunPython(migrations.RunPython.noop, reverse_code=migrations.RunPython.noop)] diff --git a/cfl_common/common/migrations/0008_unlock_worksheet_3.py b/cfl_common/common/migrations/0008_unlock_worksheet_3.py index 82edfc6b2..ab96d3571 100644 --- a/cfl_common/common/migrations/0008_unlock_worksheet_3.py +++ b/cfl_common/common/migrations/0008_unlock_worksheet_3.py @@ -8,8 +8,4 @@ class Migration(migrations.Migration): ("common", "0007_add_pdf_names_to_first_two_worksheets"), ] - operations = [ - migrations.RunPython( - migrations.RunPython.noop, reverse_code=migrations.RunPython.noop - ) - ] + operations = [migrations.RunPython(migrations.RunPython.noop, reverse_code=migrations.RunPython.noop)] diff --git a/cfl_common/common/migrations/0017_copy_email_to_username.py b/cfl_common/common/migrations/0017_copy_email_to_username.py index 9eb4f75fb..ec5dfd6a4 100644 --- a/cfl_common/common/migrations/0017_copy_email_to_username.py +++ b/cfl_common/common/migrations/0017_copy_email_to_username.py @@ -3,9 +3,7 @@ def copy_email_to_username(apps, schema): Student = apps.get_model("common", "Student") - independent_students = Student.objects.filter( - class_field__isnull=True, new_user__is_active=True - ) + independent_students = Student.objects.filter(class_field__isnull=True, new_user__is_active=True) for student in independent_students: student.new_user.username = student.new_user.email student.new_user.save() @@ -17,8 +15,4 @@ class Migration(migrations.Migration): ("common", "0016_joinreleasestudent"), ] - operations = [ - migrations.RunPython( - code=copy_email_to_username, reverse_code=migrations.RunPython.noop - ) - ] + operations = [migrations.RunPython(code=copy_email_to_username, reverse_code=migrations.RunPython.noop)] diff --git a/cfl_common/common/migrations/0021_school_is_active.py b/cfl_common/common/migrations/0021_school_is_active.py index d4e867d7f..b19b3afc0 100644 --- a/cfl_common/common/migrations/0021_school_is_active.py +++ b/cfl_common/common/migrations/0021_school_is_active.py @@ -6,23 +6,23 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0020_class_is_active_and_null_access_code'), + ("common", "0020_class_is_active_and_null_access_code"), ] operations = [ migrations.AddField( - model_name='school', - name='is_active', + model_name="school", + name="is_active", field=models.BooleanField(default=True), ), migrations.AlterField( - model_name='school', - name='postcode', + model_name="school", + name="postcode", field=models.CharField(max_length=10, null=True), ), migrations.AlterField( - model_name='school', - name='town', + model_name="school", + name="town", field=models.CharField(max_length=200, null=True), ), ] diff --git a/cfl_common/common/migrations/0022_school_cleanup.py b/cfl_common/common/migrations/0022_school_cleanup.py index 273b2aef7..7c25ab851 100644 --- a/cfl_common/common/migrations/0022_school_cleanup.py +++ b/cfl_common/common/migrations/0022_school_cleanup.py @@ -6,24 +6,24 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0021_school_is_active'), + ("common", "0021_school_is_active"), ] operations = [ migrations.RemoveField( - model_name='school', - name='latitude', + model_name="school", + name="latitude", ), migrations.RemoveField( - model_name='school', - name='longitude', + model_name="school", + name="longitude", ), migrations.RemoveField( - model_name='school', - name='town', + model_name="school", + name="town", ), migrations.RemoveField( - model_name='userprofile', - name='can_view_aggregated_data', + model_name="userprofile", + name="can_view_aggregated_data", ), ] diff --git a/cfl_common/common/migrations/0023_userprofile_aimmo_badges.py b/cfl_common/common/migrations/0023_userprofile_aimmo_badges.py index 9a8b84f7d..a577c7052 100644 --- a/cfl_common/common/migrations/0023_userprofile_aimmo_badges.py +++ b/cfl_common/common/migrations/0023_userprofile_aimmo_badges.py @@ -6,17 +6,17 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0022_school_cleanup'), + ("common", "0022_school_cleanup"), ] operations = [ migrations.AlterModelOptions( - name='school', + name="school", options={}, ), migrations.AddField( - model_name='userprofile', - name='aimmo_badges', + model_name="userprofile", + name="aimmo_badges", field=models.CharField(blank=True, max_length=200, null=True), ), ] diff --git a/cfl_common/common/migrations/0025_schoolteacherinvitation.py b/cfl_common/common/migrations/0025_schoolteacherinvitation.py index 3705416cd..d8af62bac 100644 --- a/cfl_common/common/migrations/0025_schoolteacherinvitation.py +++ b/cfl_common/common/migrations/0025_schoolteacherinvitation.py @@ -8,24 +8,40 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0024_teacher_invited_by'), + ("common", "0024_teacher_invited_by"), ] operations = [ migrations.CreateModel( - name='SchoolTeacherInvitation', + name="SchoolTeacherInvitation", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('token', models.CharField(max_length=32)), - ('invited_teacher_first_name', models.CharField(max_length=150)), - ('invited_teacher_last_name', models.CharField(max_length=150)), - ('invited_teacher_email', models.EmailField(max_length=254)), - ('invited_teacher_is_admin', models.BooleanField(default=False)), - ('expiry', models.DateTimeField()), - ('creation_time', models.DateTimeField(default=django.utils.timezone.now, null=True)), - ('is_active', models.BooleanField(default=True)), - ('from_teacher', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='school_invitations', to='common.teacher')), - ('school', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teacher_invitations', to='common.school')), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("token", models.CharField(max_length=32)), + ("invited_teacher_first_name", models.CharField(max_length=150)), + ("invited_teacher_last_name", models.CharField(max_length=150)), + ("invited_teacher_email", models.EmailField(max_length=254)), + ("invited_teacher_is_admin", models.BooleanField(default=False)), + ("expiry", models.DateTimeField()), + ("creation_time", models.DateTimeField(default=django.utils.timezone.now, null=True)), + ("is_active", models.BooleanField(default=True)), + ( + "from_teacher", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="school_invitations", + to="common.teacher", + ), + ), + ( + "school", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teacher_invitations", + to="common.school", + ), + ), ], ), ] diff --git a/cfl_common/common/migrations/0026_teacher_remove_join_request.py b/cfl_common/common/migrations/0026_teacher_remove_join_request.py index 638103544..723429724 100644 --- a/cfl_common/common/migrations/0026_teacher_remove_join_request.py +++ b/cfl_common/common/migrations/0026_teacher_remove_join_request.py @@ -6,17 +6,17 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0025_schoolteacherinvitation'), + ("common", "0025_schoolteacherinvitation"), ] operations = [ migrations.RemoveField( - model_name='teacher', - name='pending_join_request', + model_name="teacher", + name="pending_join_request", ), migrations.AlterField( - model_name='teacher', - name='blocked_time', + model_name="teacher", + name="blocked_time", field=models.DateTimeField(blank=True, null=True), ), ] diff --git a/cfl_common/common/migrations/0027_class_created_by.py b/cfl_common/common/migrations/0027_class_created_by.py index 8e538b1c5..69309133c 100644 --- a/cfl_common/common/migrations/0027_class_created_by.py +++ b/cfl_common/common/migrations/0027_class_created_by.py @@ -7,13 +7,19 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0026_teacher_remove_join_request'), + ("common", "0026_teacher_remove_join_request"), ] operations = [ migrations.AddField( - model_name='class', - name='created_by', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_classes', to='common.teacher'), + model_name="class", + name="created_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="created_classes", + to="common.teacher", + ), ), ] diff --git a/cfl_common/common/migrations/0028_coding_club_downloads.py b/cfl_common/common/migrations/0028_coding_club_downloads.py index 4ac09f9fa..4baa8806c 100644 --- a/cfl_common/common/migrations/0028_coding_club_downloads.py +++ b/cfl_common/common/migrations/0028_coding_club_downloads.py @@ -6,18 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0027_class_created_by'), + ("common", "0027_class_created_by"), ] operations = [ migrations.AddField( - model_name='dailyactivity', - name='primary_coding_club_downloads', + model_name="dailyactivity", + name="primary_coding_club_downloads", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='dailyactivity', - name='python_coding_club_downloads', + model_name="dailyactivity", + name="python_coding_club_downloads", field=models.PositiveIntegerField(default=0), ), ] diff --git a/cfl_common/common/migrations/0029_dynamicelement.py b/cfl_common/common/migrations/0029_dynamicelement.py index b7ae229b2..6d9e09d00 100644 --- a/cfl_common/common/migrations/0029_dynamicelement.py +++ b/cfl_common/common/migrations/0029_dynamicelement.py @@ -6,17 +6,17 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0028_coding_club_downloads'), + ("common", "0028_coding_club_downloads"), ] operations = [ migrations.CreateModel( - name='DynamicElement', + name="DynamicElement", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=64, unique=True, editable=False)), - ('active', models.BooleanField(default=False)), - ('text', models.TextField(blank=True, null=True)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=64, unique=True, editable=False)), + ("active", models.BooleanField(default=False)), + ("text", models.TextField(blank=True, null=True)), ], ), ] diff --git a/cfl_common/common/migrations/0030_add_maintenance_banner.py b/cfl_common/common/migrations/0030_add_maintenance_banner.py index 91b84c0be..ce88937b4 100644 --- a/cfl_common/common/migrations/0030_add_maintenance_banner.py +++ b/cfl_common/common/migrations/0030_add_maintenance_banner.py @@ -22,6 +22,4 @@ class Migration(migrations.Migration): dependencies = [("common", "0029_dynamicelement")] - operations = [ - migrations.RunPython(add_maintenance_banner, remove_maintenance_banner) - ] + operations = [migrations.RunPython(add_maintenance_banner, remove_maintenance_banner)] diff --git a/cfl_common/common/migrations/0031_improve_admin_panel.py b/cfl_common/common/migrations/0031_improve_admin_panel.py index c91416ba7..eb7a80f62 100644 --- a/cfl_common/common/migrations/0031_improve_admin_panel.py +++ b/cfl_common/common/migrations/0031_improve_admin_panel.py @@ -7,32 +7,50 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0030_add_maintenance_banner'), + ("common", "0030_add_maintenance_banner"), ] operations = [ migrations.AlterModelOptions( - name='dailyactivity', - options={'verbose_name_plural': 'Daily activities'}, + name="dailyactivity", + options={"verbose_name_plural": "Daily activities"}, ), migrations.AlterField( - model_name='student', - name='blocked_time', + model_name="student", + name="blocked_time", field=models.DateTimeField(blank=True, null=True), ), migrations.AlterField( - model_name='student', - name='class_field', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='students', to='common.class'), + model_name="student", + name="class_field", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="students", + to="common.class", + ), ), migrations.AlterField( - model_name='student', - name='pending_class_request', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='class_request', to='common.class'), + model_name="student", + name="pending_class_request", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="class_request", + to="common.class", + ), ), migrations.AlterField( - model_name='teacher', - name='school', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='teacher_school', to='common.school'), + model_name="teacher", + name="school", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="teacher_school", + to="common.school", + ), ), ] diff --git a/cfl_common/common/migrations/0032_dailyactivity_level_control_submits.py b/cfl_common/common/migrations/0032_dailyactivity_level_control_submits.py index e2ba29ab3..07b385aa5 100644 --- a/cfl_common/common/migrations/0032_dailyactivity_level_control_submits.py +++ b/cfl_common/common/migrations/0032_dailyactivity_level_control_submits.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0031_improve_admin_panel'), + ("common", "0031_improve_admin_panel"), ] operations = [ migrations.AddField( - model_name='dailyactivity', - name='level_control_submits', + model_name="dailyactivity", + name="level_control_submits", field=models.PositiveBigIntegerField(default=0), ), ] diff --git a/cfl_common/common/migrations/0033_password_reset_tracking_fields.py b/cfl_common/common/migrations/0033_password_reset_tracking_fields.py index 95a0230d4..1b411f33f 100644 --- a/cfl_common/common/migrations/0033_password_reset_tracking_fields.py +++ b/cfl_common/common/migrations/0033_password_reset_tracking_fields.py @@ -6,18 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0032_dailyactivity_level_control_submits'), + ("common", "0032_dailyactivity_level_control_submits"), ] operations = [ migrations.AddField( - model_name='dailyactivity', - name='daily_indy_lockout_reset', + model_name="dailyactivity", + name="daily_indy_lockout_reset", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='dailyactivity', - name='daily_teacher_lockout_reset', + model_name="dailyactivity", + name="daily_teacher_lockout_reset", field=models.PositiveIntegerField(default=0), ), ] diff --git a/cfl_common/common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py b/cfl_common/common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py index ad46443a5..79dcf4966 100644 --- a/cfl_common/common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py +++ b/cfl_common/common/migrations/0034_dailyactivity_daily_school_student_lockout_reset.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0033_password_reset_tracking_fields'), + ("common", "0033_password_reset_tracking_fields"), ] operations = [ migrations.AddField( - model_name='dailyactivity', - name='daily_school_student_lockout_reset', + model_name="dailyactivity", + name="daily_school_student_lockout_reset", field=models.PositiveIntegerField(default=0), ), ] diff --git a/cfl_common/common/migrations/0035_rename_lockout_fields.py b/cfl_common/common/migrations/0035_rename_lockout_fields.py index 791efd6e2..75c6a6529 100644 --- a/cfl_common/common/migrations/0035_rename_lockout_fields.py +++ b/cfl_common/common/migrations/0035_rename_lockout_fields.py @@ -5,23 +5,23 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0034_dailyactivity_daily_school_student_lockout_reset'), + ("common", "0034_dailyactivity_daily_school_student_lockout_reset"), ] operations = [ migrations.RenameField( - model_name='dailyactivity', - old_name='daily_indy_lockout_reset', - new_name='indy_lockout_resets', + model_name="dailyactivity", + old_name="daily_indy_lockout_reset", + new_name="indy_lockout_resets", ), migrations.RenameField( - model_name='dailyactivity', - old_name='daily_school_student_lockout_reset', - new_name='school_student_lockout_resets', + model_name="dailyactivity", + old_name="daily_school_student_lockout_reset", + new_name="school_student_lockout_resets", ), migrations.RenameField( - model_name='dailyactivity', - old_name='daily_teacher_lockout_reset', - new_name='teacher_lockout_resets', + model_name="dailyactivity", + old_name="daily_teacher_lockout_reset", + new_name="teacher_lockout_resets", ), ] diff --git a/cfl_common/common/migrations/0037_migrate_email_verification.py b/cfl_common/common/migrations/0037_migrate_email_verification.py index 6cbe1e3cd..1b2ad5be9 100644 --- a/cfl_common/common/migrations/0037_migrate_email_verification.py +++ b/cfl_common/common/migrations/0037_migrate_email_verification.py @@ -7,13 +7,13 @@ class Migration(migrations.Migration): ] def forwards(apps, schema_editor): - """ Finds the users of verified Email Verification objects and sets their `is_verified` to True """ + """Finds the users of verified Email Verification objects and sets their `is_verified` to True""" UserProfile = apps.get_model("common", "UserProfile") db_alias = schema_editor.connection.alias UserProfile.objects.using(db_alias).filter(user__email_verifications__verified=True).update(is_verified=True) def backwards(apps, schema_editor): - """ Finds the users of verified Email Verification objects and sets their `is_verified` to False """ + """Finds the users of verified Email Verification objects and sets their `is_verified` to False""" UserProfile = apps.get_model("common", "UserProfile") db_alias = schema_editor.connection.alias UserProfile.objects.using(db_alias).filter(user__email_verifications__verified=True).update(is_verified=False) diff --git a/cfl_common/common/migrations/0038_delete_emailverification.py b/cfl_common/common/migrations/0038_delete_emailverification.py index 7b1172951..7dd925b84 100644 --- a/cfl_common/common/migrations/0038_delete_emailverification.py +++ b/cfl_common/common/migrations/0038_delete_emailverification.py @@ -6,11 +6,11 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0037_migrate_email_verification'), + ("common", "0037_migrate_email_verification"), ] operations = [ migrations.DeleteModel( - name='EmailVerification', + name="EmailVerification", ), ] diff --git a/cfl_common/common/migrations/0039_copy_email_to_username.py b/cfl_common/common/migrations/0039_copy_email_to_username.py index 7029b0d48..f86e98f07 100644 --- a/cfl_common/common/migrations/0039_copy_email_to_username.py +++ b/cfl_common/common/migrations/0039_copy_email_to_username.py @@ -15,9 +15,4 @@ class Migration(migrations.Migration): ("common", "0038_delete_emailverification"), ] - operations = [ - migrations.RunPython( - code=copy_email_to_username, reverse_code=migrations.RunPython.noop - ) - ] - + operations = [migrations.RunPython(code=copy_email_to_username, reverse_code=migrations.RunPython.noop)] diff --git a/cfl_common/common/migrations/0040_school_county.py b/cfl_common/common/migrations/0040_school_county.py index 68999bc76..3d187239d 100644 --- a/cfl_common/common/migrations/0040_school_county.py +++ b/cfl_common/common/migrations/0040_school_county.py @@ -6,13 +6,13 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0039_copy_email_to_username'), + ("common", "0039_copy_email_to_username"), ] operations = [ migrations.AddField( - model_name='school', - name='county', + model_name="school", + name="county", field=models.CharField(blank=True, max_length=50, null=True), ), ] diff --git a/cfl_common/common/migrations/0042_totalactivity.py b/cfl_common/common/migrations/0042_totalactivity.py index 18ff9dd35..351721e02 100644 --- a/cfl_common/common/migrations/0042_totalactivity.py +++ b/cfl_common/common/migrations/0042_totalactivity.py @@ -6,20 +6,20 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0041_populate_gb_counties'), + ("common", "0041_populate_gb_counties"), ] operations = [ migrations.CreateModel( - name='TotalActivity', + name="TotalActivity", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('teacher_registrations', models.PositiveIntegerField(default=0)), - ('student_registrations', models.PositiveIntegerField(default=0)), - ('independent_registrations', models.PositiveIntegerField(default=0)), + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("teacher_registrations", models.PositiveIntegerField(default=0)), + ("student_registrations", models.PositiveIntegerField(default=0)), + ("independent_registrations", models.PositiveIntegerField(default=0)), ], options={ - 'verbose_name_plural': 'Total activity', + "verbose_name_plural": "Total activity", }, ), ] diff --git a/cfl_common/common/migrations/0044_update_activity_models.py b/cfl_common/common/migrations/0044_update_activity_models.py index ffdf9593f..2455dc588 100644 --- a/cfl_common/common/migrations/0044_update_activity_models.py +++ b/cfl_common/common/migrations/0044_update_activity_models.py @@ -6,28 +6,28 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0043_add_total_activity'), + ("common", "0043_add_total_activity"), ] operations = [ migrations.AddField( - model_name='dailyactivity', - name='anonymised_unverified_independents', + model_name="dailyactivity", + name="anonymised_unverified_independents", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='dailyactivity', - name='anonymised_unverified_teachers', + model_name="dailyactivity", + name="anonymised_unverified_teachers", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='totalactivity', - name='anonymised_unverified_independents', + model_name="totalactivity", + name="anonymised_unverified_independents", field=models.PositiveIntegerField(default=0), ), migrations.AddField( - model_name='totalactivity', - name='anonymised_unverified_teachers', + model_name="totalactivity", + name="anonymised_unverified_teachers", field=models.PositiveIntegerField(default=0), ), ] diff --git a/cfl_common/common/migrations/0045_otp.py b/cfl_common/common/migrations/0045_otp.py index 8ae203bcf..52d542176 100644 --- a/cfl_common/common/migrations/0045_otp.py +++ b/cfl_common/common/migrations/0045_otp.py @@ -6,18 +6,18 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0044_update_activity_models'), + ("common", "0044_update_activity_models"), ] operations = [ migrations.AddField( - model_name='userprofile', - name='last_otp_for_time', + model_name="userprofile", + name="last_otp_for_time", field=models.DateTimeField(blank=True, null=True), ), migrations.AddField( - model_name='userprofile', - name='otp_secret', + model_name="userprofile", + name="otp_secret", field=models.CharField(blank=True, max_length=40, null=True), ), ] diff --git a/cfl_common/common/migrations/0046_alter_school_country.py b/cfl_common/common/migrations/0046_alter_school_country.py index c743c2cee..64b5e1947 100644 --- a/cfl_common/common/migrations/0046_alter_school_country.py +++ b/cfl_common/common/migrations/0046_alter_school_country.py @@ -7,13 +7,13 @@ class Migration(migrations.Migration): dependencies = [ - ('common', '0045_otp'), + ("common", "0045_otp"), ] operations = [ migrations.AlterField( - model_name='school', - name='country', + model_name="school", + name="country", field=django_countries.fields.CountryField(blank=True, max_length=2, null=True), ), ] diff --git a/cfl_common/common/tests/utils/email.py b/cfl_common/common/tests/utils/email.py index 552b0f896..e8d195979 100644 --- a/cfl_common/common/tests/utils/email.py +++ b/cfl_common/common/tests/utils/email.py @@ -2,20 +2,14 @@ from builtins import str -def follow_verify_email_link_to_onboarding(page, email): - _follow_verify_email_link(page, email) +def follow_verify_email_link_to_onboarding(page, url): + page.browser.get(url) return go_to_teacher_login_page(page.browser) -def follow_verify_email_link_to_teacher_dashboard(page, email): - _follow_verify_email_link(page, email) - - return go_to_teacher_dashboard_page(page.browser) - - -def follow_verify_email_link_to_login(page, email, user_type): - _follow_verify_email_link(page, email) +def follow_verify_email_link_to_login(page, url, user_type): + page.browser.get(url) if user_type == "teacher": return go_to_teacher_login_page(page.browser) @@ -32,15 +26,6 @@ def follow_duplicate_account_link_to_login(page, email, user_type): return go_to_independent_student_login_page(page.browser) -def _follow_verify_email_link(page, email): - message = str(email.message()) - prefix = '

Please go to ' - j = str.find(message, suffix, i) - page.browser.get(message[i:j]) - - def _follow_duplicate_account_email_link(page, email): message = str(email.message()) prefix = 'please login: 0 - page = email.follow_verify_email_link_to_login(page, mail.outbox[0], "independent") - mail.outbox = [] +def verify_email(page, verification_url): + page = email.follow_verify_email_link_to_login(page, verification_url, "independent") return page diff --git a/cfl_common/common/tests/utils/teacher.py b/cfl_common/common/tests/utils/teacher.py index 7e68449cc..f0a129aa3 100644 --- a/cfl_common/common/tests/utils/teacher.py +++ b/cfl_common/common/tests/utils/teacher.py @@ -1,5 +1,6 @@ import random import sys +from unittest.mock import patch from common.helpers.emails import generate_token from common.models import Teacher @@ -47,7 +48,8 @@ def signup_duplicate_teacher_fail(page, duplicate_email): return page, email_address, password -def signup_teacher(page, newsletter=False): +@patch("common.helpers.emails.send_dotdigital_email") +def signup_teacher(page, mock_send_dotdigital_email, newsletter=False): page = page.go_to_signup_page() first_name, last_name, email_address, password = generate_details() @@ -57,16 +59,14 @@ def signup_teacher(page, newsletter=False): page = page.return_to_home_page() - page = email.follow_verify_email_link_to_onboarding(page, mail.outbox[0]) - mail.outbox = [] + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] - return page, email_address, password + page = email.follow_verify_email_link_to_onboarding(page, verification_url) + return page, email_address, password -def verify_email(page): - assert len(mail.outbox) > 0 - page = email.follow_verify_email_link_to_login(page, mail.outbox[0], "teacher") - mail.outbox = [] +def verify_email(page, verification_url): + page = email.follow_verify_email_link_to_login(page, verification_url, "teacher") return page diff --git a/example_project/portal_test_settings.py b/example_project/portal_test_settings.py index 4b02973a2..9694f2036 100644 --- a/example_project/portal_test_settings.py +++ b/example_project/portal_test_settings.py @@ -1,5 +1,7 @@ """Django settings for example_project project.""" + import os + from selenium import webdriver DEBUG = True @@ -33,6 +35,8 @@ ROOT_URLCONF = "example_project.urls" SECRET_KEY = "bad_test_secret" +DOTDIGITAL_AUTH = "dummy_dotdigital_auth" + DOTMAILER_CREATE_CONTACT_URL = "https://test-create-contact/" DOTMAILER_DELETE_USER_BY_ID_URL = "https://test-delete-contact/" DOTMAILER_MAIN_ADDRESS_BOOK_URL = "https://test-main-address-book/" @@ -169,7 +173,7 @@ "common.context_processors.cookie_management_enabled", "portal.context_processors.process_newsletter_form", ] - } + }, } ] diff --git a/portal/tests/test_independent_student.py b/portal/tests/test_independent_student.py index a81886d1a..87ab32d05 100644 --- a/portal/tests/test_independent_student.py +++ b/portal/tests/test_independent_student.py @@ -2,11 +2,16 @@ import datetime import time +from unittest.mock import ANY, Mock, patch +from common.mail import campaign_ids from common.models import JoinReleaseStudent from common.tests.utils import email as email_utils from common.tests.utils.classes import create_class_directly -from common.tests.utils.organisation import create_organisation_directly, join_teacher_to_organisation +from common.tests.utils.organisation import ( + create_organisation_directly, + join_teacher_to_organisation, +) from common.tests.utils.student import ( create_independent_student, create_independent_student_directly, @@ -137,7 +142,8 @@ def test_signup_invalid_name_fails(self): # Assert response isn't a redirect (submit failure) assert response.status_code == 200 - def test_signup_under_13_sends_parent_email(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_signup_under_13_sends_parent_email(self, mock_send_dotdigital_email: Mock): c = Client() response = c.post( @@ -156,8 +162,9 @@ def test_signup_under_13_sends_parent_email(self): ) assert response.status_code == 302 - assert len(mail.outbox) == 1 - assert mail.outbox[0].subject == "Code for Life account request" + mock_send_dotdigital_email.assert_called_once_with( + campaign_ids["verify_new_user_via_parent"], ANY, personalization_values=ANY + ) # Class for Selenium tests. We plan to replace these and turn them into Cypress tests @@ -256,16 +263,20 @@ def test_login_success(self): page = page.independent_student_login(username, password) assert self.is_dashboard(page) - def test_login_not_verified(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_login_not_verified(self, mock_send_dotdigital_email): username, password, _ = create_independent_student_directly(preverified=False) self.selenium.get(self.live_server_url) page = HomePage(self.selenium) page = page.go_to_independent_student_login_page() page = page.independent_student_login_failure(username, password) + errors = page.has_login_failed("independent_student_login_form", INVALID_LOGIN_MESSAGE) assert page.has_login_failed("independent_student_login_form", INVALID_LOGIN_MESSAGE) - verify_email(page) + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] + + verify_email(page, verification_url) assert is_email_verified_message_showing(self.selenium) @@ -340,7 +351,8 @@ def test_update_name_failure(self): "student_account_form", "Names may only contain letters, numbers, dashes, underscores, and spaces." ) - def test_change_email(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_change_email(self, mock_send_dotdigital_email): homepage = self.go_to_homepage() _, _, _, student_email, password = create_independent_student(homepage) @@ -354,9 +366,9 @@ def test_change_email(self): assert is_student_details_updated_message_showing(self.selenium) assert is_email_updated_message_showing(self.selenium) - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" - mail.outbox = [] + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_notification"], ANY, personalization_values=ANY + ) # Try changing email to an existing teacher's email teacher_email, _ = signup_teacher_directly() @@ -372,9 +384,9 @@ def test_change_email(self): assert is_student_details_updated_message_showing(self.selenium) assert is_email_updated_message_showing(self.selenium) - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" - mail.outbox = [] + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_notification"], ANY, personalization_values=ANY + ) page = ( self.go_to_homepage() @@ -402,11 +414,12 @@ def test_change_email(self): page = page.logout() - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_verification"], ANY, personalization_values=ANY + ) + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] - page = email_utils.follow_change_email_link_to_independent_dashboard(page, mail.outbox[1]) - mail.outbox = [] + page = email_utils.follow_change_email_link_to_independent_dashboard(page, verification_url) page = page.independent_student_login(new_email, password) diff --git a/portal/tests/test_ratelimit.py b/portal/tests/test_ratelimit.py index fb6e08efd..204696ef2 100644 --- a/portal/tests/test_ratelimit.py +++ b/portal/tests/test_ratelimit.py @@ -2,10 +2,12 @@ import re from datetime import datetime, timedelta +from unittest.mock import ANY, Mock, patch import pytest import pytz -from common.models import Teacher, Student, DailyActivity +from common.mail import campaign_ids +from common.models import DailyActivity, Student, Teacher from common.tests.utils.classes import create_class_directly from common.tests.utils.organisation import create_organisation_directly from common.tests.utils.student import ( @@ -13,11 +15,10 @@ create_school_student_directly, generate_independent_student_details, ) -from common.tests.utils.teacher import signup_teacher_directly, generate_details +from common.tests.utils.teacher import generate_details, signup_teacher_directly from django.core import mail from django.test import Client, TestCase -from django.urls import reverse -from django.urls import reverse_lazy +from django.urls import reverse, reverse_lazy from portal.helpers.ratelimit import get_ratelimit_count_for_user from portal.views.login import has_user_lockout_expired @@ -432,8 +433,9 @@ def test_lockout_reset_tracking(self): assert current_daily_activity.school_student_lockout_resets == 2 +@patch("common.helpers.emails.send_dotdigital_email") @pytest.mark.django_db -def test_teacher_already_registered_email(client): +def test_teacher_already_registered_email(mock_send_dotdigital_email: Mock, client): first_name, last_name, email, password = generate_details() register_url = reverse("register") data = { @@ -448,19 +450,20 @@ def test_teacher_already_registered_email(client): # Register the teacher first time, there should be a registration email client.post(register_url, data) - assert len(mail.outbox) == 1 + mock_send_dotdigital_email.assert_called_once_with(campaign_ids["verify_new_user"], ANY, personalization_values=ANY) # Register with the same email again, there should also be an already registered email client.post(register_url, data) - assert len(mail.outbox) == 2 + assert len(mail.outbox) == 1 # Register with the same email one more time, there shouldn't be any new emails client.post(register_url, data) - assert len(mail.outbox) == 2 + assert len(mail.outbox) == 1 +@patch("common.helpers.emails.send_dotdigital_email") @pytest.mark.django_db -def test_independent_student_already_registered_email(client): +def test_independent_student_already_registered_email(mock_send_dotdigital_email: Mock, client): name, username, email_address, password = generate_independent_student_details() register_url = reverse("register") data = { @@ -477,12 +480,12 @@ def test_independent_student_already_registered_email(client): # Register the independent student first time, there should be a registration email client.post(register_url, data) - assert len(mail.outbox) == 1 + mock_send_dotdigital_email.assert_called_once_with(campaign_ids["verify_new_user"], ANY, personalization_values=ANY) # Register with the same email again, there should also be an already registered email client.post(register_url, data) - assert len(mail.outbox) == 2 + assert len(mail.outbox) == 1 # Reset mock and register with the same email one more time, there shouldn't be any new emails client.post(register_url, data) - assert len(mail.outbox) == 2 + assert len(mail.outbox) == 1 diff --git a/portal/tests/test_teacher.py b/portal/tests/test_teacher.py index 59d07b482..f12b5e73a 100644 --- a/portal/tests/test_teacher.py +++ b/portal/tests/test_teacher.py @@ -1,17 +1,24 @@ from __future__ import absolute_import -import re import time from datetime import timedelta +from unittest.mock import ANY, Mock, patch from uuid import uuid4 import jwt from aimmo.models import Game +from common.mail import campaign_ids from common.models import Class, Student, Teacher from common.tests.utils import email as email_utils from common.tests.utils.classes import create_class_directly -from common.tests.utils.organisation import create_organisation_directly, join_teacher_to_organisation -from common.tests.utils.student import create_independent_student_directly, create_school_student_directly +from common.tests.utils.organisation import ( + create_organisation_directly, + join_teacher_to_organisation, +) +from common.tests.utils.student import ( + create_independent_student_directly, + create_school_student_directly, +) from common.tests.utils.teacher import ( signup_duplicate_teacher_fail, signup_teacher, @@ -357,7 +364,8 @@ def test_signup_fails_without_consent(self): # Assert response isn't a redirect (submit failure) assert response.status_code == 200 - def test_signup_email_verification(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_signup_email_verification(self, mock_send_dotdigital_email: Mock): c = Client() response = c.post( @@ -374,7 +382,9 @@ def test_signup_email_verification(self): ) assert response.status_code == 302 - assert len(mail.outbox) == 1 + mock_send_dotdigital_email.assert_called_once_with( + campaign_ids["verify_new_user"], ANY, personalization_values=ANY + ) # Try verification URL with a fake token fake_token = jwt.encode( @@ -393,9 +403,8 @@ def test_signup_email_verification(self): # Assert response isn't a redirect (get failure) assert bad_verification_response.status_code == 200 - # Get verification link from email - message = str(mail.outbox[0].body) - verification_url = re.search("http.+/", message).group(0) + # Get verification link from function call + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] # Verify the email properly verification_response = c.get(verification_url) @@ -472,7 +481,8 @@ def test_login_success(self): page = page.login(email, password) assert self.is_dashboard_page(page) - def test_login_not_verified(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_login_not_verified(self, mock_send_dotdigital_email): email, password = signup_teacher_directly(preverified=False) create_organisation_directly(email) _, _, access_code = create_class_directly(email) @@ -484,7 +494,9 @@ def test_login_not_verified(self): assert page.has_login_failed("form-login-teacher", INVALID_LOGIN_MESSAGE) - verify_email(page) + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] + + verify_email(page, verification_url) assert is_email_verified_message_showing(self.selenium) @@ -537,7 +549,8 @@ def test_edit_details_non_admin(self): assert page.check_account_details({"first_name": "Florian", "last_name": "Aucomte"}) - def test_change_email(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_change_email(self, mock_send_dotdigital_email): email, password = signup_teacher_directly() create_organisation_directly(email) _, _, access_code = create_class_directly(email) @@ -553,9 +566,9 @@ def test_change_email(self): assert self.is_email_verification_page(page) assert is_email_updated_message_showing(self.selenium) - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" - mail.outbox = [] + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_notification"], ANY, personalization_values=ANY + ) # Try changing email to an existing indy student's email, should fail indy_email, _, _ = create_independent_student_directly() @@ -566,9 +579,9 @@ def test_change_email(self): assert self.is_email_verification_page(page) assert is_email_updated_message_showing(self.selenium) - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" - mail.outbox = [] + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_notification"], ANY, personalization_values=ANY + ) page = self.go_to_homepage() page = page.go_to_teacher_login_page().login(email, password).open_account_tab() @@ -586,11 +599,12 @@ def test_change_email(self): page = page.logout() - subject = str(mail.outbox[0].subject) - assert subject == "Email address update" + mock_send_dotdigital_email.assert_called_with( + campaign_ids["email_change_verification"], ANY, personalization_values=ANY + ) + verification_url = mock_send_dotdigital_email.call_args.kwargs["personalization_values"]["VERIFICATION_LINK"] - page = email_utils.follow_change_email_link_to_dashboard(page, mail.outbox[1]) - mail.outbox = [] + page = email_utils.follow_change_email_link_to_dashboard(page, verification_url) page = page.login(new_email, password).open_account_tab() diff --git a/portal/tests/test_teacher_student.py b/portal/tests/test_teacher_student.py index 12ac78ec6..b178fee98 100644 --- a/portal/tests/test_teacher_student.py +++ b/portal/tests/test_teacher_student.py @@ -1,11 +1,15 @@ from __future__ import absolute_import import json +from unittest.mock import Mock, patch import pytest from common.models import JoinReleaseStudent from common.tests.utils.classes import create_class_directly -from common.tests.utils.organisation import create_organisation_directly, join_teacher_to_organisation +from common.tests.utils.organisation import ( + create_organisation_directly, + join_teacher_to_organisation, +) from common.tests.utils.student import ( create_many_school_students, create_school_student, @@ -528,7 +532,8 @@ def test_move(self): page = page.logout().go_to_teacher_login_page().login(email_2, password_2).open_classes_tab().go_to_class_page() assert page.student_exists(student_name_1) - def test_dismiss(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_dismiss(self, mock_send_dotdigital_email: Mock): email, password = signup_teacher_directly() create_organisation_directly(email) _, _, access_code = create_class_directly(email) @@ -557,7 +562,10 @@ def test_dismiss(self): assert len(logs) == 1 assert logs[0].action_type == JoinReleaseStudent.RELEASE - def test_multiple_dismiss(self): + mock_send_dotdigital_email.assert_called() + + @patch("common.helpers.emails.send_dotdigital_email") + def test_multiple_dismiss(self, mock_send_dotdigital_email: Mock): email, password = signup_teacher_directly() create_organisation_directly(email) _, _, access_code = create_class_directly(email) @@ -597,3 +605,5 @@ def test_multiple_dismiss(self): # student should still exist assert page.student_exists(student_name_2) + + mock_send_dotdigital_email.assert_called() diff --git a/portal/tests/test_views.py b/portal/tests/test_views.py index 0de935fd5..3952012e7 100644 --- a/portal/tests/test_views.py +++ b/portal/tests/test_views.py @@ -7,7 +7,6 @@ import PyPDF2 import pytest from aimmo.models import Game -from common.helpers.emails import NOTIFICATION_EMAIL from common.models import ( Class, DailyActivity, @@ -55,9 +54,7 @@ class TestTeacherViews(TestCase): def setUpTestData(cls): cls.email, cls.password = signup_teacher_directly() _, _, cls.class_access_code = create_class_directly(cls.email) - _, _, cls.student = create_school_student_directly( - cls.class_access_code - ) + _, _, cls.student = create_school_student_directly(cls.class_access_code) def login(self): c = Client() @@ -66,9 +63,7 @@ def login(self): def test_reminder_cards(self): c = self.login() - url = reverse( - "teacher_print_reminder_cards", args=[self.class_access_code] - ) + url = reverse("teacher_print_reminder_cards", args=[self.class_access_code]) # First test with 2 dummy students NAME1 = "Test name" @@ -102,9 +97,7 @@ def test_reminder_cards(self): # page number students_per_page = REMINDER_CARDS_PDF_ROWS * REMINDER_CARDS_PDF_COLUMNS for _ in range(len(studentlist), students_per_page + 1): - studentlist.append( - {"name": NAME1, "password": PASSWORD1, "login_url": URL} - ) + studentlist.append({"name": NAME1, "password": PASSWORD1, "login_url": URL}) assert len(studentlist) == students_per_page + 1 @@ -143,9 +136,7 @@ def test_csv(self): reader = csv.reader(io.StringIO(content)) access_code = self.class_access_code - class_url = reverse( - "student_login", kwargs={"access_code": access_code} - ) + class_url = reverse("student_login", kwargs={"access_code": access_code}) row0 = next(reader) assert row0[0].strip() == access_code assert class_url in row0[1].strip() @@ -184,9 +175,7 @@ def test_organisation_kick_has_correct_permissions(self): def test_daily_activity_student_details(self): c = self.login() - url = reverse( - "teacher_print_reminder_cards", args=[self.class_access_code] - ) + url = reverse("teacher_print_reminder_cards", args=[self.class_access_code]) data = { "data": json.dumps( @@ -249,9 +238,7 @@ def _set_up_test_data(self): teacher_email, teacher_password = signup_teacher_directly() create_organisation_directly(teacher_email) _, _, class_access_code = create_class_directly(teacher_email) - student_name, student_password, _ = create_school_student_directly( - class_access_code - ) + student_name, student_password, _ = create_school_student_directly(class_access_code) return ( teacher_email, @@ -284,16 +271,9 @@ def _create_and_login_school_student(self, next_url=False): _, _, name, password, class_access_code = self._set_up_test_data() if next_url: - url = ( - reverse( - "student_login", kwargs={"access_code": class_access_code} - ) - + "?next=/" - ) + url = reverse("student_login", kwargs={"access_code": class_access_code}) + "?next=/" else: - url = reverse( - "student_login", kwargs={"access_code": class_access_code} - ) + url = reverse("student_login", kwargs={"access_code": class_access_code}) c = Client() response = c.post(url, {"username": name, "password": password}) @@ -332,9 +312,7 @@ def test_teacher_session(self): def _get_user_class(self, name, class_access_code): klass = Class.objects.get(access_code=class_access_code) - students = Student.objects.filter( - new_user__first_name__iexact=name, class_field=klass - ) + students = Student.objects.filter(new_user__first_name__iexact=name, class_field=klass) assert len(students) == 1 user = students[0].new_user return user, klass @@ -376,9 +354,7 @@ def test_student_session_class_link(self): _, _, name, password, class_access_code = self._set_up_test_data() c = Client() - url = reverse( - "student_login", kwargs={"access_code": class_access_code} - ) + url = reverse("student_login", kwargs={"access_code": class_access_code}) c.post(url, {"username": name, "password": password}) # check if there's a UserSession data within the last 10 secs @@ -399,9 +375,7 @@ def test_student_login_failed(self): randomname = "randomname" c = Client() - url = reverse( - "student_login", kwargs={"access_code": class_access_code} - ) + url = reverse("student_login", kwargs={"access_code": class_access_code}) c.post(url, {"username": randomname, "password": "xx"}) # check if there's a UserSession data within the last 10 secs @@ -427,9 +401,7 @@ def test_indep_student_session(self): def test_student_direct_login(self): _, _, _, _, class_access_code = self._set_up_test_data() - student, login_id, _, _ = create_student_with_direct_login( - class_access_code - ) + student, login_id, _, _ = create_student_with_direct_login(class_access_code) c = Client() assert c.login(user_id=student.new_user.id, login_id=login_id) == True @@ -551,9 +523,7 @@ def test_student_dashboard_view(self): c = Client() # Login and check initial data - url = reverse( - "student_login", kwargs={"access_code": class_access_code} - ) + url = reverse("student_login", kwargs={"access_code": class_access_code}) c.post(url, {"username": student_name, "password": student_password}) student_dashboard_url = reverse("student_details") @@ -631,9 +601,7 @@ def test_delete_account(self): # try again with the correct password url = reverse("delete_account") - response = c.post( - url, {"password": password, "unsubscribe_newsletter": "on"} - ) + response = c.post(url, {"password": password, "unsubscribe_newsletter": "on"}) assert response.status_code == 302 assert response.url == reverse("home") @@ -710,9 +678,7 @@ def test_delete_account_admin(self): school_id = school.id school_name = school.name - teachers = Teacher.objects.filter(school=school).order_by( - "new_user__last_name", "new_user__first_name" - ) + teachers = Teacher.objects.filter(school=school).order_by("new_user__last_name", "new_user__first_name") assert len(teachers) == 3 # one of the remaining teachers should be admin (the second in our case, as it's alphabetical) @@ -742,9 +708,7 @@ def test_delete_account_admin(self): c.post(url, {"password": password3}) # 2 teachers left - teachers = Teacher.objects.filter(school=school).order_by( - "new_user__last_name", "new_user__first_name" - ) + teachers = Teacher.objects.filter(school=school).order_by("new_user__last_name", "new_user__first_name") assert len(teachers) == 2 # teacher2 should still be admin, teacher4 is not passed admin role because there is teacher2 @@ -756,9 +720,7 @@ def test_delete_account_admin(self): # delete teacher4 anonymise(user4) - teachers = Teacher.objects.filter(school=school).order_by( - "new_user__last_name", "new_user__first_name" - ) + teachers = Teacher.objects.filter(school=school).order_by("new_user__last_name", "new_user__first_name") assert len(teachers) == 1 u = User.objects.get(id=usrid2) assert u.new_teacher.is_admin @@ -814,15 +776,14 @@ def test_logged_in_as_admin_check(self): c.logout() - def test_registrations_increment_data(self): + @patch("common.helpers.emails.send_dotdigital_email") + def test_registrations_increment_data(self, mock_send_dotdigital_email: Mock): c = Client() total_activity = TotalActivity.objects.get(id=1) teacher_registration_count = total_activity.teacher_registrations student_registration_count = total_activity.student_registrations - independent_registration_count = ( - total_activity.independent_registrations - ) + independent_registration_count = total_activity.independent_registrations response = c.post( reverse("register"), @@ -838,13 +799,11 @@ def test_registrations_increment_data(self): ) assert response.status_code == 302 + mock_send_dotdigital_email.assert_called_once() total_activity = TotalActivity.objects.get(id=1) - assert ( - total_activity.teacher_registrations - == teacher_registration_count + 1 - ) + assert total_activity.teacher_registrations == teacher_registration_count + 1 response = c.post( reverse("register"), @@ -862,13 +821,11 @@ def test_registrations_increment_data(self): ) assert response.status_code == 302 + mock_send_dotdigital_email.assert_called() total_activity = TotalActivity.objects.get(id=1) - assert ( - total_activity.independent_registrations - == independent_registration_count + 1 - ) + assert total_activity.independent_registrations == independent_registration_count + 1 teacher_email, teacher_password = signup_teacher_directly() create_organisation_directly(teacher_email) @@ -884,10 +841,7 @@ def test_registrations_increment_data(self): total_activity = TotalActivity.objects.get(id=1) - assert ( - total_activity.student_registrations - == student_registration_count + 3 - ) + assert total_activity.student_registrations == student_registration_count + 3 # CRON view tests @@ -906,12 +860,8 @@ def generic( secure=False, **extra, ): - wsgi_response = super().generic( - method, path, data, content_type, secure, **extra - ) - assert ( - 200 <= wsgi_response.status_code < 300 - ), f"Response has error status code: {wsgi_response.status_code}" + wsgi_response = super().generic(method, path, data, content_type, secure, **extra) + assert 200 <= wsgi_response.status_code < 300, f"Response has error status code: {wsgi_response.status_code}" return wsgi_response @@ -930,34 +880,27 @@ def setUp(self): indy_email, _, _ = create_independent_student_directly() self.teacher_user = User.objects.get(email=teacher_email) - self.teacher_user_profile = UserProfile.objects.get( - user=self.teacher_user - ) + self.teacher_user_profile = UserProfile.objects.get(user=self.teacher_user) self.indy_user = User.objects.get(email=indy_email) self.indy_user_profile = UserProfile.objects.get(user=self.indy_user) self.student_user: User = student.new_user + @patch("portal.views.cron.user.send_dotdigital_email") def send_verify_email_reminder( self, days: int, is_verified: bool, view_name: str, - send_email: Mock, assert_called: bool, + mock_send_dotdigital_email: Mock, ): - self.teacher_user.date_joined = timezone.now() - timedelta( - days=days, hours=12 - ) + self.teacher_user.date_joined = timezone.now() - timedelta(days=days, hours=12) self.teacher_user.save() - self.student_user.date_joined = timezone.now() - timedelta( - days=days, hours=12 - ) + self.student_user.date_joined = timezone.now() - timedelta(days=days, hours=12) self.student_user.save() - self.indy_user.date_joined = timezone.now() - timedelta( - days=days, hours=12 - ) + self.indy_user.date_joined = timezone.now() - timedelta(days=days, hours=12) self.indy_user.save() self.teacher_user_profile.is_verified = is_verified @@ -968,100 +911,34 @@ def send_verify_email_reminder( self.client.get(reverse(view_name)) if assert_called: - send_email.assert_any_call( - sender=NOTIFICATION_EMAIL, - recipients=[self.teacher_user.email], - subject=ANY, - title=ANY, - text_content=ANY, - replace_url=ANY, - ) + mock_send_dotdigital_email.assert_any_call(ANY, [self.teacher_user.email], personalization_values=ANY) - send_email.assert_any_call( - sender=NOTIFICATION_EMAIL, - recipients=[self.indy_user.email], - subject=ANY, - title=ANY, - text_content=ANY, - replace_url=ANY, - ) + mock_send_dotdigital_email.assert_any_call(ANY, [self.indy_user.email], personalization_values=ANY) # Check only two emails are sent - the student should never be included. - assert send_email.call_count == 2 + assert mock_send_dotdigital_email.call_count == 2 else: - send_email.assert_not_called() + mock_send_dotdigital_email.assert_not_called() - send_email.reset_mock() + mock_send_dotdigital_email.reset_mock() - @patch("portal.views.cron.user.send_email") - def test_first_verify_email_reminder_view(self, send_email: Mock): - self.send_verify_email_reminder( - days=6, - is_verified=False, - view_name="first-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) - self.send_verify_email_reminder( - days=7, - is_verified=False, - view_name="first-verify-email-reminder", - send_email=send_email, - assert_called=True, - ) - self.send_verify_email_reminder( - days=7, - is_verified=True, - view_name="first-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) - self.send_verify_email_reminder( - days=8, - is_verified=False, - view_name="first-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) + def test_first_verify_email_reminder_view(self): + self.send_verify_email_reminder(6, False, "first-verify-email-reminder", False) + self.send_verify_email_reminder(7, False, "first-verify-email-reminder", True) + self.send_verify_email_reminder(7, True, "first-verify-email-reminder", False) + self.send_verify_email_reminder(8, False, "first-verify-email-reminder", False) - @patch("portal.views.cron.user.send_email") - def test_second_verify_email_reminder_view(self, send_email: Mock): - self.send_verify_email_reminder( - days=13, - is_verified=False, - view_name="second-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) - self.send_verify_email_reminder( - days=14, - is_verified=False, - view_name="second-verify-email-reminder", - send_email=send_email, - assert_called=True, - ) - self.send_verify_email_reminder( - days=14, - is_verified=True, - view_name="second-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) - self.send_verify_email_reminder( - days=15, - is_verified=False, - view_name="second-verify-email-reminder", - send_email=send_email, - assert_called=False, - ) + def test_second_verify_email_reminder_view(self): + self.send_verify_email_reminder(13, False, "second-verify-email-reminder", False) + self.send_verify_email_reminder(14, False, "second-verify-email-reminder", True) + self.send_verify_email_reminder(14, True, "second-verify-email-reminder", False) + self.send_verify_email_reminder(15, False, "second-verify-email-reminder", False) def test_anonymise_unverified_accounts_view(self): now = timezone.now() for user in [self.teacher_user, self.indy_user, self.student_user]: - user.date_joined = now - timedelta( - days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS + 1 - ) + user.date_joined = now - timedelta(days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS + 1) user.save() for user_profile in [self.teacher_user_profile, self.indy_user_profile]: @@ -1126,9 +1003,7 @@ def anonymise_unverified_users( new_user=indy_user, ) - activity_today = DailyActivity.objects.get_or_create( - date=datetime.now().date() - )[0] + activity_today = DailyActivity.objects.get_or_create(date=datetime.now().date())[0] daily_teacher_count = activity_today.anonymised_unverified_teachers daily_indy_count = activity_today.anonymised_unverified_independents @@ -1151,30 +1026,16 @@ def anonymise_unverified_users( assert indy_user_active == assert_active assert student_user_active - activity_today = DailyActivity.objects.get_or_create( - date=datetime.now().date() - )[0] + activity_today = DailyActivity.objects.get_or_create(date=datetime.now().date())[0] total_activity = TotalActivity.objects.get(id=1) if not teacher_user_active: - assert ( - activity_today.anonymised_unverified_teachers - == daily_teacher_count + 1 - ) - assert ( - total_activity.anonymised_unverified_teachers - == total_teacher_count + 1 - ) + assert activity_today.anonymised_unverified_teachers == daily_teacher_count + 1 + assert total_activity.anonymised_unverified_teachers == total_teacher_count + 1 if not indy_user_active: - assert ( - activity_today.anonymised_unverified_independents - == daily_indy_count + 1 - ) - assert ( - total_activity.anonymised_unverified_independents - == total_indy_count + 1 - ) + assert activity_today.anonymised_unverified_independents == daily_indy_count + 1 + assert total_activity.anonymised_unverified_independents == total_indy_count + 1 teacher_user.delete() indy_user.delete() diff --git a/portal/views/cron/user.py b/portal/views/cron/user.py index 00bbb654d..ae676f9dc 100644 --- a/portal/views/cron/user.py +++ b/portal/views/cron/user.py @@ -1,11 +1,8 @@ import logging -from datetime import timedelta, datetime +from datetime import datetime, timedelta -from common.helpers.emails import ( - NOTIFICATION_EMAIL, - generate_token_for_email, - send_email, -) +from common.helpers.emails import generate_token_for_email +from common.mail import campaign_ids, send_dotdigital_email from common.models import DailyActivity, TotalActivity from django.contrib.auth.models import User from django.db.models import F @@ -14,29 +11,14 @@ from django.utils import timezone from rest_framework.response import Response from rest_framework.views import APIView + from portal.views.api import anonymise from ...mixins import CronMixin # TODO: move email templates to DotDigital. USER_1ST_VERIFY_EMAIL_REMINDER_DAYS = 7 -USER_1ST_VERIFY_EMAIL_REMINDER_TEXT = ( - "Please go to the link below to verify your email address:" - "\n{email_verification_url}." - "\nYou will not be able to use your account until it is verified." - "\n\nBy activating the account you confirm that you have read and agreed to" - " our terms ({terms_url}) and our privacy notice ({privacy_notice_url}). If" - " your account is not verified within 12 days we will delete it." -) USER_2ND_VERIFY_EMAIL_REMINDER_DAYS = 14 -USER_2ND_VERIFY_EMAIL_REMINDER_TEXT = ( - "Please go to the link below to verify your email address:" - "\n{email_verification_url}." - "\nYou will not be able to use your account until it is verified." - "\n\nBy activating the account you confirm that you have read and agreed to" - " our terms ({terms_url}) and our privacy notice ({privacy_notice_url}). If" - " your account is not verified within 5 days we will delete it." -) USER_DELETE_UNVERIFIED_ACCOUNT_DAYS = 19 @@ -87,9 +69,6 @@ def get(self, request): logging.info(f"{user_count} emails unverified.") if user_count > 0: - terms_url = build_absolute_google_uri(request, reverse("terms")) - privacy_notice_url = build_absolute_google_uri(request, reverse("privacy_notice")) - sent_email_count = 0 for email in user_queryset.values_list("email", flat=True).iterator(chunk_size=500): email_verification_url = build_absolute_google_uri( @@ -101,17 +80,10 @@ def get(self, request): ) try: - send_email( - sender=NOTIFICATION_EMAIL, - recipients=[email], - subject="Awaiting verification", - title="Awaiting verification", - text_content=USER_1ST_VERIFY_EMAIL_REMINDER_TEXT.format( - email_verification_url=email_verification_url, - terms_url=terms_url, - privacy_notice_url=privacy_notice_url, - ), - replace_url={"verify_url": email_verification_url}, + send_dotdigital_email( + campaign_ids["verify_new_user_first_reminder"], + [email], + personalization_values={"VERIFICATION_LINK": email_verification_url}, ) sent_email_count += 1 @@ -135,8 +107,6 @@ def get(self, request): logging.info(f"{user_count} emails unverified.") if user_count > 0: - terms_url = build_absolute_google_uri(request, reverse("terms")) - privacy_notice_url = build_absolute_google_uri(request, reverse("privacy_notice")) sent_email_count = 0 for email in user_queryset.values_list("email", flat=True).iterator(chunk_size=500): @@ -149,17 +119,10 @@ def get(self, request): ) try: - send_email( - sender=NOTIFICATION_EMAIL, - recipients=[email], - subject="Your account needs verification", - title="Your account needs verification", - text_content=USER_2ND_VERIFY_EMAIL_REMINDER_TEXT.format( - email_verification_url=email_verification_url, - terms_url=terms_url, - privacy_notice_url=privacy_notice_url, - ), - replace_url={"verify_url": email_verification_url}, + send_dotdigital_email( + campaign_ids["verify_new_user_second_reminder"], + [email], + personalization_values={"VERIFICATION_LINK": email_verification_url}, ) sent_email_count += 1 diff --git a/pyproject.toml b/pyproject.toml index db809df8f..5cea31143 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,4 +6,7 @@ build-backend = "setuptools.build_meta:__legacy__" line-length = 120 [tool.pytest.ini_options] -DJANGO_SETTINGS_MODULE = "example_project.settings" +DJANGO_SETTINGS_MODULE = "example_project.portal_test_settings" + +[tool.isort] +profile = "black" \ No newline at end of file