diff options
author | 2020-10-30 17:43:13 +0200 | |
---|---|---|
committer | 2020-10-30 17:43:13 +0200 | |
commit | 3fa3854eb192466ca59acb577bfe135b53c2da11 (patch) | |
tree | af5aa98d8d521f21f25c45528c89941ac23e77aa | |
parent | Cover fetching article GitHub information with tests (diff) | |
parent | Merge pull request #395 from ks129/resources-home (diff) |
Merge remote-tracking branch 'up/dewikification' into guides-app
39 files changed, 305 insertions, 2028 deletions
diff --git a/.mdlrc b/.mdlrc deleted file mode 100644 index 0c02cde4..00000000 --- a/.mdlrc +++ /dev/null @@ -1 +0,0 @@ -rules '~MD024' @@ -16,7 +16,6 @@ whitenoise = "~=5.0" requests = "~=2.21" pyyaml = "~=5.1" pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"} -django-allauth = "~=0.41" sentry-sdk = "~=0.14" gitpython = "~=3.1.7" markdown2 = "~=2.3.9" diff --git a/Pipfile.lock b/Pipfile.lock index 7b642e4d..18b0d582 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a564091c8be701ba148292d490babded217f2885c129c2dd829ece4134f1258b" + "sha256": "c0e53fd7b7c3d2fc62331078d4ba9301a7e848cd39ec8b41f0f7bc6d911a4d88" }, "pipfile-spec": 6, "requires": { @@ -18,10 +18,11 @@ "default": { "asgiref": { "hashes": [ - "sha256:7e51911ee147dd685c3c8b805c0ad0cb58d360987b56953878f8c06d2d1c6f1a", - "sha256:9fc6fb5d39b8af147ba40765234fa822b39818b12cc80b35ad9b0cef3a476aed" + "sha256:a5098bc870b80e7b872bff60bb363c7f2c2c89078759f6c47b53ff8c525a152e", + "sha256:cd88907ecaec59d78e4ac00ea665b03e571cb37e3a0e37b3702af1a9e86c365a" ], - "version": "==3.2.10" + "markers": "python_version >= '3.5'", + "version": "==3.3.0" }, "certifi": { "hashes": [ @@ -37,13 +38,6 @@ ], "version": "==3.0.4" }, - "defusedxml": { - "hashes": [ - "sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93", - "sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5" - ], - "version": "==0.6.0" - }, "django": { "hashes": [ "sha256:2d14be521c3ae24960e5e83d4575e156a8c479a75c935224b671b1c6e66eddaf", @@ -52,13 +46,6 @@ "index": "pypi", "version": "==3.0.10" }, - "django-allauth": { - "hashes": [ - "sha256:f17209410b7f87da0a84639fd79d3771b596a6d3fc1a8e48ce50dabc7f441d30" - ], - "index": "pypi", - "version": "==0.42.0" - }, "django-environ": { "hashes": [ "sha256:6c9d87660142608f63ec7d5ce5564c49b603ea8ff25da595fd6098f6dc82afde", @@ -111,21 +98,23 @@ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" ], + "markers": "python_version >= '3.4'", "version": "==4.0.5" }, "gitpython": { "hashes": [ - "sha256:138016d519bf4dd55b22c682c904ed2fd0235c3612b2f8f65ce218ff358deed8", - "sha256:a03f728b49ce9597a6655793207c6ab0da55519368ff5961e4a74ae475b9fa8e" + "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b", + "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8" ], "index": "pypi", - "version": "==3.1.9" + "version": "==3.1.11" }, "idna": { "hashes": [ "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.10" }, "libsass": { @@ -148,53 +137,47 @@ }, "markdown2": { "hashes": [ - "sha256:89526090907ae5ece66d783c434b35c29ee500c1986309e306ce2346273ada6a", - "sha256:e6b401ec80b75e76a6b3dbb2c8ade513156fa55fa6c30b9640a1abf6184a07c8" + "sha256:85956d8119fa6378156fef65545d66705a842819d2e1b50379a2b9d2aaa17cf0", + "sha256:fef148e5fd68d4532286c3e2943e9d2c076a8ad781b0a70a9d599a0ffe91652d" ], "index": "pypi", - "version": "==2.3.9" - }, - "oauthlib": { - "hashes": [ - "sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889", - "sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea" - ], - "version": "==3.1.0" + "version": "==2.3.10" }, "psycopg2-binary": { "hashes": [ - "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", - "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", + "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", - "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", + "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", + "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", - "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", - "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", - "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", - "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", - "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", - "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", - "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", - "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", + "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", + "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", + "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:89705f45ce07b2dfa806ee84439ec67c5d9a0ef20154e0e475e2b2ed392a5b83", + "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", + "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", - "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", - "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", - "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", - "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", - "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", - "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", - "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", + "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", + "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5", "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", + "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", - "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", - "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", - "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", - "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", - "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" + "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", + "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", + "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", + "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", + "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", + "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", + "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", + "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", + "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679" ], "index": "pypi", "version": "==2.8.6" @@ -207,13 +190,6 @@ "index": "pypi", "version": "==2.8.1" }, - "python3-openid": { - "hashes": [ - "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", - "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b" - ], - "version": "==3.2.0" - }, "pytz": { "hashes": [ "sha256:a494d53b6d39c3c6e44c3bec237336e14305e4f29bbf800b599253057fbb79ed", @@ -265,26 +241,20 @@ "index": "pypi", "version": "==2.24.0" }, - "requests-oauthlib": { - "hashes": [ - "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", - "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a" - ], - "version": "==1.3.0" - }, "sentry-sdk": { "hashes": [ - "sha256:1d91a0059d2d8bb980bec169578035c2f2d4b93cd8a4fb5b85c81904d33e221a", - "sha256:6222cf623e404c3e62b8e0e81c6db866ac2d12a663b7c1f7963350e3f397522a" + "sha256:0eea248408d36e8e7037c7b73827bea20b13a4375bf1719c406cae6fcbc094e3", + "sha256:5cf36eb6b1dc62d55f3c64289792cbaebc8ffa5a9da14474f49b46d20caa7fc8" ], "index": "pypi", - "version": "==0.18.0" + "version": "==0.19.1" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -292,21 +262,24 @@ "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.4" }, "sqlparse": { "hashes": [ - "sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e", - "sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548" + "sha256:017cde379adbd6a1f15a61873f43e8274179378e95ef3fede90b5aa64d304ed0", + "sha256:0f91fd2e829c44362cbcfab3e9ae12e22badaa8a29ad5ff599f9ec109f0454e8" ], - "version": "==0.3.1" + "markers": "python_version >= '3.5'", + "version": "==0.4.1" }, "urllib3": { "hashes": [ - "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", - "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" + "sha256:8d7eaa5a82a1cac232164990f04874c594c9453ec55eef02eab885aa02fc17a2", + "sha256:f5321fbe4bf3fefa0efd0bfe7fb14e90909eb62a48ccda331726b4319897dd5e" ], - "version": "==1.25.10" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", + "version": "==1.25.11" }, "whitenoise": { "hashes": [ @@ -330,6 +303,7 @@ "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.2.0" }, "bandit": { @@ -344,6 +318,7 @@ "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.2.0" }, "coverage": { @@ -482,22 +457,32 @@ "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" ], + "markers": "python_version >= '3.4'", "version": "==4.0.5" }, "gitpython": { "hashes": [ - "sha256:138016d519bf4dd55b22c682c904ed2fd0235c3612b2f8f65ce218ff358deed8", - "sha256:a03f728b49ce9597a6655793207c6ab0da55519368ff5961e4a74ae475b9fa8e" + "sha256:6eea89b655917b500437e9668e4a12eabdcf00229a0df1762aabd692ef9b746b", + "sha256:befa4d101f91bad1b632df4308ec64555db684c360bd7d2130b4807d49ce86b8" ], "index": "pypi", - "version": "==3.1.9" + "version": "==3.1.11" }, "identify": { "hashes": [ - "sha256:7c22c384a2c9b32c5cc891d13f923f6b2653aa83e2d75d8f79be240d6c86c4f4", - "sha256:da683bfb7669fa749fc7731f378229e2dbf29a1d1337cbde04106f02236eb29d" + "sha256:3139bf72d81dfd785b0a464e2776bd59bdc725b4cc10e6cf46b56a0db931c82e", + "sha256:969d844b7a85d32a5f9ac4e163df6e846d73c87c8b75847494ee8f4bd2186421" ], - "version": "==1.5.5" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.5.6" + }, + "importlib-metadata": { + "hashes": [ + "sha256:77a540690e24b0305878c37ffd421785a6f7e53c8b5720d211b211de8d0e95da", + "sha256:cefa1a2f919b866c5beb7c9f7b0ebb4061f30a8a9bf16d609b000e2dfaceb9c3" + ], + "markers": "python_version < '3.8'", + "version": "==2.0.0" }, "mccabe": { "hashes": [ @@ -516,10 +501,11 @@ }, "pbr": { "hashes": [ - "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea", - "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15" + "sha256:5fad80b613c402d5b7df7bd84812548b2a61e9977387a80a5fc5c396492b13c9", + "sha256:b236cde0ac9a6aedd5e3c34517b423cd4fd97ef723849da6b0d2231142d89c00" ], - "version": "==5.5.0" + "markers": "python_version >= '2.6'", + "version": "==5.5.1" }, "pep8-naming": { "hashes": [ @@ -531,17 +517,18 @@ }, "pre-commit": { "hashes": [ - "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", - "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" + "sha256:7eadaa7f4547a8a19b83230ce430ba81bbe4797bd41c8d7fb54b246164628d1f", + "sha256:8fb2037c404ef8c87125e72564f316cf2bc94fc9c1cb184b8352117de747e164" ], "index": "pypi", - "version": "==2.7.1" + "version": "==2.8.1" }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -549,6 +536,7 @@ "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], + "markers": "python_version >= '3.5'", "version": "==5.1.1" }, "pyflakes": { @@ -556,6 +544,7 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pyyaml": { @@ -580,6 +569,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "smmap": { @@ -587,6 +577,7 @@ "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==3.0.4" }, "snowballstemmer": { @@ -601,6 +592,7 @@ "sha256:5e1ab03eaae06ef6ce23859402de785f08d97780ed774948ef16c4652c41bc62", "sha256:f845868b3a3a77a2489d226568abe7328b5c2d4f6a011cc759dfa99144a521f0" ], + "markers": "python_version >= '3.6'", "version": "==3.2.2" }, "toml": { @@ -610,6 +602,42 @@ ], "version": "==0.10.1" }, + "typed-ast": { + "hashes": [ + "sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355", + "sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919", + "sha256:0d8110d78a5736e16e26213114a38ca35cb15b6515d535413b090bd50951556d", + "sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa", + "sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652", + "sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75", + "sha256:3742b32cf1c6ef124d57f95be609c473d7ec4c14d0090e5a5e05a15269fb4d0c", + "sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01", + "sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d", + "sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1", + "sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907", + "sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c", + "sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3", + "sha256:7e4c9d7658aaa1fc80018593abdf8598bf91325af6af5cce4ce7c73bc45ea53d", + "sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b", + "sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614", + "sha256:92c325624e304ebf0e025d1224b77dd4e6393f18aab8d829b5b7e04afe9b7a2c", + "sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb", + "sha256:b52ccf7cfe4ce2a1064b18594381bccf4179c2ecf7f513134ec2f993dd4ab395", + "sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b", + "sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41", + "sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6", + "sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34", + "sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe", + "sha256:d648b8e3bf2fe648745c8ffcee3db3ff903d0817a01a12dd6a6ea7a8f4889072", + "sha256:f208eb7aff048f6bea9586e61af041ddf7f9ade7caed625742af423f6bae3298", + "sha256:fac11badff8313e23717f3dada86a15389d0708275bddf766cca67a84ead3e91", + "sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4", + "sha256:fcf135e17cc74dbfbc05894ebca928ffeb23d9790b3167a674921db19082401f", + "sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7" + ], + "markers": "python_version < '3.8'", + "version": "==1.4.1" + }, "unittest-xml-reporting": { "hashes": [ "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", @@ -620,10 +648,19 @@ }, "virtualenv": { "hashes": [ - "sha256:35ecdeb58cfc2147bb0706f7cdef69a8f34f1b81b6d49568174e277932908b8f", - "sha256:a5e0d253fe138097c6559c906c528647254f437d1019af9d5a477b09bfa7300f" + "sha256:b0011228208944ce71052987437d3843e05690b2f23d1c7da4263fde104c97a2", + "sha256:b8d6110f493af256a40d65e29846c69340a947669eec8ce784fcf3dd3af28380" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.1.0" + }, + "zipp": { + "hashes": [ + "sha256:102c24ef8f171fd729d46599845e95c7ab894a4cf45f5de11a44cc7444fb1108", + "sha256:ed5eee1974372595f9e416cc7bbeeb12335201d8081ca8a0743c954d4446e5cb" ], - "version": "==20.0.33" + "markers": "python_version >= '3.6'", + "version": "==3.4.0" } } } diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py index c15c59c8..e69de29b 100644 --- a/pydis_site/__init__.py +++ b/pydis_site/__init__.py @@ -1,4 +0,0 @@ -# Empty list of validators for Allauth to ponder over. This is referred to in settings.py -# by a string because Allauth won't let us just give it a list _there_, we have to point -# at a list _somewhere else_ instead. -VALIDATORS = [] diff --git a/pydis_site/apps/home/__init__.py b/pydis_site/apps/home/__init__.py index ecfab449..e69de29b 100644 --- a/pydis_site/apps/home/__init__.py +++ b/pydis_site/apps/home/__init__.py @@ -1 +0,0 @@ -default_app_config = "pydis_site.apps.home.apps.HomeConfig" diff --git a/pydis_site/apps/home/apps.py b/pydis_site/apps/home/apps.py deleted file mode 100644 index 55a393a9..00000000 --- a/pydis_site/apps/home/apps.py +++ /dev/null @@ -1,38 +0,0 @@ -from typing import Any, Dict - -from django.apps import AppConfig - - -class HomeConfig(AppConfig): - """Django AppConfig for the home app.""" - - name = 'pydis_site.apps.home' - signal_listener = None - - def ready(self) -> None: - """Run when the app has been loaded and is ready to serve requests.""" - from pydis_site.apps.home.signals import AllauthSignalListener - - self.signal_listener = AllauthSignalListener() - self.patch_allauth() - - def patch_allauth(self) -> None: - """Monkey-patches Allauth classes so we never collect email addresses.""" - # Imported here because we can't import it before our apps are loaded up - from allauth.socialaccount.providers.base import Provider - - def extract_extra_data(_: Provider, data: Dict[str, Any]) -> Dict[str, Any]: - """ - Extracts extra data for a SocialAccount provided by Allauth. - - This is our version of this function that strips the email address from incoming extra - data. We do this so that we never have to store it. - - This is monkey-patched because most OAuth providers - or at least the ones we care - about - all use the function from the base Provider class. This means we don't have - to make a new Django app for each one we want to work with. - """ - data["email"] = "" - return data - - Provider.extract_extra_data = extract_extra_data diff --git a/pydis_site/apps/home/forms/account_deletion.py b/pydis_site/apps/home/forms/account_deletion.py deleted file mode 100644 index eec70bea..00000000 --- a/pydis_site/apps/home/forms/account_deletion.py +++ /dev/null @@ -1,10 +0,0 @@ -from django.forms import CharField, Form - - -class AccountDeletionForm(Form): - """Account deletion form, to collect username for confirmation of removal.""" - - username = CharField( - label="Username", - required=True - ) diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py deleted file mode 100644 index 8af48c15..00000000 --- a/pydis_site/apps/home/signals.py +++ /dev/null @@ -1,314 +0,0 @@ -from contextlib import suppress -from typing import List, Optional, Type - -from allauth.account.signals import user_logged_in -from allauth.socialaccount.models import SocialAccount, SocialLogin -from allauth.socialaccount.providers.base import Provider -from allauth.socialaccount.providers.discord.provider import DiscordProvider -from allauth.socialaccount.signals import ( - pre_social_login, social_account_added, social_account_removed, - social_account_updated) -from django.contrib.auth.models import Group, User as DjangoUser -from django.db.models.signals import post_delete, post_save, pre_save - -from pydis_site.apps.api.models import User as DiscordUser -from pydis_site.apps.staff.models import RoleMapping - - -class AllauthSignalListener: - """ - Listens to and processes events via the Django Signals system. - - Django Signals is basically an event dispatcher. It consists of Signals (which are the events) - and Receivers, which listen for and handle those events. Signals are triggered by Senders, - which are essentially just any class at all, and Receivers can filter the Signals they listen - for by choosing a Sender, if required. - - Signals themselves define a set of arguments that they will provide to Receivers when the - Signal is sent. They are always keyword arguments, and Django recommends that all Receiver - functions accept them as `**kwargs` (and will supposedly error if you don't do this), - supposedly because Signals can change in the future and your receivers should still work. - - Signals do provide a list of their arguments when they're initially constructed, but this - is purely for documentation purposes only and Django does not enforce it. - - The Django Signals docs are here: https://docs.djangoproject.com/en/2.2/topics/signals/ - """ - - def __init__(self): - post_save.connect(self.user_model_updated, sender=DiscordUser) - - post_delete.connect(self.mapping_model_deleted, sender=RoleMapping) - pre_save.connect(self.mapping_model_updated, sender=RoleMapping) - - pre_social_login.connect(self.social_account_updated) - social_account_added.connect(self.social_account_updated) - social_account_updated.connect(self.social_account_updated) - social_account_removed.connect(self.social_account_removed) - - user_logged_in.connect(self.user_logged_in) - - def user_logged_in(self, sender: Type[DjangoUser], **kwargs) -> None: - """ - Processes Allauth login signals to ensure a user has the correct perms. - - This method tries to find a Discord SocialAccount for a user - this should always - be the case, but the admin user likely won't have one, so we do check for it. - - After that, we try to find the user's stored Discord account details, provided by the - bot on the server. Finally, we pass the relevant information over to the - `_apply_groups()` method for final processing. - """ - user: DjangoUser = kwargs["user"] - - try: - account: SocialAccount = SocialAccount.objects.get( - user=user, provider=DiscordProvider.id - ) - except SocialAccount.DoesNotExist: - return # User's never linked a Discord account - - try: - discord_user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) - except DiscordUser.DoesNotExist: - return - - self._apply_groups(discord_user, account) - - def social_account_updated(self, sender: Type[SocialLogin], **kwargs) -> None: - """ - Processes Allauth social account update signals to ensure a user has the correct perms. - - In this case, a SocialLogin is provided that we can check against. We check that this - is a Discord login in order to ensure that future OAuth logins using other providers - don't break things. - - Like most of the other methods that handle signals, this method defers to the - `_apply_groups()` method for final processing. - """ - social_login: SocialLogin = kwargs["sociallogin"] - - account: SocialAccount = social_login.account - provider: Provider = account.get_provider() - - if not isinstance(provider, DiscordProvider): - return - - try: - user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) - except DiscordUser.DoesNotExist: - return - - self._apply_groups(user, account) - - def social_account_removed(self, sender: Type[SocialLogin], **kwargs) -> None: - """ - Processes Allauth social account reomval signals to ensure a user has the correct perms. - - In this case, a SocialAccount is provided that we can check against. If this is a - Discord OAuth being removed from the account, we want to ensure that the user loses - their permissions groups as well. - - While this isn't a realistic scenario to reach in our current setup, I've provided it - for the sake of covering any edge cases and ensuring that SocialAccounts can be removed - from Django users in the future if required. - - Like most of the other methods that handle signals, this method defers to the - `_apply_groups()` method for final processing. - """ - account: SocialAccount = kwargs["socialaccount"] - provider: Provider = account.get_provider() - - if not isinstance(provider, DiscordProvider): - return - - try: - user: DiscordUser = DiscordUser.objects.get(id=int(account.uid)) - except DiscordUser.DoesNotExist: - return - - self._apply_groups(user, account, deletion=True) - - def mapping_model_deleted(self, sender: Type[RoleMapping], **kwargs) -> None: - """ - Processes deletion signals from the RoleMapping model, removing perms from users. - - We need to do this to ensure that users aren't left with permissions groups that - they shouldn't have assigned to them when a RoleMapping is deleted from the database, - and to remove their staff status if they should no longer have it. - """ - instance: RoleMapping = kwargs["instance"] - - for user in instance.group.user_set.all(): - # Firstly, remove their related user group - user.groups.remove(instance.group) - - with suppress(SocialAccount.DoesNotExist, DiscordUser.DoesNotExist): - # If we get either exception, then the user could not have been assigned staff - # with our system in the first place. - - social_account = SocialAccount.objects.get(user=user, provider=DiscordProvider.id) - discord_user = DiscordUser.objects.get(id=int(social_account.uid)) - - mappings = RoleMapping.objects.filter(role__id__in=discord_user.roles).all() - is_staff = any(m.is_staff for m in mappings) - - if user.is_staff != is_staff: - user.is_staff = is_staff - user.save(update_fields=("is_staff", )) - - def mapping_model_updated(self, sender: Type[RoleMapping], **kwargs) -> None: - """ - Processes update signals from the RoleMapping model. - - This method is in charge of figuring out what changed when a RoleMapping is updated - (via the Django admin or otherwise). It operates based on what was changed, and can - handle changes to both the role and permissions group assigned to it. - """ - instance: RoleMapping = kwargs["instance"] - raw: bool = kwargs["raw"] - - if raw: - # Fixtures are being loaded, so don't touch anything - return - - old_instance: Optional[RoleMapping] = None - - if instance.id is not None: - # We don't try to catch DoesNotExist here because we can't test for it, - # it should never happen (unless we have a bad DB failure) but I'm still - # kind of antsy about not having the extra security here. - - old_instance = RoleMapping.objects.get(id=instance.id) - - if old_instance: - self.mapping_model_deleted(RoleMapping, instance=old_instance) - - accounts = SocialAccount.objects.filter( - uid__in=(u.id for u in DiscordUser.objects.filter(roles__contains=[instance.role.id])) - ) - - for account in accounts: - account.user.groups.add(instance.group) - - if instance.is_staff and not account.user.is_staff: - account.user.is_staff = instance.is_staff - account.user.save(update_fields=("is_staff", )) - else: - discord_user = DiscordUser.objects.get(id=int(account.uid)) - - mappings = RoleMapping.objects.filter( - role__id__in=discord_user.roles - ).exclude(id=instance.id).all() - is_staff = any(m.is_staff for m in mappings) - - if account.user.is_staff != is_staff: - account.user.is_staff = is_staff - account.user.save(update_fields=("is_staff",)) - - def user_model_updated(self, sender: Type[DiscordUser], **kwargs) -> None: - """ - Processes update signals from the Discord User model, assigning perms as required. - - When a user's roles are changed on the Discord server, this method will ensure that - the user has only the permissions groups that they should have based on the RoleMappings - that have been set up in the Django admin. - - Like some of the other signal handlers, this method ensures that a SocialAccount exists - for this Discord User, and defers to `_apply_groups()` to do the heavy lifting of - ensuring the permissions groups are correct. - """ - instance: DiscordUser = kwargs["instance"] - raw: bool = kwargs["raw"] - - # `update_fields` could be used for checking changes, but it's None here due to how the - # model is saved without using that argument - so we can't use it. - - if raw: - # Fixtures are being loaded, so don't touch anything - return - - try: - account: SocialAccount = SocialAccount.objects.get( - uid=str(instance.id), provider=DiscordProvider.id - ) - except SocialAccount.DoesNotExist: - return # User has never logged in with Discord on the site - - self._apply_groups(instance, account) - - def _apply_groups( - self, user: DiscordUser, account: SocialAccount, deletion: bool = False - ) -> None: - """ - Ensures that the correct permissions are set for a Django user based on the RoleMappings. - - This (private) method is designed to check a Discord User against a given SocialAccount, - and makes sure that the Django user associated with the SocialAccount has the correct - permissions groups. - - While it would be possible to get the Discord User object with just the SocialAccount - object, the current approach results in less queries. - - The `deletion` parameter is used to signify that the user's SocialAccount is about - to be removed, and so we should always remove all of their permissions groups. The - same thing will happen if the user is no longer actually on the Discord server, as - leaving the server does not currently remove their SocialAccount from the database. - """ - mappings = RoleMapping.objects.all() - - try: - current_groups: List[Group] = list(account.user.groups.all()) - except SocialAccount.user.RelatedObjectDoesNotExist: - return # There's no user account yet, this will be handled by another receiver - - # Ensure that the username on this account is correct - new_username = f"{user.name}#{user.discriminator}" - - if account.user.username != new_username: - account.user.username = new_username - account.user.first_name = new_username - - if not user.in_guild: - deletion = True - - if deletion: - # They've unlinked Discord or left the server, so we have to remove their groups - # and their staff status - - if current_groups: - # They do have groups, so let's remove them - account.user.groups.remove( - *(mapping.group for mapping in mappings) - ) - - if account.user.is_staff: - # They're marked as a staff user and they shouldn't be, so let's fix that - account.user.is_staff = False - else: - new_groups = [] - is_staff = False - - for role in user.roles: - try: - mapping = mappings.get(role__id=role) - except RoleMapping.DoesNotExist: - continue # No mapping exists - - new_groups.append(mapping.group) - - if mapping.is_staff: - is_staff = True - - account.user.groups.add( - *[group for group in new_groups if group not in current_groups] - ) - - account.user.groups.remove( - *[mapping.group for mapping in mappings if mapping.group not in new_groups] - ) - - if account.user.is_staff != is_staff: - account.user.is_staff = is_staff - - account.user.save() diff --git a/pydis_site/apps/home/tests/test_signal_listener.py b/pydis_site/apps/home/tests/test_signal_listener.py deleted file mode 100644 index d99d81a5..00000000 --- a/pydis_site/apps/home/tests/test_signal_listener.py +++ /dev/null @@ -1,458 +0,0 @@ -from unittest import mock - -from allauth.account.signals import user_logged_in -from allauth.socialaccount.models import SocialAccount, SocialLogin -from allauth.socialaccount.providers import registry -from allauth.socialaccount.providers.discord.provider import DiscordProvider -from allauth.socialaccount.providers.github.provider import GitHubProvider -from allauth.socialaccount.signals import ( - pre_social_login, social_account_added, social_account_removed, - social_account_updated) -from django.contrib.auth.models import Group, User as DjangoUser -from django.db.models.signals import post_save, pre_save -from django.test import TestCase - -from pydis_site.apps.api.models import Role, User as DiscordUser -from pydis_site.apps.home.signals import AllauthSignalListener -from pydis_site.apps.staff.models import RoleMapping - - -class SignalListenerTests(TestCase): - @classmethod - def setUpTestData(cls): - """ - Executed when testing begins in order to set up database fixtures required for testing. - - This sets up quite a lot of stuff, in order to try to cover every eventuality while - ensuring that everything works when every possible situation is in the database - at the same time. - - That does unfortunately mean that half of this file is just test fixtures, but I couldn't - think of a better way to do this. - """ - # This needs to be registered so we can test the role linking logic with a user that - # doesn't have a Discord account linked, but is logged in somehow with another account - # type anyway. The logic this is testing was designed so that the system would be - # robust enough to handle that case, but it's impossible to fully test (and therefore - # to have coverage of) those lines without an extra provider, and GH was the second - # provider it was built with in mind. - registry.register(GitHubProvider) - - cls.admin_role = Role.objects.create( - id=0, - name="admin", - colour=0, - permissions=0, - position=0 - ) - - cls.moderator_role = Role.objects.create( - id=1, - name="moderator", - colour=0, - permissions=0, - position=1 - ) - - cls.unmapped_role = Role.objects.create( - id=2, - name="unmapped", - colour=0, - permissions=0, - position=1 - ) - - cls.admin_group = Group.objects.create(name="admin") - cls.moderator_group = Group.objects.create(name="moderator") - - cls.admin_mapping = RoleMapping.objects.create( - role=cls.admin_role, - group=cls.admin_group, - is_staff=True - ) - - cls.moderator_mapping = RoleMapping.objects.create( - role=cls.moderator_role, - group=cls.moderator_group, - is_staff=False - ) - - cls.discord_user = DiscordUser.objects.create( - id=0, - name="user", - discriminator=0, - ) - - cls.discord_unmapped = DiscordUser.objects.create( - id=2, - name="unmapped", - discriminator=0, - ) - - cls.discord_unmapped.roles.append(cls.unmapped_role.id) - cls.discord_unmapped.save() - - cls.discord_not_in_guild = DiscordUser.objects.create( - id=3, - name="not-in-guild", - discriminator=0, - in_guild=False - ) - - cls.discord_admin = DiscordUser.objects.create( - id=1, - name="admin", - discriminator=0, - ) - - cls.discord_admin.roles = [cls.admin_role.id] - cls.discord_admin.save() - - cls.discord_moderator = DiscordUser.objects.create( - id=4, - name="admin", - discriminator=0, - ) - - cls.discord_moderator.roles = [cls.moderator_role.id] - cls.discord_moderator.save() - - cls.django_user_discordless = DjangoUser.objects.create(username="no-discord") - cls.django_user_never_joined = DjangoUser.objects.create(username="never-joined") - - cls.social_never_joined = SocialAccount.objects.create( - user=cls.django_user_never_joined, - provider=DiscordProvider.id, - uid=5 - ) - - cls.django_user = DjangoUser.objects.create(username="user") - - cls.social_user = SocialAccount.objects.create( - user=cls.django_user, - provider=DiscordProvider.id, - uid=cls.discord_user.id - ) - - cls.social_user_github = SocialAccount.objects.create( - user=cls.django_user, - provider=GitHubProvider.id, - uid=cls.discord_user.id - ) - - cls.social_unmapped = SocialAccount( - # We instantiate it and don't put it in the DB. This is (surprisingly) - # a realistic test case, so we need to check for it - - provider=DiscordProvider.id, - uid=5, - user_id=None # No relation exists at all - ) - - cls.django_admin = DjangoUser.objects.create( - username="admin", - is_staff=True, - is_superuser=True - ) - - cls.social_admin = SocialAccount.objects.create( - user=cls.django_admin, - provider=DiscordProvider.id, - uid=cls.discord_admin.id - ) - - cls.django_moderator = DjangoUser.objects.create( - username="moderator", - is_staff=False, - is_superuser=False - ) - - cls.social_moderator = SocialAccount.objects.create( - user=cls.django_moderator, - provider=DiscordProvider.id, - uid=cls.discord_moderator.id - ) - - def test_model_save(self): - """Test signal handling for when Discord user model objects are saved to DB.""" - mock_obj = mock.Mock() - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - post_save.send( - DiscordUser, - instance=self.discord_user, - raw=True, - created=None, # Not realistic, but we don't use it - using=None, # Again, we don't use it - update_fields=False # Always false during integration testing - ) - - mock_obj.assert_not_called() - - post_save.send( - DiscordUser, - instance=self.discord_user, - raw=False, - created=None, # Not realistic, but we don't use it - using=None, # Again, we don't use it - update_fields=False # Always false during integration testing - ) - - mock_obj.assert_called_with(self.discord_user, self.social_user) - - def test_pre_social_login(self): - """Test the pre-social-login Allauth signal handling.""" - mock_obj = mock.Mock() - - discord_login = SocialLogin(self.django_user, self.social_user) - github_login = SocialLogin(self.django_user, self.social_user_github) - unmapped_login = SocialLogin(self.django_user, self.social_unmapped) - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - # Don't attempt to apply groups if the user doesn't have a linked Discord account - pre_social_login.send(SocialLogin, sociallogin=github_login) - mock_obj.assert_not_called() - - # Don't attempt to apply groups if the user hasn't joined the Discord server - pre_social_login.send(SocialLogin, sociallogin=unmapped_login) - mock_obj.assert_not_called() - - # Attempt to apply groups if everything checks out - pre_social_login.send(SocialLogin, sociallogin=discord_login) - mock_obj.assert_called_with(self.discord_user, self.social_user) - - def test_social_added(self): - """Test the social-account-added Allauth signal handling.""" - mock_obj = mock.Mock() - - discord_login = SocialLogin(self.django_user, self.social_user) - github_login = SocialLogin(self.django_user, self.social_user_github) - unmapped_login = SocialLogin(self.django_user, self.social_unmapped) - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - # Don't attempt to apply groups if the user doesn't have a linked Discord account - social_account_added.send(SocialLogin, sociallogin=github_login) - mock_obj.assert_not_called() - - # Don't attempt to apply groups if the user hasn't joined the Discord server - social_account_added.send(SocialLogin, sociallogin=unmapped_login) - mock_obj.assert_not_called() - - # Attempt to apply groups if everything checks out - social_account_added.send(SocialLogin, sociallogin=discord_login) - mock_obj.assert_called_with(self.discord_user, self.social_user) - - def test_social_updated(self): - """Test the social-account-updated Allauth signal handling.""" - mock_obj = mock.Mock() - - discord_login = SocialLogin(self.django_user, self.social_user) - github_login = SocialLogin(self.django_user, self.social_user_github) - unmapped_login = SocialLogin(self.django_user, self.social_unmapped) - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - # Don't attempt to apply groups if the user doesn't have a linked Discord account - social_account_updated.send(SocialLogin, sociallogin=github_login) - mock_obj.assert_not_called() - - # Don't attempt to apply groups if the user hasn't joined the Discord server - social_account_updated.send(SocialLogin, sociallogin=unmapped_login) - mock_obj.assert_not_called() - - # Attempt to apply groups if everything checks out - social_account_updated.send(SocialLogin, sociallogin=discord_login) - mock_obj.assert_called_with(self.discord_user, self.social_user) - - def test_social_removed(self): - """Test the social-account-removed Allauth signal handling.""" - mock_obj = mock.Mock() - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - # Don't attempt to remove groups if the user doesn't have a linked Discord account - social_account_removed.send(SocialLogin, socialaccount=self.social_user_github) - mock_obj.assert_not_called() - - # Don't attempt to remove groups if the social account doesn't map to a Django user - social_account_removed.send(SocialLogin, socialaccount=self.social_unmapped) - mock_obj.assert_not_called() - - # Attempt to remove groups if everything checks out - social_account_removed.send(SocialLogin, socialaccount=self.social_user) - mock_obj.assert_called_with(self.discord_user, self.social_user, deletion=True) - - def test_logged_in(self): - """Test the user-logged-in Allauth signal handling.""" - mock_obj = mock.Mock() - - with mock.patch.object(AllauthSignalListener, "_apply_groups", mock_obj): - AllauthSignalListener() - - # Don't attempt to apply groups if the user doesn't have a linked Discord account - user_logged_in.send(DjangoUser, user=self.django_user_discordless) - mock_obj.assert_not_called() - - # Don't attempt to apply groups if the user hasn't joined the Discord server - user_logged_in.send(DjangoUser, user=self.django_user_never_joined) - mock_obj.assert_not_called() - - # Attempt to apply groups if everything checks out - user_logged_in.send(DjangoUser, user=self.django_user) - mock_obj.assert_called_with(self.discord_user, self.social_user) - - def test_apply_groups_admin(self): - """Test application of groups by role, relating to an admin user.""" - handler = AllauthSignalListener() - - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # Apply groups based on admin role being present on Discord - handler._apply_groups(self.discord_admin, self.social_admin) - self.assertTrue(self.admin_group in self.django_admin.groups.all()) - - # Remove groups based on the user apparently leaving the server - handler._apply_groups(self.discord_admin, self.social_admin, True) - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # Apply the admin role again - handler._apply_groups(self.discord_admin, self.social_admin) - - # Remove all of the roles from the user - self.discord_admin.roles.clear() - - # Remove groups based on the user no longer having the admin role on Discord - handler._apply_groups(self.discord_admin, self.social_admin) - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - self.discord_admin.roles.append(self.admin_role.id) - self.discord_admin.save() - - def test_apply_groups_moderator(self): - """Test application of groups by role, relating to a non-`is_staff` moderator user.""" - handler = AllauthSignalListener() - - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # Apply groups based on moderator role being present on Discord - handler._apply_groups(self.discord_moderator, self.social_moderator) - self.assertTrue(self.moderator_group in self.django_moderator.groups.all()) - - # Remove groups based on the user apparently leaving the server - handler._apply_groups(self.discord_moderator, self.social_moderator, True) - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # Apply the moderator role again - handler._apply_groups(self.discord_moderator, self.social_moderator) - - # Remove all of the roles from the user - self.discord_moderator.roles.clear() - - # Remove groups based on the user no longer having the moderator role on Discord - handler._apply_groups(self.discord_moderator, self.social_moderator) - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - self.discord_moderator.roles.append(self.moderator_role.id) - self.discord_moderator.save() - - def test_apply_groups_other(self): - """Test application of groups by role, relating to non-standard cases.""" - handler = AllauthSignalListener() - - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # No groups should be applied when there's no user account yet - handler._apply_groups(self.discord_unmapped, self.social_unmapped) - self.assertEqual(self.django_user_discordless.groups.all().count(), 0) - - # No groups should be applied when there are only unmapped roles to match - handler._apply_groups(self.discord_unmapped, self.social_user) - self.assertEqual(self.django_user.groups.all().count(), 0) - - # No groups should be applied when the user isn't in the guild - handler._apply_groups(self.discord_not_in_guild, self.social_user) - self.assertEqual(self.django_user.groups.all().count(), 0) - - def test_role_mapping_str(self): - """Test that role mappings stringify correctly.""" - self.assertEqual( - str(self.admin_mapping), - f"@{self.admin_role.name} -> {self.admin_group.name}" - ) - - def test_role_mapping_changes(self): - """Test that role mapping listeners work when changes are made.""" - # Set up (just for this test) - self.django_moderator.groups.add(self.moderator_group) - self.django_admin.groups.add(self.admin_group) - - self.assertEqual(self.django_moderator.groups.all().count(), 1) - self.assertEqual(self.django_admin.groups.all().count(), 1) - - # Test is_staff changes - self.admin_mapping.is_staff = False - self.admin_mapping.save() - - self.assertFalse(self.django_moderator.is_staff) - self.assertFalse(self.django_admin.is_staff) - - self.admin_mapping.is_staff = True - self.admin_mapping.save() - - self.django_admin.refresh_from_db(fields=("is_staff", )) - self.assertTrue(self.django_admin.is_staff) - - # Test mapping deletion - self.admin_mapping.delete() - - self.django_admin.refresh_from_db(fields=("is_staff",)) - self.assertEqual(self.django_admin.groups.all().count(), 0) - self.assertFalse(self.django_admin.is_staff) - - # Test mapping update - self.moderator_mapping.group = self.admin_group - self.moderator_mapping.save() - - self.assertEqual(self.django_moderator.groups.all().count(), 1) - self.assertTrue(self.admin_group in self.django_moderator.groups.all()) - - # Test mapping creation - new_mapping = RoleMapping.objects.create( - role=self.admin_role, - group=self.moderator_group, - is_staff=True - ) - - self.assertEqual(self.django_admin.groups.all().count(), 1) - self.assertTrue(self.moderator_group in self.django_admin.groups.all()) - - self.django_admin.refresh_from_db(fields=("is_staff",)) - self.assertTrue(self.django_admin.is_staff) - - new_mapping.delete() - - # Test mapping creation (without is_staff) - new_mapping = RoleMapping.objects.create( - role=self.admin_role, - group=self.moderator_group, - ) - - self.assertEqual(self.django_admin.groups.all().count(), 1) - self.assertTrue(self.moderator_group in self.django_admin.groups.all()) - - self.django_admin.refresh_from_db(fields=("is_staff",)) - self.assertFalse(self.django_admin.is_staff) - - # Test that nothing happens when fixtures are loaded - pre_save.send(RoleMapping, instance=new_mapping, raw=True) - - self.assertEqual(self.django_admin.groups.all().count(), 1) - self.assertTrue(self.moderator_group in self.django_admin.groups.all()) diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index 572317a7..bd1671b1 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,198 +1,5 @@ -from allauth.socialaccount.models import SocialAccount -from django.contrib.auth.models import User -from django.http import HttpResponseRedirect from django.test import TestCase -from django_hosts.resolvers import get_host, reverse, reverse_host - - -def check_redirect_url( - response: HttpResponseRedirect, reversed_url: str, strip_params=True -) -> bool: - """ - Check whether a given redirect response matches a specific reversed URL. - - Arguments: - * `response`: The HttpResponseRedirect returned by the test client - * `reversed_url`: The URL returned by `reverse()` - * `strip_params`: Whether to strip URL parameters (following a "?") from the URL given in the - `response` object - """ - host = get_host(None) - hostname = reverse_host(host) - - redirect_url = response.url - - if strip_params and "?" in redirect_url: - redirect_url = redirect_url.split("?", 1)[0] - - result = reversed_url == f"//{hostname}{redirect_url}" - return result - - -class TestAccountDeleteView(TestCase): - def setUp(self) -> None: - """Create an authorized Django user for testing purposes.""" - self.user = User.objects.create( - username="user#0000" - ) - - def test_redirect_when_logged_out(self): - """Test that the user is redirected to the homepage when not logged in.""" - url = reverse("account_delete") - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - def test_get_when_logged_in(self): - """Test that the view returns a HTTP 200 when the user is logged in.""" - url = reverse("account_delete") - - self.client.force_login(self.user) - resp = self.client.get(url) - self.client.logout() - - self.assertEqual(resp.status_code, 200) - - def test_post_invalid(self): - """Test that the user is redirected when the form is filled out incorrectly.""" - url = reverse("account_delete") - - self.client.force_login(self.user) - - resp = self.client.post(url, {}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, url)) - - resp = self.client.post(url, {"username": "user"}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, url)) - - self.client.logout() - - def test_post_valid(self): - """Test that the account is deleted when the form is filled out correctly..""" - url = reverse("account_delete") - - self.client.force_login(self.user) - - resp = self.client.post(url, {"username": "user#0000"}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - with self.assertRaises(User.DoesNotExist): - User.objects.get(username=self.user.username) - - self.client.logout() - - -class TestAccountSettingsView(TestCase): - def setUp(self) -> None: - """Create an authorized Django user for testing purposes.""" - self.user = User.objects.create( - username="user#0000" - ) - - self.user_unlinked = User.objects.create( - username="user#9999" - ) - - self.user_unlinked_discord = User.objects.create( - username="user#1234" - ) - - self.user_unlinked_github = User.objects.create( - username="user#1111" - ) - - self.github_account = SocialAccount.objects.create( - user=self.user, - provider="github", - uid="0" - ) - - self.discord_account = SocialAccount.objects.create( - user=self.user, - provider="discord", - uid="0000" - ) - - self.github_account_secondary = SocialAccount.objects.create( - user=self.user_unlinked_discord, - provider="github", - uid="1" - ) - - self.discord_account_secondary = SocialAccount.objects.create( - user=self.user_unlinked_github, - provider="discord", - uid="1111" - ) - - def test_redirect_when_logged_out(self): - """Check that the user is redirected to the homepage when not logged in.""" - url = reverse("account_settings") - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - def test_get_when_logged_in(self): - """Test that the view returns a HTTP 200 when the user is logged in.""" - url = reverse("account_settings") - - self.client.force_login(self.user) - resp = self.client.get(url) - self.client.logout() - - self.assertEqual(resp.status_code, 200) - - self.client.force_login(self.user_unlinked) - resp = self.client.get(url) - self.client.logout() - - self.assertEqual(resp.status_code, 200) - - self.client.force_login(self.user_unlinked_discord) - resp = self.client.get(url) - self.client.logout() - - self.assertEqual(resp.status_code, 200) - - self.client.force_login(self.user_unlinked_github) - resp = self.client.get(url) - self.client.logout() - - self.assertEqual(resp.status_code, 200) - - def test_post_invalid(self): - """Test the behaviour of invalid POST submissions.""" - url = reverse("account_settings") - - self.client.force_login(self.user_unlinked) - - resp = self.client.post(url, {"provider": "discord"}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - resp = self.client.post(url, {"provider": "github"}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - self.client.logout() - - def test_post_valid(self): - """Ensure that GitHub is unlinked with a valid POST submission.""" - url = reverse("account_settings") - - self.client.force_login(self.user) - - resp = self.client.post(url, {"provider": "github"}) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - with self.assertRaises(SocialAccount.DoesNotExist): - SocialAccount.objects.get(user=self.user, provider="github") - - self.client.logout() +from django_hosts.resolvers import reverse class TestIndexReturns200(TestCase): @@ -201,21 +8,3 @@ class TestIndexReturns200(TestCase): url = reverse('home') resp = self.client.get(url) self.assertEqual(resp.status_code, 200) - - -class TestLoginCancelledReturns302(TestCase): - def test_login_cancelled_returns_302(self): - """Check that the login cancelled redirect returns a HTTP 302 response.""" - url = reverse('socialaccount_login_cancelled') - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) - - -class TestLoginErrorReturns302(TestCase): - def test_login_error_returns_302(self): - """Check that the login error redirect returns a HTTP 302 response.""" - url = reverse('socialaccount_login_error') - resp = self.client.get(url) - self.assertEqual(resp.status_code, 302) - self.assertTrue(check_redirect_url(resp, reverse("home"))) diff --git a/pydis_site/apps/home/urls.py b/pydis_site/apps/home/urls.py index 51093f50..80a3c587 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,37 +1,12 @@ -from allauth.account.views import LogoutView from django.contrib import admin -from django.contrib.messages import ERROR -from django.urls import include, path +from django.urls import path -from pydis_site.utils.views import MessageRedirectView -from .views import AccountDeleteView, AccountSettingsView, HomeView +from .views import HomeView app_name = 'home' urlpatterns = [ - # We do this twice because Allauth expects specific view names to exist path('', HomeView.as_view(), name='home'), - path('', HomeView.as_view(), name='socialaccount_connections'), - - path('accounts/', include('allauth.socialaccount.providers.discord.urls')), - path('accounts/', include('allauth.socialaccount.providers.github.urls')), - - path( - 'accounts/login/cancelled', MessageRedirectView.as_view( - pattern_name="home", message="Login cancelled." - ), name='socialaccount_login_cancelled' - ), - path( - 'accounts/login/error', MessageRedirectView.as_view( - pattern_name="home", message="Login encountered an unknown error, please try again.", - message_level=ERROR - ), name='socialaccount_login_error' - ), - - path('accounts/settings', AccountSettingsView.as_view(), name="account_settings"), - path('accounts/delete', AccountDeleteView.as_view(), name="account_delete"), - - path('logout', LogoutView.as_view(), name="logout"), - path('admin/', admin.site.urls), + path('resources/', include('pydis_site.apps.resources.urls')), path('articles/', include('pydis_site.apps.content.urls', namespace='articles')), ] diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py index 801fd398..971d73a3 100644 --- a/pydis_site/apps/home/views/__init__.py +++ b/pydis_site/apps/home/views/__init__.py @@ -1,4 +1,3 @@ -from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView from .home import HomeView -__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"] +__all__ = ["HomeView"] diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py deleted file mode 100644 index 3b3250ea..00000000 --- a/pydis_site/apps/home/views/account/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .delete import DeleteView -from .settings import SettingsView - -__all__ = ["DeleteView", "SettingsView"] diff --git a/pydis_site/apps/home/views/account/delete.py b/pydis_site/apps/home/views/account/delete.py deleted file mode 100644 index 798b8a33..00000000 --- a/pydis_site/apps/home/views/account/delete.py +++ /dev/null @@ -1,37 +0,0 @@ -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.messages import ERROR, INFO, add_message -from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views import View - -from pydis_site.apps.home.forms.account_deletion import AccountDeletionForm - - -class DeleteView(LoginRequiredMixin, View): - """Account deletion view, for removing linked user accounts from the DB.""" - - def __init__(self, *args, **kwargs): - self.login_url = reverse("home") - super().__init__(*args, **kwargs) - - def get(self, request: HttpRequest) -> HttpResponse: - """HTTP GET: Return the view template.""" - return render( - request, "home/account/delete.html", - context={"form": AccountDeletionForm()} - ) - - def post(self, request: HttpRequest) -> HttpResponse: - """HTTP POST: Process the deletion, as requested by the user.""" - form = AccountDeletionForm(request.POST) - - if not form.is_valid() or request.user.username != form.cleaned_data["username"]: - add_message(request, ERROR, "Please enter your username exactly as shown.") - - return redirect(reverse("account_delete")) - - request.user.delete() - add_message(request, INFO, "Your account has been deleted.") - - return redirect(reverse("home")) diff --git a/pydis_site/apps/home/views/account/settings.py b/pydis_site/apps/home/views/account/settings.py deleted file mode 100644 index 3a817dbc..00000000 --- a/pydis_site/apps/home/views/account/settings.py +++ /dev/null @@ -1,59 +0,0 @@ -from allauth.socialaccount.models import SocialAccount -from allauth.socialaccount.providers import registry -from django.contrib.auth.mixins import LoginRequiredMixin -from django.contrib.messages import ERROR, INFO, add_message -from django.http import HttpRequest, HttpResponse -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views import View - - -class SettingsView(LoginRequiredMixin, View): - """ - Account settings view, for managing and deleting user accounts and connections. - - This view actually renders a template with a bare modal, and is intended to be - inserted into another template using JavaScript. - """ - - def __init__(self, *args, **kwargs): - self.login_url = reverse("home") - super().__init__(*args, **kwargs) - - def get(self, request: HttpRequest) -> HttpResponse: - """HTTP GET: Return the view template.""" - context = { - "groups": request.user.groups.all(), - - "discord": None, - "github": None, - - "discord_provider": registry.provider_map.get("discord"), - "github_provider": registry.provider_map.get("github"), - } - - for account in SocialAccount.objects.filter(user=request.user).all(): - if account.provider == "discord": - context["discord"] = account - - if account.provider == "github": - context["github"] = account - - return render(request, "home/account/settings.html", context=context) - - def post(self, request: HttpRequest) -> HttpResponse: - """HTTP POST: Process account disconnections.""" - provider = request.POST["provider"] - - if provider == "github": - try: - account = SocialAccount.objects.get(user=request.user, provider=provider) - except SocialAccount.DoesNotExist: - add_message(request, ERROR, "You do not have a GitHub account linked.") - else: - account.delete() - add_message(request, INFO, "The social account has been disconnected.") - else: - add_message(request, ERROR, f"Unknown provider: {provider}") - - return redirect(reverse("home")) diff --git a/pydis_site/apps/home/forms/__init__.py b/pydis_site/apps/resources/__init__.py index e69de29b..e69de29b 100644 --- a/pydis_site/apps/home/forms/__init__.py +++ b/pydis_site/apps/resources/__init__.py diff --git a/pydis_site/apps/resources/apps.py b/pydis_site/apps/resources/apps.py new file mode 100644 index 00000000..e0c235bd --- /dev/null +++ b/pydis_site/apps/resources/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ResourcesConfig(AppConfig): + """AppConfig instance for Resources app.""" + + name = 'resources' diff --git a/pydis_site/tests/__init__.py b/pydis_site/apps/resources/migrations/__init__.py index e69de29b..e69de29b 100644 --- a/pydis_site/tests/__init__.py +++ b/pydis_site/apps/resources/migrations/__init__.py diff --git a/pydis_site/apps/resources/tests/__init__.py b/pydis_site/apps/resources/tests/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/resources/tests/__init__.py diff --git a/pydis_site/apps/resources/tests/test_views.py b/pydis_site/apps/resources/tests/test_views.py new file mode 100644 index 00000000..497e9bfe --- /dev/null +++ b/pydis_site/apps/resources/tests/test_views.py @@ -0,0 +1,10 @@ +from django.test import TestCase +from django_hosts import reverse + + +class TestResourcesView(TestCase): + def test_resources_index_200(self): + """Check does index of resources app return 200 HTTP response.""" + url = reverse("resources:index") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) diff --git a/pydis_site/apps/resources/urls.py b/pydis_site/apps/resources/urls.py new file mode 100644 index 00000000..c91e306e --- /dev/null +++ b/pydis_site/apps/resources/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from pydis_site.apps.resources import views + +app_name = "resources" +urlpatterns = [ + path("", views.ResourcesView.as_view(), name="index"), +] diff --git a/pydis_site/apps/resources/views/__init__.py b/pydis_site/apps/resources/views/__init__.py new file mode 100644 index 00000000..f54118f2 --- /dev/null +++ b/pydis_site/apps/resources/views/__init__.py @@ -0,0 +1,3 @@ +from .resources import ResourcesView + +__all__ = ["ResourcesView"] diff --git a/pydis_site/apps/resources/views/resources.py b/pydis_site/apps/resources/views/resources.py new file mode 100644 index 00000000..e770954b --- /dev/null +++ b/pydis_site/apps/resources/views/resources.py @@ -0,0 +1,7 @@ +from django.views.generic import TemplateView + + +class ResourcesView(TemplateView): + """View for resources index page.""" + + template_name = "resources/resources.html" diff --git a/pydis_site/apps/staff/admin.py b/pydis_site/apps/staff/admin.py deleted file mode 100644 index 94cd83c5..00000000 --- a/pydis_site/apps/staff/admin.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.contrib import admin - -from .models import RoleMapping - - -admin.site.register(RoleMapping) diff --git a/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py b/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py new file mode 100644 index 00000000..e9b6114e --- /dev/null +++ b/pydis_site/apps/staff/migrations/0003_delete_rolemapping.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.9 on 2020-10-04 17:49 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('staff', '0002_add_is_staff_to_role_mappings'), + ] + + operations = [ + migrations.DeleteModel( + name='RoleMapping', + ), + ] diff --git a/pydis_site/apps/staff/models/__init__.py b/pydis_site/apps/staff/models/__init__.py deleted file mode 100644 index b49b6fd0..00000000 --- a/pydis_site/apps/staff/models/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .role_mapping import RoleMapping - -__all__ = ["RoleMapping"] diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py deleted file mode 100644 index 8a1fac2e..00000000 --- a/pydis_site/apps/staff/models/role_mapping.py +++ /dev/null @@ -1,31 +0,0 @@ -from django.contrib.auth.models import Group -from django.db import models - -from pydis_site.apps.api.models import Role - - -class RoleMapping(models.Model): - """A mapping between a Discord role and Django permissions group.""" - - role = models.OneToOneField( - Role, - on_delete=models.CASCADE, - help_text="The Discord role to use for this mapping.", - unique=True, # Unique in order to simplify group assignment logic - ) - - group = models.OneToOneField( - Group, - on_delete=models.CASCADE, - help_text="The Django permissions group to use for this mapping.", - unique=True, # Unique in order to simplify group assignment logic - ) - - is_staff = models.BooleanField( - help_text="Whether this role mapping relates to a Django staff group", - default=False - ) - - def __str__(self): - """Returns the mapping, for display purposes.""" - return f"@{self.role.name} -> {self.group.name}" diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 5d8353fe..c5937bb6 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -16,7 +16,6 @@ import sys import environ import sentry_sdk -from django.contrib.messages import constants as messages from sentry_sdk.integrations.django import DjangoIntegration from pydis_site.constants import GIT_SHA @@ -87,6 +86,7 @@ INSTALLED_APPS = [ 'pydis_site.apps.api', 'pydis_site.apps.home', 'pydis_site.apps.staff', + 'pydis_site.apps.resources', 'pydis_site.apps.content', 'django.contrib.admin', @@ -97,13 +97,6 @@ INSTALLED_APPS = [ 'django.contrib.sites', 'django.contrib.staticfiles', - 'allauth', - 'allauth.account', - 'allauth.socialaccount', - - 'allauth.socialaccount.providers.discord', - 'allauth.socialaccount.providers.github', - 'django_hosts', 'django_filters', 'django_simple_bulma', @@ -268,22 +261,10 @@ LOGGING = { } } -# Django Messages framework config -MESSAGE_TAGS = { - messages.DEBUG: 'primary', - messages.INFO: 'info', - messages.SUCCESS: 'success', - messages.WARNING: 'warning', - messages.ERROR: 'danger', -} - # Custom settings for django-simple-bulma BULMA_SETTINGS = { "variables": { # If you update these colours, please update the notification.css file "primary": "#7289DA", # Discord blurple - - # "orange": "", # Apparently unused, but the default is fine - # "yellow": "", # The default yellow looks pretty good "green": "#32ac66", # Colour picked after Discord discussion "turquoise": "#7289DA", # Blurple, because Bulma uses this regardless of `primary` above "blue": "#2482c1", # Colour picked after Discord discussion @@ -299,26 +280,6 @@ BULMA_SETTINGS = { } } -# Django Allauth stuff -AUTHENTICATION_BACKENDS = ( - # Needed to login by username in Django admin, regardless of `allauth` - 'django.contrib.auth.backends.ModelBackend', - - # `allauth` specific authentication methods, such as login by e-mail - 'allauth.account.auth_backends.AuthenticationBackend', -) - -ACCOUNT_ADAPTER = "pydis_site.utils.account.AccountAdapter" -ACCOUNT_EMAIL_REQUIRED = False # Undocumented allauth setting; don't require emails -ACCOUNT_EMAIL_VERIFICATION = "none" # No verification required; we don't use emails for anything - -# We use this validator because Allauth won't let us actually supply a list with no validators -# in it, and we can't just give it a lambda - that'd be too easy, I suppose. -ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS" - -LOGIN_REDIRECT_URL = "home" -SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter" - # Information about site repository SITE_REPOSITORY_OWNER = env("SITE_REPOSITORY_OWNER") SITE_REPOSITORY_NAME = env("SITE_REPOSITORY_NAME") diff --git a/pydis_site/static/css/base/notification.css b/pydis_site/static/css/base/notification.css deleted file mode 100644 index b2824641..00000000 --- a/pydis_site/static/css/base/notification.css +++ /dev/null @@ -1,99 +0,0 @@ -/* On-page message styling */ - -@keyframes message-slide-in { - 0% { - transform: translateX(100%); - } - - 100% { - transform: translateX(0); - } -} - -div.messages { - animation: 0.5s ease-out 0s 1 message-slide-in; - padding: 0.5rem; - position: fixed; - right: 0; - top: 76px; - - z-index: 1000; /* On top of everything else */ -} - -/* Discord light theme inspired notifications */ - -.messages .notification { - background-color: #fdfdfd; /* Discord embed background */ - border: #eeeeee 1px solid; /* Discord embed border */ - border-left: #4f545c 4px solid; /* Discord default embed colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-primary { - background-color: #fdfdfd; - border-left-color: #7289DA; - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-info { - background-color: #fdfdfd; - border-left-color: #1c8ad3; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-success { - background-color: #fdfdfd; - border-left-color: #21c65c; - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-warning { - background-color: #fdfdfd; - border-left-color: #ffdd57; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -.messages .notification.is-danger { - background-color: #fdfdfd; - border-left-color: #ff3860; /* Bulma default colour */ - color: #4a4a4a; /* Bulma default colour */ -} - -/* Discord dark theme inspired notifications */ - -.messages .notification.is-dark { - background-color: #33353C; /* Discord embed background */ - border: #36393f 1px solid; /* Discord embed border */ - border-left: #4f545c 4px solid; /* Discord default embed colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-primary { - background-color: #33353C; - border-left-color: #7289DA; - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-info { - background-color: #33353C; - border-left-color: #1c8ad3; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-success { - background-color: #33353C; - border-left-color: #21c65c; - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-warning { - background-color: #33353C; - border-left-color: #ffdd57; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} - -.messages .notification.is-dark.is-danger { - background-color: #33353C; - border-left-color: #ff3860; /* Bulma default colour */ - color: #fff; /* Bulma default colour */ -} diff --git a/pydis_site/static/css/resources/resources.css b/pydis_site/static/css/resources/resources.css new file mode 100644 index 00000000..cf4cb472 --- /dev/null +++ b/pydis_site/static/css/resources/resources.css @@ -0,0 +1,29 @@ +.box, .tile.is-parent { + transition: 0.1s ease-out; +} +.box { + min-height: 15vh; +} +.tile.is-parent:hover .box { + box-shadow: 0 10px 20px rgba(0,0,0,0.19), 0 6px 6px rgba(0,0,0,0.23); +} +.tile.is-parent:hover { + padding: 0.65rem 0.85rem 0.85rem 0.65rem; + filter: saturate(1.1) brightness(1.1); +} + +#readingBlock { + background-image: linear-gradient(141deg, #911eb4 0%, #b631de 71%, #cf4bf7 100%); +} + +#interactiveBlock { + background-image: linear-gradient(141deg, #d05600 0%, #da722a 71%, #e68846 100%); +} + +#communitiesBlock { + background-image: linear-gradient(141deg, #3b756f 0%, #3a847c 71%, #41948b 100%); +} + +#podcastsBlock { + background-image: linear-gradient(141deg, #232382 0%, #30309c 71%, #4343ad 100%); +} diff --git a/pydis_site/static/js/base/modal.js b/pydis_site/static/js/base/modal.js deleted file mode 100644 index eccc8845..00000000 --- a/pydis_site/static/js/base/modal.js +++ /dev/null @@ -1,100 +0,0 @@ -/* - modal.js: A simple way to wire up Bulma modals. - - This library is intended to be used with Bulma's modals, as described in the - official Bulma documentation. It's based on the JavaScript that Bulma - themselves use for this purpose on the modals documentation page. - - Note that, just like that piece of JavaScript, this library assumes that - you will only ever want to have one modal open at once. - */ - -"use strict"; - -// Event handler for the "esc" key, for closing modals. - -document.addEventListener("keydown", (event) => { - const e = event || window.event; - - if (e.code === "Escape" || e.keyCode === 27) { - closeModals(); - } -}); - -// An array of all the modal buttons we've already set up - -const modal_buttons = []; - -// Public API functions - -function setupModal(target) { - // Set up a modal's events, given a DOM element. This can be - // used later in order to set up a modal that was added after - // this library has been run. - - // We need to collect a bunch of elements to work with - const modal_background = Array.from(target.getElementsByClassName("modal-background")); - const modal_close = Array.from(target.getElementsByClassName("modal-close")); - - const modal_head = Array.from(target.getElementsByClassName("modal-card-head")); - const modal_foot = Array.from(target.getElementsByClassName("modal-card-foot")); - - const modal_delete = []; - const modal_button = []; - - modal_head.forEach((element) => modal_delete.concat(Array.from(element.getElementsByClassName("delete")))); - modal_foot.forEach((element) => modal_button.concat(Array.from(element.getElementsByClassName("button")))); - - // Collect all the elements that can be used to close modals - const modal_closers = modal_background.concat(modal_close).concat(modal_delete).concat(modal_button); - - // Assign click events for closing modals - modal_closers.forEach((element) => { - element.addEventListener("click", () => { - closeModals(); - }); - }); - - setupOpeningButtons(); -} - -function setupOpeningButtons() { - // Wire up all the opening buttons, avoiding buttons we've already wired up. - const modal_opening_buttons = Array.from(document.getElementsByClassName("modal-button")); - - modal_opening_buttons.forEach((element) => { - if (!modal_buttons.includes(element)) { - element.addEventListener("click", () => { - openModal(element.dataset.target); - }); - - modal_buttons.push(element); - } - }); -} - -function openModal(target) { - // Open a modal, given a string ID - const element = document.getElementById(target); - - document.documentElement.classList.add("is-clipped"); - element.classList.add("is-active"); -} - -function closeModals() { - // Close all open modals - const modals = Array.from(document.getElementsByClassName("modal")); - document.documentElement.classList.remove("is-clipped"); - - modals.forEach((element) => { - element.classList.remove("is-active"); - }); -} - -(function () { - // Set up all the modals currently on the page - const modals = Array.from(document.getElementsByClassName("modal")); - - modals.forEach((modal) => setupModal(modal)); - setupOpeningButtons(); -}()); diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html index 905d408c..6fc0c6bb 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -30,7 +30,6 @@ <script src="{% static "js/base/modal.js" %}"></script> <link rel="stylesheet" href="{% static "css/base/base.css" %}"> - <link rel="stylesheet" href="{% static "css/base/notification.css" %}"> {% block head %}{% endblock %} </head> @@ -38,18 +37,6 @@ <!-- Git hash for this release: {{ git_sha }} --> <main class="site-content"> - {% if messages %} - <div class="messages"> - {% for message in messages %} - <div class="notification {% if message.tags %}is-{{ message.tags }}{% endif %}"> - <button class="delete"></button> - - {{ message }} - </div> - {% endfor %} - </div> - {% endif %} - {% block content %} {{ block.super }} {% endblock %} diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index dd68949b..6c8d52a1 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -1,4 +1,3 @@ -{% load socialaccount %} {% load static %} <nav class="navbar is-primary" role="navigation" aria-label="main navigation"> @@ -93,33 +92,6 @@ <a class="navbar-item" href="#"> All events </a> - <hr class="navbar-divider"> - - {% if not user.is_authenticated %} - {% get_providers as socialaccount_providers %} - - {% for provider in socialaccount_providers %} - {% if provider.id == "discord" %} - <a class="navbar-item" - href="{% provider_login_url provider.id process="login" scope=scope auth_params=auth_params %}" - >Login with {{ provider.name }}</a> - {% endif %} - {% endfor %} - {% else %} - <form method="post" action="{% url 'logout' %}"> - {% csrf_token %} - - <div class="field navbar-item is-paddingless is-fullwidth is-grouped"> - <button type="submit" class="button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button> - <a title="Settings" class="button is-white is-inline has-text-right is-size-navbar-menu has-text-grey-dark modal-button" data-target="account-modal"> - <span class="is-icon"> - <i class="fas fa-cog"></i> - </span> - </a> - </div> - </form> - {% endif %} - </div> </div> </div> @@ -130,24 +102,3 @@ </a> </div> </nav> - -{% if user.is_authenticated %} - <script defer type="text/javascript"> - // Script which loads and sets up the account settings modal. - // This script must be placed in a template, or rewritten to take the fetch - // URL as a function argument, in order to be used. - - "use strict"; - - // Create and prepend a new div for this modal - let element = document.createElement("div"); - document.body.prepend(element); - - fetch("{% url "account_settings" %}") // Fetch the URL - .then((response) => response.text()) // Read in the data stream as text - .then((text) => { - element.outerHTML = text; // Replace the div's HTML with the loaded modal HTML - setupModal(document.getElementById("account-modal")); // Set up the modal - }); - </script> -{% endif %} diff --git a/pydis_site/templates/home/account/delete.html b/pydis_site/templates/home/account/delete.html deleted file mode 100644 index 0d44e32a..00000000 --- a/pydis_site/templates/home/account/delete.html +++ /dev/null @@ -1,47 +0,0 @@ -{% extends 'base/base.html' %} -{% load static %} - -{% block title %}Delete Account{% endblock %} - -{% block content %} - {% include "base/navbar.html" %} - - <section class="section content"> - <div class="container"> - <h2 class="is-size-2 has-text-centered">Account Deletion</h2> - - <div class="columns is-centered"> - <div class="column is-half-desktop is-full-tablet is-full-mobile"> - - <article class="message is-danger"> - <div class="message-body"> - <p> - You have requested to delete the account with username - <strong><span class="has-text-dark is-family-monospace">{{ user.username }}</span></strong>. - </p> - - <p> - Please note that this <strong>cannot be undone</strong>. - </p> - - <p> - To verify that you'd like to remove your account, please type your username into the box below. - </p> - </div> - </article> - </div> - </div> - - <div class="columns is-centered"> - <div class="column is-half-desktop is-full-tablet is-full-mobile"> - <form method="post"> - {% csrf_token %} - <label for="id_username" class="label requiredField">Username</label> - <input id="id_username" class="input" type="text" required name="username"> - <input style="margin-top: 1em;" type="submit" value="I understand, delete my account" class="button is-primary"> - </form> - </div> - </div> - </div> - </section> -{% endblock %} diff --git a/pydis_site/templates/home/account/settings.html b/pydis_site/templates/home/account/settings.html deleted file mode 100644 index ed59b052..00000000 --- a/pydis_site/templates/home/account/settings.html +++ /dev/null @@ -1,136 +0,0 @@ -{% load socialaccount %} - -{# This template is just for a modal, which is actually inserted into the navbar #} -{# template. Take a look at `navbar.html` to see how it's inserted. #} - -<div class="modal" id="account-modal"> - <div class="modal-background"></div> - <div class="modal-card"> - <div class="modal-card-head"> - <div class="modal-card-title">Settings for {{ user.username }}</div> - - {% if groups %} - <div> - {% for group in groups %} - <span class="tag is-primary">{{ group.name }}</span> - {% endfor %} - </div> - {% else %} - <span class="tag is-dark">No groups</span> - {% endif %} - </div> - <div class="modal-card-body"> - <h3 class="title">Connections</h3> - <div class="columns"> - {% if discord_provider is not None %} - <div class="column"> - <div class="box"> - {% if not discord %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-discord fa-3x has-text-primary"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">Discord</div> - <div class="subtitle is-6">Not connected</div> - </div> - </div> - <div> - <br /> - <a class="button is-primary" href="{% provider_login_url "discord" process="connect" %}"> - <span class="icon"> - <i class="fad fa-link"></i> - </span> - <span>Connect</span> - </a> - </div> - {% else %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-discord fa-3x has-text-primary"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">Discord</div> - <div class="subtitle is-6">{{ user.username }}</div> - </div> - </div> - <div> - <br /> - <button class="button" disabled> - <span class="icon"> - <i class="fas fa-check"></i> - </span> - <span>Connected</span> - </button> - </div> - {% endif %} - </div> - </div> - {% endif %} - - {% if github_provider is not None %} - <div class="column"> - <div class="box"> - {% if not github %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-github fa-3x"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">GitHub</div> - <div class="subtitle is-6">Not connected</div> - </div> - </div> - <div> - <br /> - <a class="button is-primary" href="{% provider_login_url "github" process="connect" %}"> - <span class="icon"> - <i class="fad fa-link"></i> - </span> - <span>Connect</span> - </a> - </div> - {% else %} - <div class="media"> - <div class="media-left"> - <div class="image"> - <i class="fab fa-github fa-3x"></i> - </div> - </div> - <div class="media-content"> - <div class="title is-5">GitHub</div> - <div class="subtitle is-6">{{ github.extra_data.name }}</div> - </div> - </div> - <div> - <form method="post" action="{% url "account_settings" %}" type="submit"> - {% csrf_token %} - - <input type="hidden" name="provider" value="github" /> - - <br /> - <button type="submit" class="button is-danger"> - <span class="icon"> - <i class="fas fa-times"></i> - </span> - <span>Disconnect</span> - </button> - </form> - </div> - {% endif %} - </div> - </div> - {% endif %} - </div> - </div> - <div class="modal-card-foot"> - <a class="button is-danger" href="{% url "account_delete" %}">Delete Account</a> - </div> - </div> -</div> diff --git a/pydis_site/templates/resources/resources.html b/pydis_site/templates/resources/resources.html new file mode 100644 index 00000000..6eb32c97 --- /dev/null +++ b/pydis_site/templates/resources/resources.html @@ -0,0 +1,90 @@ +{% extends 'base/base.html' %} +{% load static %} + +{% block title %}Resources{% endblock %} +{% block head %} + <link rel="stylesheet" href="{% static "css/resources/resources.css" %}"> +{% endblock %} + +{% block content %} + {% include "base/navbar.html" %} + + <section class="section"> + <div class="container"> + <div class="content"> + <h1>Resources</h1> + + <div class="tile is-ancestor"> + <a class="tile is-parent" href="/articles/category/guides"> + <article class="tile is-child box hero is-primary is-bold"> + <p class="title is-size-1"><i class="fad fa-info-circle" aria-hidden="true"></i> Guides</p> + <p class="subtitle is-size-4">Made by us, for you</p> + </article> + </a> + + <div class="tile is-vertical is-9"> + <div class="tile"> + <a class="tile is-8 is-parent" href="/resources/reading/"> + <article class="tile is-child box hero is-black" id="readingBlock"> + <p class="title is-size-1"><i class="fad fa-book-alt" aria-hidden="true"></i> Read</p> + <p class="subtitle is-size-4">Lovingly curated books to explore</p> + </article> + </a> + + <div class="tile"> + <a class="tile is-parent" href="/resources/videos/"> + <article class="tile is-child box hero is-danger is-bold"> + <p class="title is-size-1"><i class="fad fa-video" aria-hidden="true"></i> Watch</p> + <p class="subtitle is-size-4">Visually engaging</p> + </article> + </a> + </div> + </div> + + <div class="tile"> + <a class="tile is-parent" href="/resources/interactive/"> + <article class="tile is-child box hero is-black" id="interactiveBlock"> + <p class="title is-size-1"><i class="fad fa-code" aria-hidden="true"></i> Try</p> + <p class="subtitle is-size-4">Interactively discover the possibilities</p> + </article> + </a> + <a class="tile is-8 is-parent" href="/resources/courses/"> + <article class="tile is-child box hero is-success is-bold"> + <p class="title is-size-1"><i class="fad fa-graduation-cap" aria-hidden="true"></i> Learn</p> + <p class="subtitle is-size-4">Structured courses with clear goals</p> + </article> + </a> + </div> + </div> + </div> + + <div class="tile is-ancestor"> + <div class="tile is-vertical is-9"> + <div class="tile"> + <a class="tile is-8 is-parent" href="/resources/communities/"> + <article class="tile is-child box hero is-black" id="communitiesBlock"> + <p class="title is-size-1"><i class="fad fa-users" aria-hidden="true"></i> Communities</p> + <p class="subtitle is-size-4">Some of our best friends</p> + </article> + </a> + <div class="tile"> + <a class="tile is-parent" href="/resources/podcasts/"> + <article class="tile is-child box hero is-black" id="podcastsBlock"> + <p class="title is-size-1"><i class="fad fa-podcast" aria-hidden="true"></i> Listen</p> + <p class="subtitle is-size-4">Regular podcasts to follow</p> + </article> + </a> + </div> + </div> + </div> + <a class="tile is-parent" href="/resources/tools/"> + <article class="tile is-child box hero is-dark"> + <p class="title is-size-1"><i class="fad fa-tools" aria-hidden="true"></i> Tools</p> + <p class="subtitle is-size-4">Things we love to use</p> + </article> + </a> + </div> + </div> + </div> + </section> +{% endblock %} diff --git a/pydis_site/tests/test_utils_account.py b/pydis_site/tests/test_utils_account.py deleted file mode 100644 index 6f8338b4..00000000 --- a/pydis_site/tests/test_utils_account.py +++ /dev/null @@ -1,139 +0,0 @@ -from unittest.mock import patch - -from allauth.exceptions import ImmediateHttpResponse -from allauth.socialaccount.models import SocialAccount, SocialLogin -from django.contrib.auth.models import User -from django.contrib.messages.storage.base import BaseStorage -from django.http import HttpRequest -from django.test import RequestFactory, TestCase - -from pydis_site.apps.api.models import Role, User as DiscordUser -from pydis_site.utils.account import AccountAdapter, SocialAccountAdapter - - -class AccountUtilsTests(TestCase): - def setUp(self): - # Create the user - self.django_user = User.objects.create(username="user") - - # Create the roles - developers_role = Role.objects.create( - id=1, - name="Developers", - colour=0, - permissions=0, - position=1 - ) - everyone_role = Role.objects.create( - id=0, - name="@everyone", - colour=0, - permissions=0, - position=0 - ) - - # Create the social accounts - self.discord_account = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=0 - ) - self.discord_account_one_role = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=1 - ) - self.discord_account_two_roles = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=2 - ) - self.discord_account_not_present = SocialAccount.objects.create( - user=self.django_user, provider="discord", uid=3 - ) - self.github_account = SocialAccount.objects.create( - user=self.django_user, provider="github", uid=0 - ) - - # Create DiscordUsers - self.discord_user = DiscordUser.objects.create( - id=0, - name="user", - discriminator=0 - ) - - self.discord_user_role = DiscordUser.objects.create( - id=1, - name="user present", - discriminator=0, - roles=[everyone_role.id] - ) - - self.discord_user_two_roles = DiscordUser.objects.create( - id=2, - name="user with both roles", - discriminator=0, - roles=[everyone_role.id, developers_role.id] - ) - - self.request_factory = RequestFactory() - - def test_account_adapter(self): - """Test that our Allauth account adapter functions correctly.""" - adapter = AccountAdapter() - - self.assertFalse(adapter.is_open_for_signup(HttpRequest())) - - def test_social_account_adapter_signup(self): - """Test that our Allauth social account adapter correctly handles signups.""" - adapter = SocialAccountAdapter() - - discord_login = SocialLogin(account=self.discord_account) - discord_login_role = SocialLogin(account=self.discord_account_one_role) - discord_login_not_present = SocialLogin(account=self.discord_account_not_present) - discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) - - github_login = SocialLogin(account=self.github_account) - - messages_request = self.request_factory.get("/") - messages_request._messages = BaseStorage(messages_request) - - with patch("pydis_site.utils.account.reverse") as mock_reverse: - with patch("pydis_site.utils.account.redirect") as mock_redirect: - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, github_login) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login_role) - - with self.assertRaises(ImmediateHttpResponse): - adapter.is_open_for_signup(messages_request, discord_login_not_present) - - self.assertTrue( - adapter.is_open_for_signup(messages_request, discord_login_two_roles) - ) - - self.assertEqual(len(messages_request._messages._queued_messages), 4) - self.assertEqual(mock_redirect.call_count, 4) - self.assertEqual(mock_reverse.call_count, 4) - - def test_social_account_adapter_populate(self): - """Test that our Allauth social account adapter correctly handles data population.""" - adapter = SocialAccountAdapter() - - discord_login = SocialLogin( - account=self.discord_account, - user=self.django_user - ) - discord_login.account.extra_data["discriminator"] = "0000" - - discord_user = adapter.populate_user( - self.request_factory.get("/"), discord_login, - {"username": "user"} - ) - self.assertEqual(discord_user.username, "user#0000") - self.assertEqual(discord_user.first_name, "user#0000") - - discord_login.account.provider = "not_discord" - not_discord_user = adapter.populate_user( - self.request_factory.get("/"), discord_login, - {"username": "user"} - ) - self.assertEqual(not_discord_user.username, "user") diff --git a/pydis_site/utils/account.py b/pydis_site/utils/account.py deleted file mode 100644 index b4e41198..00000000 --- a/pydis_site/utils/account.py +++ /dev/null @@ -1,79 +0,0 @@ -from typing import Any, Dict - -from allauth.account.adapter import DefaultAccountAdapter -from allauth.exceptions import ImmediateHttpResponse -from allauth.socialaccount.adapter import DefaultSocialAccountAdapter -from allauth.socialaccount.models import SocialLogin -from django.contrib.auth.models import User as DjangoUser -from django.contrib.messages import ERROR, add_message -from django.http import HttpRequest -from django.shortcuts import redirect -from django.urls import reverse - -from pydis_site.apps.api.models import User as DiscordUser - -ERROR_CONNECT_DISCORD = ("You must login with Discord before connecting another account. " - "Your account details have not been saved.") -ERROR_JOIN_DISCORD = ("Please join the Discord server and verify that you accept the rules and " - "privacy policy.") - - -class AccountAdapter(DefaultAccountAdapter): - """An Allauth account adapter that prevents signups via form submission.""" - - def is_open_for_signup(self, request: HttpRequest) -> bool: - """ - Checks whether or not the site is open for signups. - - We override this to always return False so that users may never sign up using - Allauth's signup form endpoints, to be on the safe side - since we only want users - to sign up using their Discord account. - """ - return False - - -class SocialAccountAdapter(DefaultSocialAccountAdapter): - """An Allauth SocialAccount adapter that prevents signups via non-Discord connections.""" - - def is_open_for_signup(self, request: HttpRequest, social_login: SocialLogin) -> bool: - """ - Checks whether or not the site is open for signups. - - We override this method in order to prevent users from creating a new account using - a non-Discord connection, as we require this connection for our users. - """ - if social_login.account.provider != "discord": - add_message(request, ERROR, ERROR_CONNECT_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - try: - user = DiscordUser.objects.get(id=int(social_login.account.uid)) - except DiscordUser.DoesNotExist: - add_message(request, ERROR, ERROR_JOIN_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - if len(user.roles) <= 1: - add_message(request, ERROR, ERROR_JOIN_DISCORD) - - raise ImmediateHttpResponse(redirect(reverse("home"))) - - return True - - def populate_user(self, request: HttpRequest, - social_login: SocialLogin, - data: Dict[str, Any]) -> DjangoUser: - """ - Method used to populate a Django User with data. - - We override this so that the Django user is created with the username#discriminator, - instead of just the username, as Django users must have unique usernames. For display - purposes, we also set the `name` key, which is used for `first_name` in the database. - """ - if social_login.account.provider == "discord": - discriminator = social_login.account.extra_data["discriminator"] - data["username"] = f"{data['username']}#{discriminator:0>4}" - data["name"] = data["username"] - - return super().populate_user(request, social_login, data) diff --git a/pydis_site/utils/views.py b/pydis_site/utils/views.py deleted file mode 100644 index c9803bd6..00000000 --- a/pydis_site/utils/views.py +++ /dev/null @@ -1,25 +0,0 @@ -from django.contrib import messages -from django.http import HttpRequest -from django.views.generic import RedirectView - - -class MessageRedirectView(RedirectView): - """ - Redirects to another URL, also setting a message using the Django Messages framework. - - This is based on Django's own `RedirectView` and works the same way, but takes two additional - parameters. - - * `message`: Set to the message content you wish to display. - * `message_level`: Set to one of the message levels from the Django messages framework. This - parameter defaults to `messages.INFO`. - """ - - message: str = "" - message_level: int = messages.INFO - - def get(self, request: HttpRequest, *args, **kwargs) -> None: - """Called upon a GET request.""" - messages.add_message(request, self.message_level, self.message) - - return super().get(request, *args, **kwargs) |