diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07cffc721..54aa2b480 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,8 +27,6 @@ jobs: run: | pip install pipenv pipenv install --dev --system - pip uninstall aimmo -y - pip install git+https://github.com/ocadotechnology/aimmo@max_games_limit#egg=aimmo yarn --frozen-lockfile - name: Build frontend run: | diff --git a/.gitignore b/.gitignore index 728fc6122..860f893ec 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,7 @@ example_project/static/ codeforlife_portal.egg-info *.egg-info/ build/ -.vscode/ +# .vscode/ dist/ node_modules diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 000000000..3b15f64cf --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,29 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Django Server", + "type": "python", + "request": "launch", + "django": true, + "justMyCode": false, + "program": "${workspaceFolder}/example_project/manage.py", + "args": [ + "runserver", + "localhost:8000" + ] + }, + { + "name": "Pytest", + "type": "python", + "request": "test", + "justMyCode": false, + "presentation": { + "hidden": true + } + } + ] +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..97db8c0f3 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.testing.unittestEnabled": false, + "python.testing.pytestEnabled": true +} \ No newline at end of file diff --git a/Pipfile.lock b/Pipfile.lock index 97ea471b7..3b53e4112 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -18,10 +18,10 @@ "default": { "aimmo": { "hashes": [ - "sha256:656e4d75e2ae1008b86f9b2ac4d4353e953fa955600db84665602f452ee8bd71", - "sha256:d7c379e0a5497cddb1c480748f61c175d94221079092bf9a06800d57dc7cc88e" + "sha256:b89f83586412320b147ea61b4277599732c10e7668fba5b2d0a383db6a173145", + "sha256:bd2841b24d7830096b7cc81bdf7548377d30602f1a1b3d9e9084a58b11557413" ], - "version": "==2.9.3" + "version": "==2.10.6" }, "asgiref": { "hashes": [ @@ -316,6 +316,7 @@ "hashes": [ "sha256:03a8f4f3430c3b3ff8d10a2a86028c660355ab637cee9333d63d66b56f09d52a", "sha256:0bf60faf0bc2468089bdc5edd10555bab6e85152191df713e2ab1fcc86382b5a", + "sha256:1087300cf9700bbf455b1b97e24db18f2f77b55302a68272c56209d5587c12d1", "sha256:18a7f18b82b52ee85322d7a7874e676f34ab319b9f8cce5de06067384aa8ff43", "sha256:18e98fb3de7dba1c0a852731c3070cf022d14f0d68b4c87a19cc1016f3bb8b33", "sha256:1a819eef4b0e0b96bb0d98d797bef17dc1b4a10e8d7446be32d1da33e095dbb8", @@ -341,6 +342,7 @@ "sha256:76ae285c8104046b3a7f06b42f29c7b73f77683df18c49ab5af7983994c2dd91", "sha256:7cafd1208fdbe93b67c7086876f061f660cfddc44f404279c1585bbf3cdc64c5", "sha256:7efde645ca1cc441d6dc4b48c0f7101e8d86b54c8530141b09fd31cef5149ec9", + "sha256:8512a0c38cfd4e66a858ddd1b17705587900dd760c6003998e9472b77b56d417", "sha256:88d9ab96491d38a5ab7c56dd7a3cc37d83336ecc564e4e8816dbed12e5aaefc8", "sha256:8eab883b3b2a38cc1e050819ef06a7e6344d4a990d24d45bc6f2cf959045a45b", "sha256:910841381caba4f744a44bf81bfd573c94e10b3045ee00de0cbf436fe50673a6", @@ -364,8 +366,10 @@ "sha256:c9c59a2120b55788e800d82dfa99b9e156ff8f2227f07c5e3012a45a399620b7", "sha256:cd021c754b162c0fb55ad5d6b9d960db667faad0fa2ff25bb6e1301b0b6e6a75", "sha256:d27ec7509b9c18b6d73f2f5ede2622441de812e7b1a80bbd446cb0633bd3d5ae", + "sha256:d4606a527e30548153be1a9f155f4e283d109ffba663a15856089fb55f933e47", "sha256:d5508f0b173e6aa47273bdc0a0b5ba055b59662ba7c7ee5119528f466585526b", "sha256:d75209eed723105f9596807495d58d10b3470fa6732dd6756595e89925ce2470", + "sha256:d967650d3f56af314b72df7089d96cda1083a7fc2da05b375d2bc48c82ab3f3c", "sha256:db1a39669102a1d8d12b57de2bb7e2ec9066a6f2b3da35ae511ff93b01b5d564", "sha256:dbfcfc0218093a19c252ca8eb9aee3d29cfdcb586df21049b9d777fd32c14fd9", "sha256:e0f72c9ddb8cd28532185f54cc1453f2c16fb417a08b53a855c4e6a418edd099", @@ -850,11 +854,11 @@ }, "websocket-client": { "hashes": [ - "sha256:c951af98631d24f8df89ab1019fc365f2227c0892f12fd150e935607c79dd0dd", - "sha256:f1f9f2ad5291f0225a49efad77abf9e700b6fef553900623060dad6e26503b9d" + "sha256:53e95c826bf800c4c465f50093a8c4ff091c7327023b10bfaff40cf1ef170eaa", + "sha256:ce54f419dfae71f4bdba69ebe65bf7f0a93fe71bc009ad3a010aacc3eebad537" ], - "markers": "python_version >= '3.7'", - "version": "==1.6.1" + "markers": "python_version >= '3.8'", + "version": "==1.6.2" }, "xlrd": { "hashes": [ @@ -1007,80 +1011,72 @@ }, "click": { "hashes": [ - "sha256:48ee849951919527a045bfe3bf7baa8a959c423134e1a5b98c05c20ba75a1cbd", - "sha256:fa244bb30b3b5ee2cae3da8f55c9e5e0c0e86093306301fb418eb9dc40fbded5" + "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28", + "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de" ], "markers": "python_version >= '3.7'", - "version": "==8.1.6" + "version": "==8.1.7" }, "coverage": { "extras": [ "toml" ], "hashes": [ - "sha256:06a9a2be0b5b576c3f18f1a241f0473575c4a26021b52b2a85263a00f034d51f", - "sha256:06fb182e69f33f6cd1d39a6c597294cff3143554b64b9825d1dc69d18cc2fff2", - "sha256:0a5f9e1dbd7fbe30196578ca36f3fba75376fb99888c395c5880b355e2875f8a", - "sha256:0e1f928eaf5469c11e886fe0885ad2bf1ec606434e79842a879277895a50942a", - "sha256:171717c7cb6b453aebac9a2ef603699da237f341b38eebfee9be75d27dc38e01", - "sha256:1e9d683426464e4a252bf70c3498756055016f99ddaec3774bf368e76bbe02b6", - "sha256:201e7389591af40950a6480bd9edfa8ed04346ff80002cec1a66cac4549c1ad7", - "sha256:245167dd26180ab4c91d5e1496a30be4cd721a5cf2abf52974f965f10f11419f", - "sha256:2aee274c46590717f38ae5e4650988d1af340fe06167546cc32fe2f58ed05b02", - "sha256:2e07b54284e381531c87f785f613b833569c14ecacdcb85d56b25c4622c16c3c", - "sha256:31563e97dae5598556600466ad9beea39fb04e0229e61c12eaa206e0aa202063", - "sha256:33d6d3ea29d5b3a1a632b3c4e4f4ecae24ef170b0b9ee493883f2df10039959a", - "sha256:3d376df58cc111dc8e21e3b6e24606b5bb5dee6024f46a5abca99124b2229ef5", - "sha256:419bfd2caae268623dd469eff96d510a920c90928b60f2073d79f8fe2bbc5959", - "sha256:48c19d2159d433ccc99e729ceae7d5293fbffa0bdb94952d3579983d1c8c9d97", - "sha256:49969a9f7ffa086d973d91cec8d2e31080436ef0fb4a359cae927e742abfaaa6", - "sha256:52edc1a60c0d34afa421c9c37078817b2e67a392cab17d97283b64c5833f427f", - "sha256:537891ae8ce59ef63d0123f7ac9e2ae0fc8b72c7ccbe5296fec45fd68967b6c9", - "sha256:54b896376ab563bd38453cecb813c295cf347cf5906e8b41d340b0321a5433e5", - "sha256:58c2ccc2f00ecb51253cbe5d8d7122a34590fac9646a960d1430d5b15321d95f", - "sha256:5b7540161790b2f28143191f5f8ec02fb132660ff175b7747b95dcb77ac26562", - "sha256:5baa06420f837184130752b7c5ea0808762083bf3487b5038d68b012e5937dbe", - "sha256:5e330fc79bd7207e46c7d7fd2bb4af2963f5f635703925543a70b99574b0fea9", - "sha256:61b9a528fb348373c433e8966535074b802c7a5d7f23c4f421e6c6e2f1697a6f", - "sha256:63426706118b7f5cf6bb6c895dc215d8a418d5952544042c8a2d9fe87fcf09cb", - "sha256:6d040ef7c9859bb11dfeb056ff5b3872436e3b5e401817d87a31e1750b9ae2fb", - "sha256:6f48351d66575f535669306aa7d6d6f71bc43372473b54a832222803eb956fd1", - "sha256:7ee7d9d4822c8acc74a5e26c50604dff824710bc8de424904c0982e25c39c6cb", - "sha256:81c13a1fc7468c40f13420732805a4c38a105d89848b7c10af65a90beff25250", - "sha256:8d13c64ee2d33eccf7437961b6ea7ad8673e2be040b4f7fd4fd4d4d28d9ccb1e", - "sha256:8de8bb0e5ad103888d65abef8bca41ab93721647590a3f740100cd65c3b00511", - "sha256:8fa03bce9bfbeeef9f3b160a8bed39a221d82308b4152b27d82d8daa7041fee5", - "sha256:924d94291ca674905fe9481f12294eb11f2d3d3fd1adb20314ba89e94f44ed59", - "sha256:975d70ab7e3c80a3fe86001d8751f6778905ec723f5b110aed1e450da9d4b7f2", - "sha256:976b9c42fb2a43ebf304fa7d4a310e5f16cc99992f33eced91ef6f908bd8f33d", - "sha256:9e31cb64d7de6b6f09702bb27c02d1904b3aebfca610c12772452c4e6c21a0d3", - "sha256:a342242fe22407f3c17f4b499276a02b01e80f861f1682ad1d95b04018e0c0d4", - "sha256:a3d33a6b3eae87ceaefa91ffdc130b5e8536182cd6dfdbfc1aa56b46ff8c86de", - "sha256:a895fcc7b15c3fc72beb43cdcbdf0ddb7d2ebc959edac9cef390b0d14f39f8a9", - "sha256:afb17f84d56068a7c29f5fa37bfd38d5aba69e3304af08ee94da8ed5b0865833", - "sha256:b1c546aca0ca4d028901d825015dc8e4d56aac4b541877690eb76490f1dc8ed0", - "sha256:b29019c76039dc3c0fd815c41392a044ce555d9bcdd38b0fb60fb4cd8e475ba9", - "sha256:b46517c02ccd08092f4fa99f24c3b83d8f92f739b4657b0f146246a0ca6a831d", - "sha256:b7aa5f8a41217360e600da646004f878250a0d6738bcdc11a0a39928d7dc2050", - "sha256:b7b4c971f05e6ae490fef852c218b0e79d4e52f79ef0c8475566584a8fb3e01d", - "sha256:ba90a9563ba44a72fda2e85302c3abc71c5589cea608ca16c22b9804262aaeb6", - "sha256:cb017fd1b2603ef59e374ba2063f593abe0fc45f2ad9abdde5b4d83bd922a353", - "sha256:d22656368f0e6189e24722214ed8d66b8022db19d182927b9a248a2a8a2f67eb", - "sha256:d2c2db7fd82e9b72937969bceac4d6ca89660db0a0967614ce2481e81a0b771e", - "sha256:d39b5b4f2a66ccae8b7263ac3c8170994b65266797fb96cbbfd3fb5b23921db8", - "sha256:d62a5c7dad11015c66fbb9d881bc4caa5b12f16292f857842d9d1871595f4495", - "sha256:e7d9405291c6928619403db1d10bd07888888ec1abcbd9748fdaa971d7d661b2", - "sha256:e84606b74eb7de6ff581a7915e2dab7a28a0517fbe1c9239eb227e1354064dcd", - "sha256:eb393e5ebc85245347950143969b241d08b52b88a3dc39479822e073a1a8eb27", - "sha256:ebba1cd308ef115925421d3e6a586e655ca5a77b5bf41e02eb0e4562a111f2d1", - "sha256:ee57190f24fba796e36bb6d3aa8a8783c643d8fa9760c89f7a98ab5455fbf818", - "sha256:f2f67fe12b22cd130d34d0ef79206061bfb5eda52feb6ce0dba0644e20a03cf4", - "sha256:f6951407391b639504e3b3be51b7ba5f3528adbf1a8ac3302b687ecababf929e", - "sha256:f75f7168ab25dd93110c8a8117a22450c19976afbc44234cbf71481094c1b850", - "sha256:fdec9e8cbf13a5bf63290fc6013d216a4c7232efb51548594ca3631a7f13c3a3" + "sha256:07ea61bcb179f8f05ffd804d2732b09d23a1238642bf7e51dad62082b5019b34", + "sha256:1084393c6bda8875c05e04fce5cfe1301a425f758eb012f010eab586f1f3905e", + "sha256:13c6cbbd5f31211d8fdb477f0f7b03438591bdd077054076eec362cf2207b4a7", + "sha256:211a4576e984f96d9fce61766ffaed0115d5dab1419e4f63d6992b480c2bd60b", + "sha256:2d22172f938455c156e9af2612650f26cceea47dc86ca048fa4e0b2d21646ad3", + "sha256:34f9f0763d5fa3035a315b69b428fe9c34d4fc2f615262d6be3d3bf3882fb985", + "sha256:3558e5b574d62f9c46b76120a5c7c16c4612dc2644c3d48a9f4064a705eaee95", + "sha256:36ce5d43a072a036f287029a55b5c6a0e9bd73db58961a273b6dc11a2c6eb9c2", + "sha256:37d5576d35fcb765fca05654f66aa71e2808d4237d026e64ac8b397ffa66a56a", + "sha256:3c9834d5e3df9d2aba0275c9f67989c590e05732439b3318fa37a725dff51e74", + "sha256:438856d3f8f1e27f8e79b5410ae56650732a0dcfa94e756df88c7e2d24851fcd", + "sha256:477c9430ad5d1b80b07f3c12f7120eef40bfbf849e9e7859e53b9c93b922d2af", + "sha256:49ab200acf891e3dde19e5aa4b0f35d12d8b4bd805dc0be8792270c71bd56c54", + "sha256:49dbb19cdcafc130f597d9e04a29d0a032ceedf729e41b181f51cd170e6ee865", + "sha256:4c8e31cf29b60859876474034a83f59a14381af50cbe8a9dbaadbf70adc4b214", + "sha256:4eddd3153d02204f22aef0825409091a91bf2a20bce06fe0f638f5c19a85de54", + "sha256:5247bab12f84a1d608213b96b8af0cbb30d090d705b6663ad794c2f2a5e5b9fe", + "sha256:5492a6ce3bdb15c6ad66cb68a0244854d9917478877a25671d70378bdc8562d0", + "sha256:56afbf41fa4a7b27f6635bc4289050ac3ab7951b8a821bca46f5b024500e6321", + "sha256:59777652e245bb1e300e620ce2bef0d341945842e4eb888c23a7f1d9e143c446", + "sha256:60f64e2007c9144375dd0f480a54d6070f00bb1a28f65c408370544091c9bc9e", + "sha256:63c5b8ecbc3b3d5eb3a9d873dec60afc0cd5ff9d9f1c75981d8c31cfe4df8527", + "sha256:68d8a0426b49c053013e631c0cdc09b952d857efa8f68121746b339912d27a12", + "sha256:74c160285f2dfe0acf0f72d425f3e970b21b6de04157fc65adc9fd07ee44177f", + "sha256:7a9baf8e230f9621f8e1d00c580394a0aa328fdac0df2b3f8384387c44083c0f", + "sha256:7df91fb24c2edaabec4e0eee512ff3bc6ec20eb8dccac2e77001c1fe516c0c84", + "sha256:7f297e0c1ae55300ff688568b04ff26b01c13dfbf4c9d2b7d0cb688ac60df479", + "sha256:80501d1b2270d7e8daf1b64b895745c3e234289e00d5f0e30923e706f110334e", + "sha256:85b7335c22455ec12444cec0d600533a238d6439d8d709d545158c1208483873", + "sha256:887665f00ea4e488501ba755a0e3c2cfd6278e846ada3185f42d391ef95e7e70", + "sha256:8f39c49faf5344af36042b293ce05c0d9004270d811c7080610b3e713251c9b0", + "sha256:90b6e2f0f66750c5a1178ffa9370dec6c508a8ca5265c42fbad3ccac210a7977", + "sha256:96d7d761aea65b291a98c84e1250cd57b5b51726821a6f2f8df65db89363be51", + "sha256:97af9554a799bd7c58c0179cc8dbf14aa7ab50e1fd5fa73f90b9b7215874ba28", + "sha256:97c44f4ee13bce914272589b6b41165bbb650e48fdb7bd5493a38bde8de730a1", + "sha256:a67e6bbe756ed458646e1ef2b0778591ed4d1fcd4b146fc3ba2feb1a7afd4254", + "sha256:ac0dec90e7de0087d3d95fa0533e1d2d722dcc008bc7b60e1143402a04c117c1", + "sha256:ad0f87826c4ebd3ef484502e79b39614e9c03a5d1510cfb623f4a4a051edc6fd", + "sha256:b3eb0c93e2ea6445b2173da48cb548364f8f65bf68f3d090404080d338e3a689", + "sha256:b543302a3707245d454fc49b8ecd2c2d5982b50eb63f3535244fd79a4be0c99d", + "sha256:b859128a093f135b556b4765658d5d2e758e1fae3e7cc2f8c10f26fe7005e543", + "sha256:bac329371d4c0d456e8d5f38a9b0816b446581b5f278474e416ea0c68c47dcd9", + "sha256:c02cfa6c36144ab334d556989406837336c1d05215a9bdf44c0bc1d1ac1cb637", + "sha256:c9737bc49a9255d78da085fa04f628a310c2332b187cd49b958b0e494c125071", + "sha256:ccc51713b5581e12f93ccb9c5e39e8b5d4b16776d584c0f5e9e4e63381356482", + "sha256:ce2ee86ca75f9f96072295c5ebb4ef2a43cecf2870b0ca5e7a1cbdd929cf67e1", + "sha256:d000a739f9feed900381605a12a61f7aaced6beae832719ae0d15058a1e81c1b", + "sha256:db76a1bcb51f02b2007adacbed4c88b6dee75342c37b05d1822815eed19edee5", + "sha256:e2ac9a1de294773b9fa77447ab7e529cf4fe3910f6a0832816e5f3d538cfea9a", + "sha256:e61260ec93f99f2c2d93d264b564ba912bec502f679793c56f678ba5251f0393", + "sha256:fac440c43e9b479d1241fe9d768645e7ccec3fb65dc3a5f6e90675e75c3f3e3a", + "sha256:fc0ed8d310afe013db1eedd37176d0839dc66c96bcfcce8f6607a73ffea2d6ba" ], - "markers": "python_version >= '3.7'", - "version": "==7.2.7" + "markers": "python_version >= '3.8'", + "version": "==7.3.0" }, "defusedxml": { "hashes": [ @@ -1140,11 +1136,11 @@ }, "exceptiongroup": { "hashes": [ - "sha256:12c3e887d6485d16943a309616de20ae5582633e0a2eda17f4e10fd61c1e8af5", - "sha256:e346e69d186172ca7cf029c8c1d16235aa0e04035e5750b4b95039e65204328f" + "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9", + "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3" ], "markers": "python_version < '3.11'", - "version": "==1.1.2" + "version": "==1.1.3" }, "execnet": { "hashes": [ @@ -1231,11 +1227,11 @@ }, "pluggy": { "hashes": [ - "sha256:c2fd55a7d7a3863cba1a013e4e2414658b1d07b6bc57b3919e0c63c9abb99849", - "sha256:d12f0c4b579b15f5e054301bb226ee85eeeba08ffec228092f8defbaa3a4c4b3" + "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12", + "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7" ], - "markers": "python_version >= '3.7'", - "version": "==1.2.0" + "markers": "python_version >= '3.8'", + "version": "==1.3.0" }, "pypdf2": { "hashes": [ diff --git a/cfl_common/common/helpers/emails.py b/cfl_common/common/helpers/emails.py index 80ace5df7..508cdadf7 100644 --- a/cfl_common/common/helpers/emails.py +++ b/cfl_common/common/helpers/emails.py @@ -76,9 +76,12 @@ def generate_token(user, new_email="", preverified=False): user.userprofile.is_verified = preverified user.userprofile.save() + return generate_token_for_email(user.email, new_email) + +def generate_token_for_email(email: str, new_email: str = ""): return jwt.encode( { - "email": user.email, + "email": email, "new_email": new_email, "email_verification_token": uuid4().hex[:30], "expires": (timezone.now() + datetime.timedelta(hours=1)).timestamp(), diff --git a/portal/mixins/__init__.py b/portal/mixins/__init__.py new file mode 100644 index 000000000..800655b46 --- /dev/null +++ b/portal/mixins/__init__.py @@ -0,0 +1 @@ +from .cron_mixin import CronMixin diff --git a/portal/mixins/cron_mixin.py b/portal/mixins/cron_mixin.py new file mode 100644 index 000000000..8201dae39 --- /dev/null +++ b/portal/mixins/cron_mixin.py @@ -0,0 +1,12 @@ +from rest_framework.request import Request +from rest_framework.response import Response + +from ..permissions import IsCronRequestFromGoogle + + +class CronMixin: + http_method_names = ["get"] + permission_classes = [IsCronRequestFromGoogle] + + def get(self, request: Request) -> Response: + raise NotImplementedError() diff --git a/portal/permissions/__init__.py b/portal/permissions/__init__.py new file mode 100644 index 000000000..cc21e16d1 --- /dev/null +++ b/portal/permissions/__init__.py @@ -0,0 +1 @@ +from .is_cron_request_from_google import IsCronRequestFromGoogle diff --git a/portal/permissions/is_cron_request_from_google.py b/portal/permissions/is_cron_request_from_google.py new file mode 100644 index 000000000..906d94506 --- /dev/null +++ b/portal/permissions/is_cron_request_from_google.py @@ -0,0 +1,14 @@ +from django.conf import settings +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import View + + +class IsCronRequestFromGoogle(BasePermission): + """ + Validate that requests to your cron URLs are coming from App Engine and not from another source. + https://cloud.google.com/appengine/docs/flexible/scheduling-jobs-with-cron-yaml#securing_urls_for_cron + """ + + def has_permission(self, request: Request, view: View): + return settings.DEBUG or request.META.get("HTTP_X_APPENGINE_CRON") == "true" diff --git a/portal/tests/test_views.py b/portal/tests/test_views.py index f93718da4..8065a94c5 100644 --- a/portal/tests/test_views.py +++ b/portal/tests/test_views.py @@ -2,11 +2,12 @@ import io import json from datetime import timedelta, date +from unittest.mock import patch, Mock, ANY import PyPDF2 import pytest from aimmo.models import Game -from common.models import Teacher, UserSession, Student, Class, DailyActivity, School +from common.models import Teacher, UserSession, Student, Class, DailyActivity, School, UserProfile 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 ( @@ -22,10 +23,11 @@ from game.models import Level from game.tests.utils.attempt import create_attempt from game.tests.utils.level import create_save_level +from rest_framework.test import APIClient, APITestCase -from portal.templatetags.app_tags import is_logged_in_as_admin_teacher - +from cfl_common.common.helpers.emails import NOTIFICATION_EMAIL from deploy import captcha +from portal.templatetags.app_tags import is_logged_in_as_admin_teacher from portal.views.api import anonymise from portal.views.teacher.teach import ( REMINDER_CARDS_PDF_ROWS, @@ -33,6 +35,7 @@ REMINDER_CARDS_PDF_WARNING_TEXT, count_student_details_click, ) +from portal.views.cron.user import USER_DELETE_UNVERIFIED_ACCOUNT_DAYS class TestTeacherViews(TestCase): @@ -706,3 +709,267 @@ def test_logged_in_as_admin_check(self): assert not is_logged_in_as_admin_teacher(teacher2.new_user) c.logout() + + +# CRON view tests + + +class CronTestClient(APIClient): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs, HTTP_X_APPENGINE_CRON="true") + + def generic( + self, + method, + path, + data="", + content_type="application/octet-stream", + 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}" + + return wsgi_response + + +class CronTestCase(APITestCase): + client_class = CronTestClient + + +class TestUser(CronTestCase): + # TODO: use fixtures + def setUp(self): + teacher_email, _ = signup_teacher_directly(preverified=False) + create_organisation_directly(teacher_email) + _, _, access_code = create_class_directly(teacher_email) + _, _, student = create_school_student_directly(access_code) + 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.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 + + def send_verify_email_reminder( + self, + days: int, + is_verified: bool, + view_name: str, + send_email: Mock, + assert_called: bool, + ): + 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.save() + self.indy_user.date_joined = timezone.now() - timedelta(days=days, hours=12) + self.indy_user.save() + + self.teacher_user_profile.is_verified = is_verified + self.teacher_user_profile.save() + self.indy_user_profile.is_verified = is_verified + self.indy_user_profile.save() + + 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, + ) + + send_email.assert_any_call( + sender=NOTIFICATION_EMAIL, + recipients=[self.indy_user.email], + subject=ANY, + title=ANY, + text_content=ANY, + ) + + # Check only two emails are sent - the student should never be included. + assert send_email.call_count == 2 + else: + send_email.assert_not_called() + + send_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, + ) + + @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_delete_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.save() + + for user_profile in [self.teacher_user_profile, self.indy_user_profile]: + user_profile.is_verified = True + user_profile.save() + + def delete_unverified_users( + days: int, + is_verified: bool, + assert_exists: bool, + ): + date_joined = now - timedelta(days=days, hours=12) + + # Create teacher. + teacher_user = User.objects.create( + first_name="Unverified", + last_name="Teacher", + username="unverified.teacher@codeforlife.com", + email="unverified.teacher@codeforlife.com", + date_joined=date_joined, + ) + teacher_user_profile = UserProfile.objects.create( + user=teacher_user, + is_verified=is_verified, + ) + Teacher.objects.create( + user=teacher_user_profile, + new_user=teacher_user, + school=self.teacher_user.new_teacher.school, + ) + + # Create dependent student. + student_user = User.objects.create( + first_name="Unverified", + last_name="DependentStudent", + username="UnverifiedDependentStudent", + date_joined=date_joined, + ) + student_user_profile = UserProfile.objects.create( + user=student_user, + ) + Student.objects.create( + user=student_user_profile, + new_user=student_user, + class_field=self.student_user.new_student.class_field, + ) + + # Create independent student. + indy_user = User.objects.create( + first_name="Unverified", + last_name="IndependentStudent", + username="unverified.independentstudent@codeforlife.com", + email="unverified.independentstudent@codeforlife.com", + date_joined=date_joined, + ) + indy_user_profile = UserProfile.objects.create( + user=indy_user, + is_verified=is_verified, + ) + Student.objects.create( + user=indy_user_profile, + new_user=indy_user, + ) + + self.client.get(reverse("delete-unverified-accounts")) + + # Assert the verified users and teach + assert User.objects.filter(id=self.teacher_user.id).exists() + assert User.objects.filter(id=self.student_user.id).exists() + assert User.objects.filter(id=self.indy_user.id).exists() + + teacher_user_exists = User.objects.filter(id=teacher_user.id).exists() + indy_user_exists = User.objects.filter(id=indy_user.id).exists() + student_user_exists = User.objects.filter(id=student_user.id).exists() + + assert teacher_user_exists == assert_exists + assert indy_user_exists == assert_exists + assert student_user_exists + + if teacher_user_exists: + teacher_user.delete() + if indy_user_exists: + indy_user.delete() + if student_user_exists: + student_user.delete() + + delete_unverified_users( + days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS - 1, + is_verified=False, + assert_exists=True, + ) + delete_unverified_users( + days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS, + is_verified=False, + assert_exists=False, + ) + delete_unverified_users( + days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS, + is_verified=True, + assert_exists=True, + ) + delete_unverified_users( + days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS + 1, + is_verified=False, + assert_exists=False, + ) diff --git a/portal/urls.py b/portal/urls.py index efa6b9e30..4d39eae01 100644 --- a/portal/urls.py +++ b/portal/urls.py @@ -1,6 +1,7 @@ from aimmo.urls import HOMEPAGE_REGEX from common.permissions import teacher_verified from django.conf.urls import include, url +from django.urls import path from django.http import HttpResponse from django.views.generic import RedirectView from django.views.generic.base import TemplateView @@ -88,6 +89,7 @@ teacher_download_csv, teacher_view_class, ) +from portal.views import cron from portal.views.two_factor.core import CustomSetupView from portal.views.two_factor.profile import CustomDisableView @@ -105,6 +107,35 @@ urlpatterns = [ + path( + "cron/", + include( + [ + path( + "user/", + include( + [ + path( + "unverified/send-first-reminder/", + cron.user.FirstVerifyEmailReminderView.as_view(), + name="first-verify-email-reminder", + ), + path( + "unverified/send-second-reminder/", + cron.user.SecondVerifyEmailReminderView.as_view(), + name="second-verify-email-reminder", + ), + path( + "unverified/delete/", + cron.user.DeleteUnverifiedAccounts.as_view(), + name="delete-unverified-accounts", + ), + ] + ), + ), + ] + ), + ), url(HOMEPAGE_REGEX, include("aimmo.urls")), url(r"^teach/kurono/dashboard/$", TeacherAimmoDashboard.as_view(), name="teacher_aimmo_dashboard"), url(r"^play/kurono/dashboard/$", StudentAimmoDashboard.as_view(), name="student_aimmo_dashboard"), @@ -115,7 +146,7 @@ AdminChangePasswordDoneView.as_view(), name="administration_password_change_done", ), - url(r"^mail/weekly", send_new_users_report, name="send_new_users_report"), + url(r"^mail/weekly/", send_new_users_report, name="send_new_users_report"), url(r"^users/inactive/", InactiveUsersView.as_view(), name="inactive_users"), url(r"^locked_out/$", TemplateView.as_view(template_name="portal/locked_out.html"), name="locked_out"), url(r"^", include((two_factor_patterns, "two_factor"), namespace="two_factor")), diff --git a/portal/views/cron/__init__.py b/portal/views/cron/__init__.py new file mode 100644 index 000000000..f4a2da081 --- /dev/null +++ b/portal/views/cron/__init__.py @@ -0,0 +1 @@ +from .user import * diff --git a/portal/views/cron/user.py b/portal/views/cron/user.py new file mode 100644 index 000000000..1921648eb --- /dev/null +++ b/portal/views/cron/user.py @@ -0,0 +1,159 @@ +import logging +from datetime import timedelta +from itertools import chain + +from common.models import Teacher, Student +from django.contrib.auth.models import User +from django.urls import reverse +from django.utils import timezone +from rest_framework.response import Response +from rest_framework.views import APIView + +from cfl_common.common.helpers.emails import ( + NOTIFICATION_EMAIL, + generate_token_for_email, + send_email, +) +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}." + "You 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 + + +def get_unverified_emails(days: int): + now = timezone.now() + + teacher_emails = Teacher.objects.filter( + user__is_verified=False, + new_user__date_joined__lte=now - timedelta(days=days), + new_user__date_joined__gt=now - timedelta(days=days + 1), + ).values_list("new_user__email", flat=True) + + student_emails = Student.objects.filter( + user__is_verified=False, + class_field=None, + new_user__date_joined__lte=now - timedelta(days=days), + new_user__date_joined__gt=now - timedelta(days=days + 1), + ).values_list("new_user__email", flat=True) + + return list(chain(teacher_emails, student_emails)) + + +class FirstVerifyEmailReminderView(CronMixin, APIView): + def get(self, request): + emails = get_unverified_emails(USER_1ST_VERIFY_EMAIL_REMINDER_DAYS) + + logging.info(f"{len(emails)} emails unverified.") + + if emails: + terms_url = request.build_absolute_uri(reverse("terms")) + privacy_notice_url = request.build_absolute_uri(reverse("privacy_notice")) + + sent_email_count = 0 + for email in emails: + 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=request.build_absolute_uri( + reverse( + "verify_email", + kwargs={"token": generate_token_for_email(email)}, + ) + ), + terms_url=terms_url, + privacy_notice_url=privacy_notice_url, + ), + ) + + sent_email_count += 1 + except Exception as ex: + logging.exception(ex) + + logging.info(f"Sent {sent_email_count}/{len(emails)} emails.") + + return Response() + + +class SecondVerifyEmailReminderView(CronMixin, APIView): + def get(self, request): + emails = get_unverified_emails(USER_2ND_VERIFY_EMAIL_REMINDER_DAYS) + + logging.info(f"{len(emails)} emails unverified.") + + if emails: + terms_url = request.build_absolute_uri(reverse("terms")) + privacy_notice_url = request.build_absolute_uri(reverse("privacy_notice")) + + sent_email_count = 0 + for email in emails: + 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=request.build_absolute_uri( + reverse( + "verify_email", + kwargs={"token": generate_token_for_email(email)}, + ) + ), + terms_url=terms_url, + privacy_notice_url=privacy_notice_url, + ), + ) + + sent_email_count += 1 + except Exception as ex: + logging.exception(ex) + + logging.info(f"Sent {sent_email_count}/{len(emails)} emails.") + + return Response() + + +class DeleteUnverifiedAccounts(CronMixin, APIView): + def get(self, request): + user_count = User.objects.count() + user_queryset = User.objects.filter( + date_joined__lte=timezone.now() - timedelta(days=USER_DELETE_UNVERIFIED_ACCOUNT_DAYS), + userprofile__is_verified=False, + ) + + # Delete teachers. + user_queryset.filter( + new_teacher__isnull=False, + new_student__isnull=True, + ).delete() + # Delete independent students. + user_queryset.filter( + new_teacher__isnull=True, + new_student__class_field__isnull=True, + ).delete() + + logging.info(f"{user_count - User.objects.count()} unverified users deleted.") + + return Response()