diff options
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) | 
