diff options
| author | 2020-02-02 22:03:00 +0000 | |
|---|---|---|
| committer | 2020-02-02 22:03:00 +0000 | |
| commit | fa7c2aa1cef7e18face5d531fcc51a5f39bf2e3e (patch) | |
| tree | e7a6417bfe02c2d6f15789f098de422fd6f70dde | |
| parent | Make newlines visible in deleted messages (diff) | |
| parent | Create CODEOWNERS (diff) | |
Merge branch 'master' into deleted-messages-visible-line-endings
55 files changed, 2613 insertions, 140 deletions
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..8aa16827 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @core-developers @@ -4,7 +4,7 @@ url = "https://pypi.org/simple"  verify_ssl = true  [packages] -django = "~=2.2.0" +django = "~=2.2.8"  django-crispy-forms = "~=1.7.2"  django-environ = "~=0.4.5"  django-filter = "~=2.1.0" @@ -43,6 +43,7 @@ unittest-xml-reporting = "~=2.5.1"  python_version = "3.7"  [scripts] +start = "python manage.py run --debug"  makemigrations = "python manage.py makemigrations"  django_shell = "python manage.py shell"  test = "coverage run manage.py test" diff --git a/Pipfile.lock b/Pipfile.lock index 06b49ce7..fea7251c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@  {      "_meta": {          "hash": { -            "sha256": "a4bfc709fcdecf5a3bd28326d51625c83a8a7661367cc69dbda20e3a55f1d9d3" +            "sha256": "dc3468691aed07cf8a9238256cc1b273669d0331a11e105b2d6adc1e19803020"          },          "pipfile-spec": 6,          "requires": { @@ -25,10 +25,10 @@          },          "certifi": {              "hashes": [ -                "sha256:e4f3620cfea4f83eedc95b24abd9cd56f3c4b146dd0177e83a21b4eb49e21e50", -                "sha256:fd7c7c74727ddcf00e9acd26bba8da604ffec95bf1c2144e67aff7a8b50e6cef" +                "sha256:017c25db2a153ce562900032d5bc68e9f191e44e9a0f762f373977de9df1fbb3", +                "sha256:25b64c7da4cd7479594d035c08c2d809eb4aab3a26e5a990ea98cc450c320f1f"              ], -            "version": "==2019.9.11" +            "version": "==2019.11.28"          },          "chardet": {              "hashes": [ @@ -46,11 +46,11 @@          },          "django": {              "hashes": [ -                "sha256:4025317ca01f75fc79250ff7262a06d8ba97cd4f82e93394b2a0a6a4a925caeb", -                "sha256:a8ca1033acac9f33995eb2209a6bf18a4681c3e5269a878e9a7e0b7384ed1ca3" +                "sha256:a4ad4f6f9c6a4b7af7e2deec8d0cbff28501852e5010d6c2dc695d3d1fae7ca0", +                "sha256:fa98ec9cc9bf5d72a08ebf3654a9452e761fbb8566e3f80de199cbc15477e891"              ],              "index": "pypi", -            "version": "==2.2.6" +            "version": "==2.2.8"          },          "django-allauth": {              "hashes": [ @@ -121,10 +121,10 @@          },          "django-nyt": {              "hashes": [ -                "sha256:187f2aae5088c4cf79e7a7caa55c2a9c292722f50cf185c5a738636713ae67ea", -                "sha256:5556e2de47a7b710325a33c49314ee3eff7021d638492e957ef2de15c9360143" +                "sha256:a696a52a0b729465c062b4808d2ad8c43b439561b2f9654328040c646abb3732", +                "sha256:b16bffcfcb468f7b5c70f61de79294a88b7df63859675721d3417507e3440d15"              ], -            "version": "==1.1.3" +            "version": "==1.1.5"          },          "django-sekizai": {              "hashes": [ @@ -171,24 +171,24 @@          },          "libsass": {              "hashes": [ -                "sha256:175355d74bd040893d539154016153247ea9775d1655a36441c97a453887a0c0", -                "sha256:3113ef32eaf3662c162c250db6883d7a5f177856bfd8bb632a147cb0a95e4fee", -                "sha256:312d135e6bd1a137927fed781dab497c05930305265e3d3b1da3b3d916cd97a6", -                "sha256:32f8322aad9b6b864b826adb5e193d704d5fb2c816f85a5cc5bf775730e5d024", -                "sha256:4252e24c8869d6ce764052f200445331d1881b5c2d283d6131a30d0684b10403", -                "sha256:517324814f81cd2642cb1e9fd772e8e50e336c7c8833d50535a731e5b4c84606", -                "sha256:607ce32c3b31542e0bf1bc2409627dd7247a3849ba720ec34d23426b96346199", -                "sha256:6124594e72ba216b00131795ad5ea5de1e0cf8784e63a01e0c6a4e4c13fc7914", -                "sha256:6129063002fc8337b734f5963ac3eb01ead51e9c88c6d27e73ddc9236cb15b2e", -                "sha256:6d392ecd6e4de2ccfa3b1953f2da8461a2b7c8c8c17c24e1c335ab3040671c1a", -                "sha256:75b38c236be6ca03e3dd3789f3044180fc0836b7c9e4991fcc52a8570f47dc91", -                "sha256:9c711d4e4d003fec7f98fe87bb1faf7d88e6d648356413d8b8d9d76bd1844089", -                "sha256:b15a0e61bd54764e658bc6931015453fa34d954f87c3b6fd35624e13bcacf69d", -                "sha256:bc0c80a4e233b6b791a7f6f99415ab877e8a4d3a45085b68981c97d74dbfc8bf", -                "sha256:c22cdc37121b730e5fb87bc8d3eee8c4b1fe219a04d198a535fbd22895c99e27", -                "sha256:c5ba74babfb3a6976611312e0026c4668913cdf05e009921e1f54146ccdc02a4" -            ], -            "version": "==0.19.3" +                "sha256:003a65b4facb4c5dbace53fb0f70f61c5aae056a04b4d112a198c3c9674b31f2", +                "sha256:0fd8b4337b3b101c6e6afda9112cc0dc4bacb9133b59d75d65968c7317aa3272", +                "sha256:338e9ae066bf1fde874e335324d5355c52d2081d978b4f74fc59536564b35b08", +                "sha256:4dcfd561fb100250b89496e1362b96f2cc804f689a59731eb0f94f9a9e144f4a", +                "sha256:50778d4be269a021ba2bf42b5b8f6ff3704ab96a82175a052680bddf3ba7cc9f", +                "sha256:6a51393d75f6e3c812785b0fa0b7d67c54258c28011921f204643b55f7355ec0", +                "sha256:74acd9adf506142699dfa292f0e569fdccbd9e7cf619e8226f7117de73566e32", +                "sha256:81a013a4c2a614927fd1ef7a386eddabbba695cbb02defe8f31cf495106e974c", +                "sha256:845a9573b25c141164972d498855f4ad29367c09e6d76fad12955ad0e1c83013", +                "sha256:8b5b6d1a7c4ea1d954e0982b04474cc076286493f6af2d0a13c2e950fbe0be95", +                "sha256:9b59afa0d755089c4165516400a39a289b796b5612eeef5736ab7a1ebf96a67c", +                "sha256:a7e685466448c9b1bf98243339793978f654a1151eb5c975f09b83c7a226f4c1", +                "sha256:c93df526eeef90b1ea4799c1d33b6cd5aea3e9f4633738fb95c1287c13e6b404", +                "sha256:e318f06f06847ff49b1f8d086ac9ebce1e63404f7ea329adab92f4f16ba0e00e", +                "sha256:fc5f8336750f76f1bfae82f7e9e89ae71438d26fc4597e3ab4c05ca8fcd41d8a", +                "sha256:fcb7ab4dc81889e5fc99cafbc2017bc76996f9992fc6b175f7a80edac61d71df" +            ], +            "version": "==0.19.4"          },          "markdown": {              "hashes": [ @@ -206,34 +206,30 @@          },          "pillow": {              "hashes": [ -                "sha256:00fdeb23820f30e43bba78eb9abb00b7a937a655de7760b2e09101d63708b64e", -                "sha256:01f948e8220c85eae1aa1a7f8edddcec193918f933fb07aaebe0bfbbcffefbf1", -                "sha256:08abf39948d4b5017a137be58f1a52b7101700431f0777bec3d897c3949f74e6", -                "sha256:099a61618b145ecb50c6f279666bbc398e189b8bc97544ae32b8fcb49ad6b830", -                "sha256:2c1c61546e73de62747e65807d2cc4980c395d4c5600ecb1f47a650c6fa78c79", -                "sha256:2ed9c4f694861642401f27dc3cb99772be67cd190e84845c749dae0a06c3bfae", -                "sha256:338581b30b908e111be578f0297255f6b57a51358cd16fa0e6f664c9a1f88bff", -                "sha256:38c7d48a21cd06fdeee93987147b9b1c55b73b4cfcbf83240568bfbd5adee447", -                "sha256:43fd026f613c8e48a25eba1a92f4d2ad7f3903c95d8c33a11611a7717d2ab654", -                "sha256:4548236844327a718ce3bb182ab32a16fa2050c61e334e959f554cac052fb0df", -                "sha256:5090857876c58885cfa388dc649e5db30aae98a068c26f3fd0ac9d7d9a4d9572", -                "sha256:5bbba34f97a26a93f5e8dec469ca4ddd712451418add43da946dbaed7f7a98d2", -                "sha256:65a28969a025a0eb4594637b6103201dc4ed2a9508bdab56ac33e43e3081c404", -                "sha256:892bb52b70bd5ea9dbbc3ac44f38e84f5a04e9d8b1bff48159d96cb795b81159", -                "sha256:8a9becd5cbd5062f973bcd2e7bc79483af310222de112b6541f8af1f93a3cc42", -                "sha256:972a7aaeb7c4a2795b52eef52ee991ef040b31009f36deca6207a986607b55f3", -                "sha256:97b119c436bfa96a92ac2ca525f7025836d4d4e64b1c9f9eff8dbaf3ff1d86f3", -                "sha256:9ba37698e242223f8053cc158f130aee046a96feacbeab65893dbe94f5530118", -                "sha256:b1b0e1f626a0f079c0d3696db70132fb1f29aa87c66aecb6501a9b8be64ce9f7", -                "sha256:c14c1224fd1a5be2733530d648a316974dbbb3c946913562c6005a76f21ca042", -                "sha256:c79a8546c48ae6465189e54e3245a97ddf21161e33ff7eaa42787353417bb2b6", -                "sha256:ceb76935ac4ebdf6d7bc845482a4450b284c6ccfb281e34da51d510658ab34d8", -                "sha256:e22bffaad04b4d16e1c091baed7f2733fc1ebb91e0c602abf1b6834d17158b1f", -                "sha256:ec883b8e44d877bda6f94a36313a1c6063f8b1997aa091628ae2f34c7f97c8d5", -                "sha256:f1baa54d50ec031d1a9beb89974108f8f2c0706f49798f4777df879df0e1adb6", -                "sha256:f53a5385932cda1e2c862d89460992911a89768c65d176ff8c50cddca4d29bed" -            ], -            "version": "==6.2.0" +                "sha256:0a628977ac2e01ca96aaae247ec2bd38e729631ddf2221b4b715446fd45505be", +                "sha256:4d9ed9a64095e031435af120d3c910148067087541131e82b3e8db302f4c8946", +                "sha256:54ebae163e8412aff0b9df1e88adab65788f5f5b58e625dc5c7f51eaf14a6837", +                "sha256:5bfef0b1cdde9f33881c913af14e43db69815c7e8df429ceda4c70a5e529210f", +                "sha256:5f3546ceb08089cedb9e8ff7e3f6a7042bb5b37c2a95d392fb027c3e53a2da00", +                "sha256:5f7ae9126d16194f114435ebb79cc536b5682002a4fa57fa7bb2cbcde65f2f4d", +                "sha256:62a889aeb0a79e50ecf5af272e9e3c164148f4bd9636cc6bcfa182a52c8b0533", +                "sha256:7406f5a9b2fd966e79e6abdaf700585a4522e98d6559ce37fc52e5c955fade0a", +                "sha256:8453f914f4e5a3d828281a6628cf517832abfa13ff50679a4848926dac7c0358", +                "sha256:87269cc6ce1e3dee11f23fa515e4249ae678dbbe2704598a51cee76c52e19cda", +                "sha256:875358310ed7abd5320f21dd97351d62de4929b0426cdb1eaa904b64ac36b435", +                "sha256:8ac6ce7ff3892e5deaab7abaec763538ffd011f74dc1801d93d3c5fc541feee2", +                "sha256:91b710e3353aea6fc758cdb7136d9bbdcb26b53cefe43e2cba953ac3ee1d3313", +                "sha256:9d2ba4ed13af381233e2d810ff3bab84ef9f18430a9b336ab69eaf3cd24299ff", +                "sha256:a62ec5e13e227399be73303ff301f2865bf68657d15ea50b038d25fc41097317", +                "sha256:ab76e5580b0ed647a8d8d2d2daee170e8e9f8aad225ede314f684e297e3643c2", +                "sha256:bf4003aa538af3f4205c5fac56eacaa67a6dd81e454ffd9e9f055fff9f1bc614", +                "sha256:bf598d2e37cf8edb1a2f26ed3fb255191f5232badea4003c16301cb94ac5bdd0", +                "sha256:c18f70dc27cc5d236f10e7834236aff60aadc71346a5bc1f4f83a4b3abee6386", +                "sha256:c5ed816632204a2fc9486d784d8e0d0ae754347aba99c811458d69fcdfd2a2f9", +                "sha256:dc058b7833184970d1248135b8b0ab702e6daa833be14035179f2acb78ff5636", +                "sha256:ff3797f2f16bf9d17d53257612da84dd0758db33935777149b3334c01ff68865" +            ], +            "version": "==7.0.0"          },          "psycopg2-binary": {              "hashes": [ @@ -259,11 +255,13 @@                  "sha256:84156313f258eafff716b2961644a4483a9be44a5d43551d554844d15d4d224e",                  "sha256:8578d6b8192e4c805e85f187bc530d0f52ba86c39172e61cd51f68fddd648103",                  "sha256:890167d5091279a27e2505ff0e1fb273f8c48c41d35c5b92adbf4af80e6b2ed6", +                "sha256:98e10634792ac0e9e7a92a76b4991b44c2325d3e7798270a808407355e7bb0a1",                  "sha256:9aadff9032e967865f9778485571e93908d27dab21d0fdfdec0ca779bb6f8ad9",                  "sha256:9f24f383a298a0c0f9b3113b982e21751a8ecde6615494a3f1470eb4a9d70e9e",                  "sha256:a73021b44813b5c84eda4a3af5826dd72356a900bac9bd9dd1f0f81ee1c22c2f",                  "sha256:afd96845e12638d2c44d213d4810a08f4dc4a563f9a98204b7428e567014b1cd",                  "sha256:b73ddf033d8cd4cc9dfed6324b1ad2a89ba52c410ef6877998422fcb9c23e3a8", +                "sha256:b8f490f5fad1767a1331df1259763b3bad7d7af12a75b950c2843ba319b2415f",                  "sha256:dbc5cd56fff1a6152ca59445178652756f4e509f672e49ccdf3d79c1043113a4",                  "sha256:eac8a3499754790187bb00574ab980df13e754777d346f85e0ff6df929bcd964",                  "sha256:eaed1c65f461a959284649e37b5051224f4db6ebdc84e40b5e65f2986f101a08" @@ -308,6 +306,7 @@                  "sha256:861c94442b28cd64af033e88e0f63c66dbd5609f67952dc18694098b47a43f3a",                  "sha256:957bc6316ffc8463795d56d9953d58e7f32aa5aad1c5ac80bc45c69f3299961e",                  "sha256:9760c3f56fb5f15852d163429096600906478e9ed2c189a52f2bb21d8a2a986c", +                "sha256:9fdfb98a2992de01e8efad2aeed22c825e36db628b144b2d6b93d81fb549f811",                  "sha256:a4b24703ea818196d0be1dc64b3b57b79c67e8dee0cfa207a4216220912035a7",                  "sha256:ad7f4968c1ddbf139a306d9b075360d959cc554d994ba5e1f512af9a40e62357",                  "sha256:b1127d34b90f74faf1707718c57a4193ac028b9f4aec0238638983132297d456", @@ -318,6 +317,7 @@                  "sha256:ce777ebdf49ce736fc04abf555b5c41ab3f130127543a689dcf8d4871cd18fe4",                  "sha256:d8b4bf930b6a19bc9ee982b9163d948c87501ad91b71516924e8ed25fe85d2ee",                  "sha256:e2a420f2c4d35f3ec0b7e752a80d7bd385e2c5a64f67c05f2d2d74230e3114b6", +                "sha256:ef5eb630f541af6b69378d58594be90a0922fa6d6a50a9248c25b9502585f6bf",                  "sha256:fed899ce96f4f2b4d1b9f338dd145a4040ee1d8a5152213af0dd8d4a4d36e9fe"              ],              "index": "pypi", @@ -353,17 +353,17 @@          },          "requests-oauthlib": {              "hashes": [ -                "sha256:bd6533330e8748e94bf0b214775fed487d309b8b8fe823dc45641ebcd9a32f57", -                "sha256:d3ed0c8f2e3bbc6b344fa63d6f933745ab394469da38db16bdddb461c7e25140" +                "sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d", +                "sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"              ], -            "version": "==1.2.0" +            "version": "==1.3.0"          },          "six": {              "hashes": [ -                "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", -                "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" +                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", +                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"              ], -            "version": "==1.12.0" +            "version": "==1.13.0"          },          "sorl-thumbnail": {              "hashes": [ @@ -381,10 +381,10 @@          },          "urllib3": {              "hashes": [ -                "sha256:3de946ffbed6e6746608990594d08faac602528ac7015ac28d33cee6a45b7398", -                "sha256:9a107b99a5393caf59c7aa3c1249c16e6879447533d0887f4336dde834c7be86" +                "sha256:a8a318824cc77d1fd4b2bec2ded92646630d7fe8619497b142c84a9e6f5a7293", +                "sha256:f3c5fd51747d450d4dcf6f923c81f78f811aab8205fda64b0aba34a4e48b0745"              ], -            "version": "==1.25.6" +            "version": "==1.25.7"          },          "webencodings": {              "hashes": [ @@ -569,24 +569,24 @@          },          "gitpython": {              "hashes": [ -                "sha256:631263cc670aa56ce3d3c414cf0fe2e840f2e913514b138ea28d88a477bbcd21", -                "sha256:6e97b9f0954807f30c2dd8e3165731ed6c477a1b365f194b69d81d7940a08332" +                "sha256:9c2398ffc3dcb3c40b27324b316f08a4f93ad646d5a6328cafbb871aa79f5e42", +                "sha256:c155c6a2653593ccb300462f6ef533583a913e17857cfef8fc617c246b6dc245"              ], -            "version": "==3.0.3" +            "version": "==3.0.5"          },          "identify": {              "hashes": [ -                "sha256:4f1fe9a59df4e80fcb0213086fcf502bc1765a01ea4fe8be48da3b65afd2a017", -                "sha256:d8919589bd2a5f99c66302fec0ef9027b12ae150b0b0213999ad3f695fc7296e" +                "sha256:6f44e637caa40d1b4cb37f6ed3b262ede74901d28b1cc5b1fc07360871edd65d", +                "sha256:72e9c4ed3bc713c7045b762b0d2e2115c572b85abfc1f4604f5a4fd4c6642b71"              ], -            "version": "==1.4.7" +            "version": "==1.4.9"          },          "importlib-metadata": {              "hashes": [ -                "sha256:aa18d7378b00b40847790e7c27e11673d7fed219354109d0e7b9e5b25dc3ad26", -                "sha256:d5f18a79777f3aa179c145737780282e27b508fc8fd688cb17c7a813e8bd39af" +                "sha256:073a852570f92da5f744a3472af1b61e28e9f78ccf0c9117658dc32b15de7b45", +                "sha256:d95141fbfa7ef2ec65cfd945e2af7e5a6ddbd7c8d9a25e66ff3be8e3daf9f60f"              ], -            "version": "==0.23" +            "version": "==1.3.0"          },          "mccabe": {              "hashes": [ @@ -598,10 +598,10 @@          },          "more-itertools": {              "hashes": [ -                "sha256:409cd48d4db7052af495b09dec721011634af3753ae1ef92d2b32f73a745f832", -                "sha256:92b8c4b06dac4f0611c0729b2f2ede52b2e1bac1ab48f089c7ddc12e26bb60c4" +                "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", +                "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564"              ], -            "version": "==7.2.0" +            "version": "==8.0.2"          },          "nodeenv": {              "hashes": [ @@ -611,10 +611,10 @@          },          "pbr": {              "hashes": [ -                "sha256:2c8e420cd4ed4cec4e7999ee47409e876af575d4c35a45840d59e8b5f3155ab8", -                "sha256:b32c8ccaac7b1a20c0ce00ce317642e6cf231cf038f9875e0280e28af5bf7ac9" +                "sha256:139d2625547dbfa5fb0b81daebb39601c478c21956dc57e2e07b74450a8c506b", +                "sha256:61aa52a0f18b71c5cc58232d2cf8f8d09cd67fcad60b742a60124cb8d6951488"              ], -            "version": "==5.4.3" +            "version": "==5.4.4"          },          "pep8-naming": {              "hashes": [ @@ -641,10 +641,10 @@          },          "pydocstyle": {              "hashes": [ -                "sha256:04c84e034ebb56eb6396c820442b8c4499ac5eb94a3bda88951ac3dc519b6058", -                "sha256:66aff87ffe34b1e49bff2dd03a88ce6843be2f3346b0c9814410d34987fbab59" +                "sha256:4167fe954b8f27ebbbef2fbcf73c6e8ad1e7bb31488fce44a69fdfc4b0cd0fae", +                "sha256:a0de36e549125d0a16a72a8c8c6c9ba267750656e72e466e994c222f1b6e92cb"              ], -            "version": "==4.0.1" +            "version": "==5.0.1"          },          "pyflakes": {              "hashes": [ @@ -674,10 +674,10 @@          },          "six": {              "hashes": [ -                "sha256:3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", -                "sha256:d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73" +                "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", +                "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66"              ], -            "version": "==1.12.0" +            "version": "==1.13.0"          },          "smmap2": {              "hashes": [ @@ -742,10 +742,10 @@          },          "virtualenv": {              "hashes": [ -                "sha256:3e3597e89c73df9313f5566e8fc582bd7037938d15b05329c232ec57a11a7ad5", -                "sha256:5d370508bf32e522d79096e8cbea3499d47e624ac7e11e9089f9397a0b3318df" +                "sha256:0d62c70883c0342d59c11d0ddac0d954d0431321a41ab20851facf2b222598f3", +                "sha256:55059a7a676e4e19498f1aad09b8313a38fcc0cdbe4fdddc0e9b06946d21b4bb"              ], -            "version": "==16.7.6" +            "version": "==16.7.9"          },          "zipp": {              "hashes": [ @@ -1,5 +1,5 @@  # Python Discord: Site -[](https://discord.gg/2B963hn) +[](https://discord.gg/2B963hn)  [](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=2&branchName=master)  [](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)  [](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master) diff --git a/pydis_site/__init__.py b/pydis_site/__init__.py index c6146450..df67cf71 100644 --- a/pydis_site/__init__.py +++ b/pydis_site/__init__.py @@ -2,3 +2,8 @@ from wiki.plugins.macros.mdx import toc  # Remove the toc header prefix. There's no option for this, so we gotta monkey patch it.  toc.HEADER_ID_PREFIX = '' + +# 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/api/admin.py b/pydis_site/apps/api/admin.py index 059f52eb..0333fefc 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -12,6 +12,7 @@ from .models import (      MessageDeletionContext,      Nomination,      OffTopicChannelName, +    OffensiveMessage,      Role,      Tag,      User @@ -60,6 +61,7 @@ admin.site.register(Infraction)  admin.site.register(LogEntry, LogEntryAdmin)  admin.site.register(MessageDeletionContext)  admin.site.register(Nomination) +admin.site.register(OffensiveMessage)  admin.site.register(OffTopicChannelName)  admin.site.register(Role)  admin.site.register(Tag) diff --git a/pydis_site/apps/api/migrations/0047_active_infractions_migration.py b/pydis_site/apps/api/migrations/0047_active_infractions_migration.py new file mode 100644 index 00000000..9ac791dc --- /dev/null +++ b/pydis_site/apps/api/migrations/0047_active_infractions_migration.py @@ -0,0 +1,105 @@ +# Generated by Django 2.2.6 on 2019-10-07 15:59 + +from django.db import migrations +from django.db.models import Count, Prefetch, QuerySet + + +class ExpirationWrapper: +    """Wraps an expiration date to properly compare permanent and temporary infractions.""" + +    def __init__(self, infraction): +        self.expiration_date = infraction.expires_at + +    def __lt__(self, other): +        """An `expiration_date` is considered smaller when it comes earlier than the `other`.""" +        if self.expiration_date is None: +            # A permanent infraction can never end sooner than another infraction +            return False +        elif other.expiration_date is None: +            # If `self` is temporary, but `other` is permanent, `self` is smaller +            return True +        else: +            return self.expiration_date < other.expiration_date + +    def __eq__(self, other): +        """If both expiration dates are permanent they're equal, otherwise compare dates.""" +        if self.expiration_date is None and other.expiration_date is None: +            return True +        elif self.expiration_date is None or other.expiration_date is None: +            return False +        else: +            return self.expiration_date == other.expiration_date + + +def migrate_inactive_types_to_inactive(apps, schema_editor): +    """Migrates infractions of non-active types to inactive.""" +    infraction_model = apps.get_model('api', 'Infraction') +    infraction_model.objects.filter(type__in=('note', 'warning', 'kick')).update(active=False) + + +def get_query(user_model, infraction_model, infr_type: str) -> QuerySet: +    """ +    Creates QuerySet to fetch users with multiple active infractions of the given `type`. + +    The QuerySet will prefetch the infractions and attach them as an `.infractions` attribute to the +    `User` instances. +    """ +    active_infractions = infraction_model.objects.filter(type=infr_type, active=True) + +    # Build an SQL query by chaining methods together + +    # Get users with active infraction(s) of the provided `infr_type` +    query = user_model.objects.filter( +        infractions_received__type=infr_type, infractions_received__active=True +    ) + +    # Prefetch their active received infractions of `infr_type` and attach `.infractions` attribute +    query = query.prefetch_related( +        Prefetch('infractions_received', queryset=active_infractions, to_attr='infractions') +    ) + +    # Count and only include them if they have at least 2 active infractions of the `type` +    query = query.annotate(num_infractions=Count('infractions_received')) +    query = query.filter(num_infractions__gte=2) + +    # Make sure we return each individual only once +    query = query.distinct() + +    return query + + +def migrate_multiple_active_infractions_per_user_to_one(apps, schema_editor): +    """ +    Make sure a user only has one active infraction of a given "active" infraction type. + +    If a user has multiple active infraction, we keep the one with longest expiration date active +    and migrate the others to inactive. +    """ +    infraction_model = apps.get_model('api', 'Infraction') +    user_model = apps.get_model('api', 'User') + +    for infraction_type in ('ban', 'mute', 'superstar', 'watch'): +        query = get_query(user_model, infraction_model, infraction_type) +        for user in query: +            infractions = sorted(user.infractions, key=ExpirationWrapper, reverse=True) +            for infraction in infractions[1:]: +                infraction.active = False +                infraction.save() + + +def reverse_migration(apps, schema_editor): +    """There's no need to do anything special to reverse these migrations.""" +    return + + +class Migration(migrations.Migration): +    """Data migration to get the database consistent with the new infraction validation rules.""" + +    dependencies = [ +        ('api', '0046_reminder_jump_url'), +    ] + +    operations = [ +        migrations.RunPython(migrate_inactive_types_to_inactive, reverse_migration), +        migrations.RunPython(migrate_multiple_active_infractions_per_user_to_one, reverse_migration) +    ] diff --git a/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py b/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py new file mode 100644 index 00000000..4ea1fb90 --- /dev/null +++ b/pydis_site/apps/api/migrations/0048_add_infractions_unique_constraints_active.py @@ -0,0 +1,17 @@ +# Generated by Django 2.2.6 on 2019-10-07 18:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0047_active_infractions_migration'), +    ] + +    operations = [ +        migrations.AddConstraint( +            model_name='infraction', +            constraint=models.UniqueConstraint(condition=models.Q(active=True), fields=('user', 'type'), name='unique_active_infraction_per_type_per_user'), +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py b/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py new file mode 100644 index 00000000..31ac239a --- /dev/null +++ b/pydis_site/apps/api/migrations/0049_deletedmessage_attachments.py @@ -0,0 +1,20 @@ +# Generated by Django 2.2.6 on 2019-10-28 17:12 + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0049_offensivemessage'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='deletedmessage', +            name='attachments', +            field=django.contrib.postgres.fields.ArrayField(base_field=models.URLField(max_length=512), default=[], blank=True, help_text='Attachments attached to this message.', size=None), +            preserve_default=False, +        ), +    ] diff --git a/pydis_site/apps/api/migrations/0049_offensivemessage.py b/pydis_site/apps/api/migrations/0049_offensivemessage.py new file mode 100644 index 00000000..fe4a1961 --- /dev/null +++ b/pydis_site/apps/api/migrations/0049_offensivemessage.py @@ -0,0 +1,25 @@ +# Generated by Django 2.2.6 on 2019-11-07 18:08 + +import django.core.validators +from django.db import migrations, models +import pydis_site.apps.api.models.bot.offensive_message +import pydis_site.apps.api.models.utils + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('api', '0048_add_infractions_unique_constraints_active'), +    ] + +    operations = [ +        migrations.CreateModel( +            name='OffensiveMessage', +            fields=[ +                ('id', models.BigIntegerField(help_text='The message ID as taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Message IDs cannot be negative.')])), +                ('channel_id', models.BigIntegerField(help_text='The channel ID that the message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), +                ('delete_date', models.DateTimeField(help_text='The date on which the message will be auto-deleted.', validators=[pydis_site.apps.api.models.bot.offensive_message.future_date_validator])), +            ], +            bases=(pydis_site.apps.api.models.utils.ModelReprMixin, models.Model), +        ), +    ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index a4656bc3..450d18cd 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -7,6 +7,7 @@ from .bot import (      Message,      MessageDeletionContext,      Nomination, +    OffensiveMessage,      OffTopicChannelName,      Reminder,      Role, diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index 46219ea2..8ae47746 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -7,6 +7,7 @@ from .message import Message  from .message_deletion_context import MessageDeletionContext  from .nomination import Nomination  from .off_topic_channel_name import OffTopicChannelName +from .offensive_message import OffensiveMessage  from .reminder import Reminder  from .role import Role  from .tag import Tag diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py index dfb32a97..108fd3a2 100644 --- a/pydis_site/apps/api/models/bot/infraction.py +++ b/pydis_site/apps/api/models/bot/infraction.py @@ -71,3 +71,10 @@ class Infraction(ModelReprMixin, models.Model):          """Defines the meta options for the infraction model."""          ordering = ['-inserted_at'] +        constraints = ( +            models.UniqueConstraint( +                fields=["user", "type"], +                condition=models.Q(active=True), +                name="unique_active_infraction_per_type_per_user" +            ), +        ) diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 31316a01..8b18fc9f 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -51,6 +51,13 @@ class Message(ModelReprMixin, models.Model):          ),          help_text="Embeds attached to this message."      ) +    attachments = pgfields.ArrayField( +        models.URLField( +            max_length=512 +        ), +        blank=True, +        help_text="Attachments attached to this message." +    )      @property      def timestamp(self) -> datetime: diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py new file mode 100644 index 00000000..b466d9c2 --- /dev/null +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -0,0 +1,48 @@ +import datetime + +from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator +from django.db import models + +from pydis_site.apps.api.models.utils import ModelReprMixin + + +def future_date_validator(date: datetime.date) -> None: +    """Raise ValidationError if the date isn't a future date.""" +    if date < datetime.datetime.now(datetime.timezone.utc): +        raise ValidationError("Date must be a future date") + + +class OffensiveMessage(ModelReprMixin, models.Model): +    """A message that triggered a filter and that will be deleted one week after it was sent.""" + +    id = models.BigIntegerField( +        primary_key=True, +        help_text="The message ID as taken from Discord.", +        validators=( +            MinValueValidator( +                limit_value=0, +                message="Message IDs cannot be negative." +            ), +        ) +    ) +    channel_id = models.BigIntegerField( +        help_text=( +            "The channel ID that the message was " +            "sent in, taken from Discord." +        ), +        validators=( +            MinValueValidator( +                limit_value=0, +                message="Channel IDs cannot be negative." +            ), +        ) +    ) +    delete_date = models.DateTimeField( +        help_text="The date on which the message will be auto-deleted.", +        validators=(future_date_validator,) +    ) + +    def __str__(self): +        """Return some info on this message, for display purposes only.""" +        return f"Message {self.id}, will be deleted at {self.delete_date}" diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py index 21617dc4..5140d2bf 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -50,7 +50,7 @@ class User(ModelReprMixin, models.Model):      def __str__(self):          """Returns the name and discriminator for the current user, for display purposes.""" -        return f"{self.name}#{self.discriminator}" +        return f"{self.name}#{self.discriminator:0>4}"      @property      def top_role(self) -> Role: diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 8a605612..0d1a4684 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,6 +1,6 @@  """Converters from Django models to data interchange formats and back.""" -  from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from rest_framework.validators import UniqueTogetherValidator  from rest_framework_bulk import BulkSerializerMixin  from .models import ( @@ -8,6 +8,7 @@ from .models import (      DocumentationLink, Infraction,      LogEntry, MessageDeletionContext,      Nomination, OffTopicChannelName, +    OffensiveMessage,      Reminder, Role,      Tag, User  ) @@ -49,7 +50,8 @@ class DeletedMessageSerializer(ModelSerializer):          fields = (              'id', 'author',              'channel_id', 'content', -            'embeds', 'deletion_context' +            'embeds', 'deletion_context', +            'attachments'          ) @@ -105,11 +107,22 @@ class InfractionSerializer(ModelSerializer):          fields = (              'id', 'inserted_at', 'expires_at', 'active', 'user', 'actor', 'type', 'reason', 'hidden'          ) +        validators = [ +            UniqueTogetherValidator( +                queryset=Infraction.objects.filter(active=True), +                fields=['user', 'type'], +                message='This user already has an active infraction of this type.', +            ) +        ]      def validate(self, attrs: dict) -> dict:          """Validate data constraints for the given data and abort if it is invalid."""          infr_type = attrs.get('type') +        active = attrs.get('active') +        if active and infr_type in ('note', 'warning', 'kick'): +            raise ValidationError({'active': [f'{infr_type} infractions cannot be active.']}) +          expires_at = attrs.get('expires_at')          if expires_at and infr_type in ('kick', 'warning'):              raise ValidationError({'expires_at': [f'{infr_type} infractions cannot expire.']}) @@ -236,3 +249,13 @@ class NominationSerializer(ModelSerializer):          fields = (              'id', 'active', 'actor', 'reason', 'user',              'inserted_at', 'end_reason', 'ended_at') + + +class OffensiveMessageSerializer(ModelSerializer): +    """A class providing (de-)serialization of `OffensiveMessage` instances.""" + +    class Meta: +        """Metadata defined for the Django REST Framework.""" + +        model = OffensiveMessage +        fields = ('id', 'channel_id', 'delete_date') diff --git a/pydis_site/apps/api/tests/migrations/__init__.py b/pydis_site/apps/api/tests/migrations/__init__.py new file mode 100644 index 00000000..38e42ffc --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/__init__.py @@ -0,0 +1 @@ +"""This submodule contains tests for functions used in data migrations.""" diff --git a/pydis_site/apps/api/tests/migrations/base.py b/pydis_site/apps/api/tests/migrations/base.py new file mode 100644 index 00000000..0c0a5bd0 --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/base.py @@ -0,0 +1,102 @@ +"""Includes utilities for testing migrations.""" +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TestCase + + +class MigrationsTestCase(TestCase): +    """ +    A `TestCase` subclass to test migration files. + +    To be able to properly test a migration, we will need to inject data into the test database +    before the migrations we want to test are applied, but after the older migrations have been +    applied. This makes sure that we are testing "as if" we were actually applying this migration +    to a database in the state it was in before introducing the new migration. + +    To set up a MigrationsTestCase, create a subclass of this class and set the following +    class-level attributes: + +    - app: The name of the app that contains the migrations (e.g., `'api'`) +    - migration_prior: The name* of the last migration file before the migrations you want to test +    - migration_target: The name* of the last migration file we want to test + +    *) Specify the file names without a path or the `.py` file extension. + +    Additionally, overwrite the `setUpMigrationData` in the subclass to inject data into the +    database before the migrations we want to test are applied. Please read the docstring of the +    method for more information. An optional hook, `setUpPostMigrationData` is also provided. +    """ + +    # These class-level attributes should be set in classes that inherit from this base class. +    app = None +    migration_prior = None +    migration_target = None + +    @classmethod +    def setUpTestData(cls): +        """ +        Injects data into the test database prior to the migration we're trying to test. + +        This class methods reverts the test database back to the state of the last migration file +        prior to the migrations we want to test. It will then allow the user to inject data into the +        test database by calling the `setUpMigrationData` hook. After the data has been injected, it +        will apply the migrations we want to test and call the `setUpPostMigrationData` hook. The +        user can now test if the migration correctly migrated the injected test data. +        """ +        if not cls.app: +            raise ValueError("The `app` attribute was not set.") + +        if not cls.migration_prior or not cls.migration_target: +            raise ValueError("Both ` migration_prior` and `migration_target` need to be set.") + +        cls.migrate_from = [(cls.app, cls.migration_prior)] +        cls.migrate_to = [(cls.app, cls.migration_target)] + +        # Reverse to database state prior to the migrations we want to test +        executor = MigrationExecutor(connection) +        executor.migrate(cls.migrate_from) + +        # Call the data injection hook with the current state of the project +        old_apps = executor.loader.project_state(cls.migrate_from).apps +        cls.setUpMigrationData(old_apps) + +        # Run the migrations we want to test +        executor = MigrationExecutor(connection) +        executor.loader.build_graph() +        executor.migrate(cls.migrate_to) + +        # Save the project state so we're able to work with the correct model states +        cls.apps = executor.loader.project_state(cls.migrate_to).apps + +        # Call `setUpPostMigrationData` to potentially set up post migration data used in testing +        cls.setUpPostMigrationData(cls.apps) + +    @classmethod +    def setUpMigrationData(cls, apps): +        """ +        Override this method to inject data into the test database before the migration is applied. + +        This method will be called after setting up the database according to the migrations that +        come before the migration(s) we are trying to test, but before the to-be-tested migration(s) +        are applied. This allows us to simulate a database state just prior to the migrations we are +        trying to test. + +        To make sure we're creating objects according to the state the models were in at this point +        in the migration history, use `apps.get_model(app_name: str, model_name: str)` to get the +        appropriate model, e.g.: + +        >>> Infraction = apps.get_model('api', 'Infraction') +        """ +        pass + +    @classmethod +    def setUpPostMigrationData(cls, apps): +        """ +        Set up additional test data after the target migration has been applied. + +        Use `apps.get_model(app_name: str, model_name: str)` to get the correct instances of the +        model classes: + +        >>> Infraction = apps.get_model('api', 'Infraction') +        """ +        pass diff --git a/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py new file mode 100644 index 00000000..8dc29b34 --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/test_active_infraction_migration.py @@ -0,0 +1,496 @@ +"""Tests for the data migration in `filename`.""" +import logging +from collections import ChainMap, namedtuple +from datetime import timedelta +from itertools import count +from typing import Dict, Iterable, Type, Union + +from django.db.models import Q +from django.forms.models import model_to_dict +from django.utils import timezone + +from pydis_site.apps.api.models import Infraction, User +from .base import MigrationsTestCase + +log = logging.getLogger(__name__) +log.setLevel(logging.DEBUG) + + +InfractionHistory = namedtuple('InfractionHistory', ("user_id", "infraction_history")) + + +class InfractionFactory: +    """Factory that creates infractions for a User instance.""" + +    infraction_id = count(1) +    user_id = count(1) +    default_values = { +        'active': True, +        'expires_at': None, +        'hidden': False, +    } + +    @classmethod +    def create( +        cls, +        actor: User, +        infractions: Iterable[Dict[str, Union[str, int, bool]]], +        infraction_model: Type[Infraction] = Infraction, +        user_model: Type[User] = User, +    ) -> InfractionHistory: +        """ +        Creates `infractions` for the `user` with the given `actor`. + +        The `infractions` dictionary can contain the following fields: +         - `type` (required) +         - `active` (default: True) +         - `expires_at` (default: None; i.e, permanent) +         - `hidden` (default: False). + +        The parameters `infraction_model` and `user_model` can be used to pass in an instance of +        both model classes from a different migration/project state. +        """ +        user_id = next(cls.user_id) +        user = user_model.objects.create( +            id=user_id, +            name=f"Infracted user {user_id}", +            discriminator=user_id, +            avatar_hash=None, +        ) +        infraction_history = [] + +        for infraction in infractions: +            infraction = dict(infraction) +            infraction["id"] = next(cls.infraction_id) +            infraction = ChainMap(infraction, cls.default_values) +            new_infraction = infraction_model.objects.create( +                user=user, +                actor=actor, +                type=infraction["type"], +                reason=f"`{infraction['type']}` infraction (ID: {infraction['id']} of {user}", +                active=infraction['active'], +                hidden=infraction['hidden'], +                expires_at=infraction['expires_at'], +            ) +            infraction_history.append(new_infraction) + +        return InfractionHistory(user_id=user_id, infraction_history=infraction_history) + + +class InfractionFactoryTests(MigrationsTestCase): +    """Tests for the InfractionFactory.""" + +    app = "api" +    migration_prior = "0046_reminder_jump_url" +    migration_target = "0046_reminder_jump_url" + +    @classmethod +    def setUpPostMigrationData(cls, apps): +        """Create a default actor for all infractions.""" +        cls.infraction_model = apps.get_model('api', 'Infraction') +        cls.user_model = apps.get_model('api', 'User') + +        cls.actor = cls.user_model.objects.create( +            id=9999, +            name="Unknown Moderator", +            discriminator=1040, +            avatar_hash=None, +        ) + +    def test_infraction_factory_total_count(self): +        """Does the test database hold as many infractions as we tried to create?""" +        InfractionFactory.create( +            actor=self.actor, +            infractions=( +                {'type': 'kick', 'active': False, 'hidden': False}, +                {'type': 'ban', 'active': True, 'hidden': False}, +                {'type': 'note', 'active': False, 'hidden': True}, +            ), +            infraction_model=self.infraction_model, +            user_model=self.user_model, +        ) +        database_count = Infraction.objects.all().count() +        self.assertEqual(3, database_count) + +    def test_infraction_factory_multiple_users(self): +        """Does the test database hold as many infractions as we tried to create?""" +        for _user in range(5): +            InfractionFactory.create( +                actor=self.actor, +                infractions=( +                    {'type': 'kick', 'active': False, 'hidden': True}, +                    {'type': 'ban', 'active': True, 'hidden': False}, +                ), +                infraction_model=self.infraction_model, +                user_model=self.user_model, +            ) + +        # Check if infractions and users are recorded properly in the database +        database_count = Infraction.objects.all().count() +        self.assertEqual(database_count, 10) + +        user_count = User.objects.all().count() +        self.assertEqual(user_count, 5 + 1) + +    def test_infraction_factory_sets_correct_fields(self): +        """Does the InfractionFactory set the correct attributes?""" +        infractions = ( +            { +                'type': 'note', +                'active': False, +                'hidden': True, +                'expires_at': timezone.now() +            }, +            {'type': 'warning', 'active': False, 'hidden': False, 'expires_at': None}, +            {'type': 'watch', 'active': False, 'hidden': True, 'expires_at': None}, +            {'type': 'mute', 'active': True, 'hidden': False, 'expires_at': None}, +            {'type': 'kick', 'active': True, 'hidden': True, 'expires_at': None}, +            {'type': 'ban', 'active': True, 'hidden': False, 'expires_at': None}, +            { +                'type': 'superstar', +                'active': True, +                'hidden': True, +                'expires_at': timezone.now() +            }, +        ) + +        InfractionFactory.create( +            actor=self.actor, +            infractions=infractions, +            infraction_model=self.infraction_model, +            user_model=self.user_model, +        ) + +        for infraction in infractions: +            with self.subTest(**infraction): +                self.assertTrue(Infraction.objects.filter(**infraction).exists()) + + +class ActiveInfractionMigrationTests(MigrationsTestCase): +    """ +    Tests the active infraction data migration. + +    The active infraction data migration should do the following things: + +    1.  migrates all active notes, warnings, and kicks to an inactive status; +    2.  migrates all users with multiple active infractions of a single type to have only one active +        infraction of that type. The infraction with the longest duration stays active. +    """ + +    app = "api" +    migration_prior = "0046_reminder_jump_url" +    migration_target = "0047_active_infractions_migration" + +    @classmethod +    def setUpMigrationData(cls, apps): +        """Sets up an initial database state that contains the relevant test cases.""" +        # Fetch the Infraction and User model in the current migration state +        cls.infraction_model = apps.get_model('api', 'Infraction') +        cls.user_model = apps.get_model('api', 'User') + +        cls.created_infractions = {} + +        # Moderator that serves as actor for all infractions +        cls.user_moderator = cls.user_model.objects.create( +            id=9999, +            name="Olivier de Vienne", +            discriminator=1040, +            avatar_hash=None, +        ) + +        # User #1: clean user with no infractions +        cls.created_infractions["no infractions"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=[], +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #2: One inactive note infraction +        cls.created_infractions["one inactive note"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'note', 'active': False, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #3: One active note infraction +        cls.created_infractions["one active note"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'note', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #4: One active and one inactive note infraction +        cls.created_infractions["one active and one inactive note"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'note', 'active': False, 'hidden': True}, +                {'type': 'note', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #5: Once active note, one active kick, once active warning +        cls.created_infractions["active note, kick, warning"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'note', 'active': True, 'hidden': True}, +                {'type': 'kick', 'active': True, 'hidden': True}, +                {'type': 'warning', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #6: One inactive ban and one active ban +        cls.created_infractions["one inactive and one active ban"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'ban', 'active': False, 'hidden': True}, +                {'type': 'ban', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #7: Two active permanent bans +        cls.created_infractions["two active perm bans"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'ban', 'active': True, 'hidden': True}, +                {'type': 'ban', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #8: Multiple active temporary bans +        cls.created_infractions["multiple active temp bans"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=1) +                }, +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=10) +                }, +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=20) +                }, +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=5) +                }, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #9: One active permanent ban, two active temporary bans +        cls.created_infractions["active perm, two active temp bans"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=10) +                }, +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': None, +                }, +                { +                    'type': 'ban', +                    'active': True, +                    'hidden': True, +                    'expires_at': timezone.now() + timedelta(days=7) +                }, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #10: One inactive permanent ban, two active temporary bans +        cls.created_infractions["one inactive perm ban, two active temp bans"] = ( +            InfractionFactory.create( +                actor=cls.user_moderator, +                infractions=( +                    { +                        'type': 'ban', +                        'active': True, +                        'hidden': True, +                        'expires_at': timezone.now() + timedelta(days=10) +                    }, +                    { +                        'type': 'ban', +                        'active': False, +                        'hidden': True, +                        'expires_at': None, +                    }, +                    { +                        'type': 'ban', +                        'active': True, +                        'hidden': True, +                        'expires_at': timezone.now() + timedelta(days=7) +                    }, +                ), +                infraction_model=cls.infraction_model, +                user_model=cls.user_model, +            ) +        ) + +        # User #11: Active ban, active mute, active superstar +        cls.created_infractions["active ban, mute, and superstar"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'ban', 'active': True, 'hidden': True}, +                {'type': 'mute', 'active': True, 'hidden': True}, +                {'type': 'superstar', 'active': True, 'hidden': True}, +                {'type': 'watch', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +        # User #12: Multiple active bans, active mutes, active superstars +        cls.created_infractions["multiple active bans, mutes, stars"] = InfractionFactory.create( +            actor=cls.user_moderator, +            infractions=( +                {'type': 'ban', 'active': True, 'hidden': True}, +                {'type': 'ban', 'active': True, 'hidden': True}, +                {'type': 'ban', 'active': True, 'hidden': True}, +                {'type': 'mute', 'active': True, 'hidden': True}, +                {'type': 'mute', 'active': True, 'hidden': True}, +                {'type': 'mute', 'active': True, 'hidden': True}, +                {'type': 'superstar', 'active': True, 'hidden': True}, +                {'type': 'superstar', 'active': True, 'hidden': True}, +                {'type': 'superstar', 'active': True, 'hidden': True}, +                {'type': 'watch', 'active': True, 'hidden': True}, +                {'type': 'watch', 'active': True, 'hidden': True}, +                {'type': 'watch', 'active': True, 'hidden': True}, +            ), +            infraction_model=cls.infraction_model, +            user_model=cls.user_model, +        ) + +    def test_all_never_active_types_became_inactive(self): +        """Are all infractions of a non-active type inactive after the migration?""" +        inactive_type_query = Q(type="note") | Q(type="warning") | Q(type="kick") +        self.assertFalse( +            self.infraction_model.objects.filter(inactive_type_query, active=True).exists() +        ) + +    def test_migration_left_clean_user_without_infractions(self): +        """Do users without infractions have no infractions after the migration?""" +        user_id, infraction_history = self.created_infractions["no infractions"] +        self.assertFalse( +            self.infraction_model.objects.filter(user__id=user_id).exists() +        ) + +    def test_migration_left_user_with_inactive_note_untouched(self): +        """Did the migration leave users with only an inactive note untouched?""" +        user_id, infraction_history = self.created_infractions["one inactive note"] +        inactive_note = infraction_history[0] +        self.assertTrue( +            self.infraction_model.objects.filter(**model_to_dict(inactive_note)).exists() +        ) + +    def test_migration_only_touched_active_field_of_active_note(self): +        """Does the migration only change the `active` field?""" +        user_id, infraction_history = self.created_infractions["one active note"] +        note = model_to_dict(infraction_history[0]) +        note['active'] = False +        self.assertTrue( +            self.infraction_model.objects.filter(**note).exists() +        ) + +    def test_migration_only_touched_active_field_of_active_note_left_inactive_untouched(self): +        """Does the migration only change the `active` field of active notes?""" +        user_id, infraction_history = self.created_infractions["one active and one inactive note"] +        for note in infraction_history: +            with self.subTest(active=note.active): +                note = model_to_dict(note) +                note['active'] = False +                self.assertTrue( +                    self.infraction_model.objects.filter(**note).exists() +                ) + +    def test_migration_migrates_all_nonactive_types_to_inactive(self): +        """Do we set the `active` field of all non-active infractions to `False`?""" +        user_id, infraction_history = self.created_infractions["active note, kick, warning"] +        self.assertFalse( +            self.infraction_model.objects.filter(user__id=user_id, active=True).exists() +        ) + +    def test_migration_leaves_user_with_one_active_ban_untouched(self): +        """Do we leave a user with one active and one inactive ban untouched?""" +        user_id, infraction_history = self.created_infractions["one inactive and one active ban"] +        for infraction in infraction_history: +            with self.subTest(active=infraction.active): +                self.assertTrue( +                    self.infraction_model.objects.filter(**model_to_dict(infraction)).exists() +                ) + +    def test_migration_turns_double_active_perm_ban_into_single_active_perm_ban(self): +        """Does the migration turn two active permanent bans into one active permanent ban?""" +        user_id, infraction_history = self.created_infractions["two active perm bans"] +        active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() +        self.assertEqual(active_count, 1) + +    def test_migration_leaves_temporary_ban_with_longest_duration_active(self): +        """Does the migration turn two active permanent bans into one active permanent ban?""" +        user_id, infraction_history = self.created_infractions["multiple active temp bans"] +        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) +        self.assertEqual(active_ban.expires_at, infraction_history[2].expires_at) + +    def test_migration_leaves_permanent_ban_active(self): +        """Does the migration leave the permanent ban active?""" +        user_id, infraction_history = self.created_infractions["active perm, two active temp bans"] +        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) +        self.assertIsNone(active_ban.expires_at) + +    def test_migration_leaves_longest_temp_ban_active_with_inactive_permanent_ban(self): +        """Does the longest temp ban stay active, even with an inactive perm ban present?""" +        user_id, infraction_history = self.created_infractions[ +            "one inactive perm ban, two active temp bans" +        ] +        active_ban = self.infraction_model.objects.get(user__id=user_id, active=True) +        self.assertEqual(active_ban.expires_at, infraction_history[0].expires_at) + +    def test_migration_leaves_all_active_types_active_if_one_of_each_exists(self): +        """Do all active infractions stay active if only one of each is present?""" +        user_id, infraction_history = self.created_infractions["active ban, mute, and superstar"] +        active_count = self.infraction_model.objects.filter(user__id=user_id, active=True).count() +        self.assertEqual(active_count, 4) + +    def test_migration_reduces_all_active_types_to_a_single_active_infraction(self): +        """Do we reduce all of the infraction types to one active infraction?""" +        user_id, infraction_history = self.created_infractions["multiple active bans, mutes, stars"] +        active_infractions = self.infraction_model.objects.filter(user__id=user_id, active=True) +        self.assertEqual(len(active_infractions), 4) +        types_observed = [infraction.type for infraction in active_infractions] + +        for infraction_type in ('ban', 'mute', 'superstar', 'watch'): +            with self.subTest(type=infraction_type): +                self.assertIn(infraction_type, types_observed) diff --git a/pydis_site/apps/api/tests/migrations/test_base.py b/pydis_site/apps/api/tests/migrations/test_base.py new file mode 100644 index 00000000..f69bc92c --- /dev/null +++ b/pydis_site/apps/api/tests/migrations/test_base.py @@ -0,0 +1,135 @@ +import logging +from unittest.mock import call, patch + +from django.db.migrations.loader import MigrationLoader +from django.test import TestCase + +from .base import MigrationsTestCase, connection + +log = logging.getLogger(__name__) + + +class SpanishInquisition(MigrationsTestCase): +    app = "api" +    migration_prior = "scragly" +    migration_target = "kosa" + + +@patch("pydis_site.apps.api.tests.migrations.base.MigrationExecutor") +class MigrationsTestCaseNoSideEffectsTests(TestCase): +    """Tests the MigrationTestCase class with actual migration side effects disabled.""" + +    def setUp(self): +        """Set up an instance of MigrationsTestCase for use in tests.""" +        self.test_case = SpanishInquisition() + +    def test_missing_app_class_raises_value_error(self, _migration_executor): +        """A MigrationsTestCase subclass should set the class-attribute `app`.""" +        class Spam(MigrationsTestCase): +            pass + +        spam = Spam() +        with self.assertRaises(ValueError, msg="The `app` attribute was not set."): +            spam.setUpTestData() + +    def test_missing_migration_class_attributes_raise_value_error(self, _migration_executor): +        """A MigrationsTestCase subclass should set both `migration_prior` and `migration_target`""" +        class Eggs(MigrationsTestCase): +            app = "api" +            migration_target = "lemon" + +        class Bacon(MigrationsTestCase): +            app = "api" +            migration_prior = "mark" + +        instances = (Eggs(), Bacon()) + +        exception_message = "Both ` migration_prior` and `migration_target` need to be set." +        for instance in instances: +            with self.subTest( +                    migration_prior=instance.migration_prior, +                    migration_target=instance.migration_target, +            ): +                with self.assertRaises(ValueError, msg=exception_message): +                    instance.setUpTestData() + +    @patch(f"{__name__}.SpanishInquisition.setUpMigrationData") +    @patch(f"{__name__}.SpanishInquisition.setUpPostMigrationData") +    def test_migration_data_hooks_are_called_once(self, pre_hook, post_hook, _migration_executor): +        """The `setUpMigrationData` and `setUpPostMigrationData` hooks should be called once.""" +        self.test_case.setUpTestData() +        for hook in (pre_hook, post_hook): +            with self.subTest(hook=repr(hook)): +                hook.assert_called_once() + +    def test_migration_executor_is_instantiated_twice(self, migration_executor): +        """The `MigrationExecutor` should be instantiated with the database connection twice.""" +        self.test_case.setUpTestData() + +        expected_args = [call(connection), call(connection)] +        self.assertEqual(migration_executor.call_args_list, expected_args) + +    def test_project_state_is_loaded_for_correct_migration_files_twice(self, migration_executor): +        """The `project_state` should first be loaded with `migrate_from`, then `migrate_to`.""" +        self.test_case.setUpTestData() + +        expected_args = [call(self.test_case.migrate_from), call(self.test_case.migrate_to)] +        self.assertEqual(migration_executor().loader.project_state.call_args_list, expected_args) + +    def test_loader_build_graph_gets_called_once(self, migration_executor): +        """We should rebuild the migration graph before applying the second set of migrations.""" +        self.test_case.setUpTestData() + +        migration_executor().loader.build_graph.assert_called_once() + +    def test_migration_executor_migrate_method_is_called_correctly_twice(self, migration_executor): +        """The migrate method of the executor should be called twice with the correct arguments.""" +        self.test_case.setUpTestData() + +        self.assertEqual(migration_executor().migrate.call_count, 2) +        calls = [call([('api', 'scragly')]), call([('api', 'kosa')])] +        migration_executor().migrate.assert_has_calls(calls) + + +class LifeOfBrian(MigrationsTestCase): +    app = "api" +    migration_prior = "0046_reminder_jump_url" +    migration_target = "0048_add_infractions_unique_constraints_active" + +    @classmethod +    def log_last_migration(cls): +        """Parses the applied migrations dictionary to log the last applied migration.""" +        loader = MigrationLoader(connection) +        api_migrations = [ +            migration for app, migration in loader.applied_migrations if app == cls.app +        ] +        last_migration = max(api_migrations, key=lambda name: int(name[:4])) +        log.info(f"The last applied migration: {last_migration}") + +    @classmethod +    def setUpMigrationData(cls, apps): +        """Method that logs the last applied migration at this point.""" +        cls.log_last_migration() + +    @classmethod +    def setUpPostMigrationData(cls, apps): +        """Method that logs the last applied migration at this point.""" +        cls.log_last_migration() + + +class MigrationsTestCaseMigrationTest(TestCase): +    """Tests if `MigrationsTestCase` travels to the right points in the migration history.""" + +    def test_migrations_test_case_travels_to_correct_migrations_in_history(self): +        """The test case should first revert to `migration_prior`, then go to `migration_target`.""" +        brian = LifeOfBrian() + +        with self.assertLogs(log, level=logging.INFO) as logs: +            brian.setUpTestData() + +            self.assertEqual(len(logs.records), 2) + +            for time_point, record in zip(("migration_prior", "migration_target"), logs.records): +                with self.subTest(time_point=time_point): +                    message = f"The last applied migration: {getattr(brian, time_point)}" +                    self.assertEqual(record.getMessage(), message) diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index d1e9f2f5..b3a8197b 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -25,14 +25,16 @@ class DeletedMessagesWithoutActorTests(APISubdomainTestCase):                      'id': 55,                      'channel_id': 5555,                      'content': "Terror Billy is a meanie", -                    'embeds': [] +                    'embeds': [], +                    'attachments': []                  },                  {                      'author': cls.author.id,                      'id': 56,                      'channel_id': 5555,                      'content': "If you purge this, you're evil", -                    'embeds': [] +                    'embeds': [], +                    'attachments': []                  }              ]          } @@ -64,7 +66,8 @@ class DeletedMessagesWithActorTests(APISubdomainTestCase):                      'id': 12903,                      'channel_id': 1824,                      'content': "I hate trailing commas", -                    'embeds': [] +                    'embeds': [], +                    'attachments': []                  },              ]          } diff --git a/pydis_site/apps/api/tests/test_infractions.py b/pydis_site/apps/api/tests/test_infractions.py index c58c32e2..7a54640e 100644 --- a/pydis_site/apps/api/tests/test_infractions.py +++ b/pydis_site/apps/api/tests/test_infractions.py @@ -1,6 +1,8 @@  from datetime import datetime as dt, timedelta, timezone +from unittest.mock import patch  from urllib.parse import quote +from django.db.utils import IntegrityError  from django_hosts.resolvers import reverse  from .base import APISubdomainTestCase @@ -167,6 +169,12 @@ class CreationTests(APISubdomainTestCase):              discriminator=1,              avatar_hash=None          ) +        cls.second_user = User.objects.create( +            id=6, +            name='carl', +            discriminator=2, +            avatar_hash=None +        )      def test_accepts_valid_data(self):          url = reverse('bot:infraction-list', host='api') @@ -305,6 +313,187 @@ class CreationTests(APISubdomainTestCase):              'hidden': [f'{data["type"]} infractions must be hidden.']          }) +    def test_returns_400_for_active_infraction_of_type_that_cannot_be_active(self): +        """Test if the API rejects active infractions for types that cannot be active.""" +        url = reverse('bot:infraction-list', host='api') +        restricted_types = ( +            ('note', True), +            ('warning', False), +            ('kick', False), +        ) + +        for infraction_type, hidden in restricted_types: +            with self.subTest(infraction_type=infraction_type): +                invalid_infraction = { +                    'user': self.user.id, +                    'actor': self.user.id, +                    'type': infraction_type, +                    'reason': 'Take me on!', +                    'hidden': hidden, +                    'active': True, +                    'expires_at': None, +                } +                response = self.client.post(url, data=invalid_infraction) +                self.assertEqual(response.status_code, 400) +                self.assertEqual( +                    response.json(), +                    {'active': [f'{infraction_type} infractions cannot be active.']} +                ) + +    def test_returns_400_for_second_active_infraction_of_the_same_type(self): +        """Test if the API rejects a second active infraction of the same type for a given user.""" +        url = reverse('bot:infraction-list', host='api') +        active_infraction_types = ('mute', 'ban', 'superstar') + +        for infraction_type in active_infraction_types: +            with self.subTest(infraction_type=infraction_type): +                first_active_infraction = { +                    'user': self.user.id, +                    'actor': self.user.id, +                    'type': infraction_type, +                    'reason': 'Take me on!', +                    'active': True, +                    'expires_at': '2019-10-04T12:52:00+00:00' +                } + +                # Post the first active infraction of a type and confirm it's accepted. +                first_response = self.client.post(url, data=first_active_infraction) +                self.assertEqual(first_response.status_code, 201) + +                second_active_infraction = { +                    'user': self.user.id, +                    'actor': self.user.id, +                    'type': infraction_type, +                    'reason': 'Take on me!', +                    'active': True, +                    'expires_at': '2019-10-04T12:52:00+00:00' +                } +                second_response = self.client.post(url, data=second_active_infraction) +                self.assertEqual(second_response.status_code, 400) +                self.assertEqual( +                    second_response.json(), +                    { +                        'non_field_errors': [ +                            'This user already has an active infraction of this type.' +                        ] +                    } +                ) + +    def test_returns_201_for_second_active_infraction_of_different_type(self): +        """Test if the API accepts a second active infraction of a different type than the first.""" +        url = reverse('bot:infraction-list', host='api') +        first_active_infraction = { +            'user': self.user.id, +            'actor': self.user.id, +            'type': 'mute', +            'reason': 'Be silent!', +            'hidden': True, +            'active': True, +            'expires_at': '2019-10-04T12:52:00+00:00' +        } +        second_active_infraction = { +            'user': self.user.id, +            'actor': self.user.id, +            'type': 'ban', +            'reason': 'Be gone!', +            'hidden': True, +            'active': True, +            'expires_at': '2019-10-05T12:52:00+00:00' +        } +        # Post the first active infraction of a type and confirm it's accepted. +        first_response = self.client.post(url, data=first_active_infraction) +        self.assertEqual(first_response.status_code, 201) + +        # Post the first active infraction of a type and confirm it's accepted. +        second_response = self.client.post(url, data=second_active_infraction) +        self.assertEqual(second_response.status_code, 201) + +    def test_unique_constraint_raises_integrity_error_on_second_active_of_same_type(self): +        """Do we raise `IntegrityError` for the second active infraction of a type for a user?""" +        Infraction.objects.create( +            user=self.user, +            actor=self.user, +            type="ban", +            active=True, +            reason="The first active ban" +        ) +        with self.assertRaises(IntegrityError): +            Infraction.objects.create( +                user=self.user, +                actor=self.user, +                type="ban", +                active=True, +                reason="The second active ban" +            ) + +    def test_unique_constraint_accepts_active_infraction_after_inactive_infraction(self): +        """Do we accept an active infraction if the others of the same type are inactive?""" +        try: +            Infraction.objects.create( +                user=self.user, +                actor=self.user, +                type="ban", +                active=False, +                reason="The first inactive ban" +            ) +            Infraction.objects.create( +                user=self.user, +                actor=self.user, +                type="ban", +                active=False, +                reason="The second inactive ban" +            ) +            Infraction.objects.create( +                user=self.user, +                actor=self.user, +                type="ban", +                active=True, +                reason="The first active ban" +            ) +        except IntegrityError: +            self.fail("An unexpected IntegrityError was raised.") + +    @patch(f"{__name__}.Infraction") +    def test_if_accepts_active_infraction_test_catches_integrity_error(self, infraction_patch): +        """Does the test properly catch the IntegrityError and raise an AssertionError?""" +        infraction_patch.objects.create.side_effect = IntegrityError +        with self.assertRaises(AssertionError, msg="An unexpected IntegrityError was raised."): +            self.test_unique_constraint_accepts_active_infraction_after_inactive_infraction() + +    def test_unique_constraint_accepts_second_active_of_different_type(self): +        """Do we accept a second active infraction of a different type for a given user?""" +        Infraction.objects.create( +            user=self.user, +            actor=self.user, +            type="ban", +            active=True, +            reason="The first active ban" +        ) +        Infraction.objects.create( +            user=self.user, +            actor=self.user, +            type="mute", +            active=True, +            reason="The first active mute" +        ) + +    def test_unique_constraint_accepts_active_infractions_for_different_users(self): +        """Do we accept two active infractions of the same type for two different users?""" +        Infraction.objects.create( +            user=self.user, +            actor=self.user, +            type="ban", +            active=True, +            reason="An active ban for the first user" +        ) +        Infraction.objects.create( +            user=self.second_user, +            actor=self.second_user, +            type="ban", +            active=False, +            reason="An active ban for the second user" +        ) +  class ExpandedTests(APISubdomainTestCase):      @classmethod @@ -318,12 +507,14 @@ class ExpandedTests(APISubdomainTestCase):          cls.kick = Infraction.objects.create(              user_id=cls.user.id,              actor_id=cls.user.id, -            type='kick' +            type='kick', +            active=False          )          cls.warning = Infraction.objects.create(              user_id=cls.user.id,              actor_id=cls.user.id, -            type='warning' +            type='warning', +            active=False,          )      def check_expanded_fields(self, infraction): diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index b4a766d0..a97d3251 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -12,6 +12,7 @@ from ..models import (      ModelReprMixin,      Nomination,      OffTopicChannelName, +    OffensiveMessage,      Reminder,      Role,      Tag, @@ -69,6 +70,11 @@ class StringDunderMethodTests(SimpleTestCase):              DocumentationLink(                  'test', 'http://example.com', 'http://example.com'              ), +            OffensiveMessage( +                id=602951077675139072, +                channel_id=291284109232308226, +                delete_date=dt(3000, 1, 1) +            ),              OffTopicChannelName(name='bob-the-builders-playground'),              Role(                  id=5, name='test role', diff --git a/pydis_site/apps/api/tests/test_offensive_message.py b/pydis_site/apps/api/tests/test_offensive_message.py new file mode 100644 index 00000000..d5896714 --- /dev/null +++ b/pydis_site/apps/api/tests/test_offensive_message.py @@ -0,0 +1,155 @@ +import datetime + +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..models import OffensiveMessage + + +class CreationTests(APISubdomainTestCase): +    def test_accept_valid_data(self): +        url = reverse('bot:offensivemessage-list', host='api') +        delete_at = datetime.datetime.now() + datetime.timedelta(days=1) +        data = { +            'id': '602951077675139072', +            'channel_id': '291284109232308226', +            'delete_date': delete_at.isoformat()[:-1] +        } + +        aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 201) + +        offensive_message = OffensiveMessage.objects.get(id=response.json()['id']) +        self.assertAlmostEqual( +            aware_delete_at, +            offensive_message.delete_date, +            delta=datetime.timedelta(seconds=1) +        ) +        self.assertEqual(data['id'], str(offensive_message.id)) +        self.assertEqual(data['channel_id'], str(offensive_message.channel_id)) + +    def test_returns_400_on_non_future_date(self): +        url = reverse('bot:offensivemessage-list', host='api') +        delete_at = datetime.datetime.now() - datetime.timedelta(days=1) +        data = { +            'id': '602951077675139072', +            'channel_id': '291284109232308226', +            'delete_date': delete_at.isoformat()[:-1] +        } +        response = self.client.post(url, data=data) +        self.assertEqual(response.status_code, 400) +        self.assertEqual(response.json(), { +            'delete_date': ['Date must be a future date'] +        }) + +    def test_returns_400_on_negative_id_or_channel_id(self): +        url = reverse('bot:offensivemessage-list', host='api') +        delete_at = datetime.datetime.now() + datetime.timedelta(days=1) +        data = { +            'id': '602951077675139072', +            'channel_id': '291284109232308226', +            'delete_date': delete_at.isoformat()[:-1] +        } +        cases = ( +            ('id', '-602951077675139072'), +            ('channel_id', '-291284109232308226') +        ) + +        for field, invalid_value in cases: +            with self.subTest(fied=field, invalid_value=invalid_value): +                test_data = data.copy() +                test_data.update({field: invalid_value}) + +                response = self.client.post(url, test_data) +                self.assertEqual(response.status_code, 400) +                self.assertEqual(response.json(), { +                    field: ['Ensure this value is greater than or equal to 0.'] +                }) + + +class ListTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        delete_at = datetime.datetime.now() + datetime.timedelta(days=1) +        aware_delete_at = delete_at.replace(tzinfo=datetime.timezone.utc) + +        cls.messages = [ +            { +                'id': 602951077675139072, +                'channel_id': 91284109232308226, +            }, +            { +                'id': 645298201494159401, +                'channel_id': 592000283102674944 +            } +        ] + +        cls.of1 = OffensiveMessage.objects.create( +            **cls.messages[0], +            delete_date=aware_delete_at.isoformat() +        ) +        cls.of2 = OffensiveMessage.objects.create( +            **cls.messages[1], +            delete_date=aware_delete_at.isoformat() +        ) + +        # Expected API answer : +        cls.messages[0]['delete_date'] = delete_at.isoformat() + 'Z' +        cls.messages[1]['delete_date'] = delete_at.isoformat() + 'Z' + +    def test_get_data(self): +        url = reverse('bot:offensivemessage-list', host='api') + +        response = self.client.get(url) +        self.assertEqual(response.status_code, 200) + +        self.assertEqual(response.json(), self.messages) + + +class DeletionTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + +        cls.valid_offensive_message = OffensiveMessage.objects.create( +            id=602951077675139072, +            channel_id=291284109232308226, +            delete_date=delete_at.isoformat() +        ) + +    def test_delete_data(self): +        url = reverse( +            'bot:offensivemessage-detail', host='api', args=(self.valid_offensive_message.id,) +        ) + +        response = self.client.delete(url) +        self.assertEqual(response.status_code, 204) + +        self.assertFalse( +            OffensiveMessage.objects.filter(id=self.valid_offensive_message.id).exists() +        ) + + +class NotAllowedMethodsTests(APISubdomainTestCase): +    @classmethod +    def setUpTestData(cls):  # noqa +        delete_at = datetime.datetime.now(tz=datetime.timezone.utc) + datetime.timedelta(days=1) + +        cls.valid_offensive_message = OffensiveMessage.objects.create( +            id=602951077675139072, +            channel_id=291284109232308226, +            delete_date=delete_at.isoformat() +        ) + +    def test_returns_405_for_patch_and_put_requests(self): +        url = reverse( +            'bot:offensivemessage-detail', host='api', args=(self.valid_offensive_message.id,) +        ) +        not_allowed_methods = (self.client.patch, self.client.put) + +        for method in not_allowed_methods: +            with self.subTest(method=method): +                response = method(url, {}) +                self.assertEqual(response.status_code, 405) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index 4222f0c0..241af08c 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -1,7 +1,10 @@ +from datetime import datetime, timezone +  from django.core.exceptions import ValidationError  from django.test import TestCase  from ..models.bot.bot_setting import validate_bot_setting_name +from ..models.bot.offensive_message import future_date_validator  from ..models.bot.tag import validate_tag_embed @@ -245,3 +248,12 @@ class TagEmbedValidatorTests(TestCase):                  'name': "Bob"              }          }) + + +class OffensiveMessageValidatorsTests(TestCase): +    def test_accepts_future_date(self): +        future_date_validator(datetime(3000, 1, 1, tzinfo=timezone.utc)) + +    def test_rejects_non_future_date(self): +        with self.assertRaises(ValidationError): +            future_date_validator(datetime(1000, 1, 1, tzinfo=timezone.utc)) diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index ac6704c8..4a0281b4 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -6,7 +6,8 @@ from .viewsets import (      BotSettingViewSet, DeletedMessageViewSet,      DocumentationLinkViewSet, InfractionViewSet,      LogEntryViewSet, NominationViewSet, -    OffTopicChannelNameViewSet, ReminderViewSet, +    OffTopicChannelNameViewSet, +    OffensiveMessageViewSet, ReminderViewSet,      RoleViewSet, TagViewSet, UserViewSet  ) @@ -34,6 +35,10 @@ bot_router.register(      NominationViewSet  )  bot_router.register( +    'offensive-messages', +    OffensiveMessageViewSet +) +bot_router.register(      'off-topic-channel-names',      OffTopicChannelNameViewSet,      base_name='offtopicchannelname' diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index f0f7efa9..fd5a6d4d 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -110,7 +110,7 @@ class RulesView(APIView):          )          discord_tos = self._format_link(              'Terms Of Service', -            'https://discordapp.com/guidelines', +            'https://discordapp.com/terms',              link_format          )          pydis_coc = self._format_link( diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index f9a186d9..3cf9f641 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -5,6 +5,7 @@ from .bot import (      DocumentationLinkViewSet,      InfractionViewSet,      NominationViewSet, +    OffensiveMessageViewSet,      OffTopicChannelNameViewSet,      ReminderViewSet,      RoleViewSet, diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index f1851e32..b3e0fa4d 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -5,6 +5,7 @@ from .documentation_link import DocumentationLinkViewSet  from .infraction import InfractionViewSet  from .nomination import NominationViewSet  from .off_topic_channel_name import OffTopicChannelNameViewSet +from .offensive_message import OffensiveMessageViewSet  from .reminder import ReminderViewSet  from .role import RoleViewSet  from .tag import TagViewSet diff --git a/pydis_site/apps/api/viewsets/bot/offensive_message.py b/pydis_site/apps/api/viewsets/bot/offensive_message.py new file mode 100644 index 00000000..54cb3a38 --- /dev/null +++ b/pydis_site/apps/api/viewsets/bot/offensive_message.py @@ -0,0 +1,61 @@ +from rest_framework.mixins import ( +    CreateModelMixin, +    DestroyModelMixin, +    ListModelMixin +) +from rest_framework.viewsets import GenericViewSet + +from pydis_site.apps.api.models.bot.offensive_message import OffensiveMessage +from pydis_site.apps.api.serializers import OffensiveMessageSerializer + + +class OffensiveMessageViewSet( +    CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet +): +    """ +    View providing CRUD access to offensive messages. + +    ## Routes +    ### GET /bot/offensive-messages +    Returns all offensive messages in the database. + +    #### Response format +    >>> [ +    ...     { +    ...         'id': '631953598091100200', +    ...         'channel_id': '291284109232308226', +    ...         'delete_date': '2019-11-01T21:51:15.545000Z' +    ...     }, +    ...     ... +    ... ] + +    #### Status codes +    - 200: returned on success + +    ### POST /bot/offensive-messages +    Create a new offensive message object. + +    #### Request body +    >>> { +    ...     'id': int, +    ...     'channel_id': int, +    ...     'delete_date': datetime.datetime  # ISO-8601-formatted date +    ... } + +    #### Status codes +    - 201: returned on success +    - 400: if the body format is invalid + +    ### DELETE /bot/offensive-messages/<id:int> +    Delete the offensive message object with the given `id`. + +    #### Status codes +    - 204: returned on success +    - 404: if a offensive message object with the given `id` does not exist + +    ## Authentication +    Requires an API token. +    """ + +    serializer_class = OffensiveMessageSerializer +    queryset = OffensiveMessage.objects.all() diff --git a/pydis_site/apps/home/forms/__init__.py b/pydis_site/apps/home/forms/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/pydis_site/apps/home/forms/__init__.py diff --git a/pydis_site/apps/home/forms/account_deletion.py b/pydis_site/apps/home/forms/account_deletion.py new file mode 100644 index 00000000..17ffe5c1 --- /dev/null +++ b/pydis_site/apps/home/forms/account_deletion.py @@ -0,0 +1,24 @@ +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Layout +from django.forms import CharField, Form +from django_crispy_bulma.layout import IconField, Submit + + +class AccountDeletionForm(Form): +    """Account deletion form, to collect username for confirmation of removal.""" + +    def __init__(self, *args, **kwargs): +        super().__init__(*args, **kwargs) +        self.helper = FormHelper() + +        self.helper.form_method = "post" +        self.helper.add_input(Submit("submit", "I understand, delete my account")) + +        self.helper.layout = Layout( +            IconField("username", icon_prepend="user") +        ) + +    username = CharField( +        label="Username", +        required=True +    ) diff --git a/pydis_site/apps/home/signals.py b/pydis_site/apps/home/signals.py index 9f286882..4cb4564b 100644 --- a/pydis_site/apps/home/signals.py +++ b/pydis_site/apps/home/signals.py @@ -1,3 +1,4 @@ +from contextlib import suppress  from typing import List, Optional, Type  from allauth.account.signals import user_logged_in @@ -8,7 +9,7 @@ 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_delete, pre_save +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 @@ -37,7 +38,7 @@ class AllauthSignalListener:      def __init__(self):          post_save.connect(self.user_model_updated, sender=DiscordUser) -        pre_delete.connect(self.mapping_model_deleted, sender=RoleMapping) +        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) @@ -133,13 +134,29 @@ class AllauthSignalListener:          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. +        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__in=discord_user.roles.all()).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. @@ -174,6 +191,21 @@ class AllauthSignalListener:          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__in=discord_user.roles.all() +                ).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. @@ -230,31 +262,53 @@ class AllauthSignalListener:          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 not current_groups: -                return  # They have no groups anyway, no point in processing +            if current_groups: +                # They do have groups, so let's remove them +                account.user.groups.remove( +                    *(mapping.group for mapping in mappings) +                ) -            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.all():                  try: -                    new_groups.append(mappings.get(role=role).group) +                    mapping = mappings.get(role=role)                  except RoleMapping.DoesNotExist:                      continue  # No mapping exists -                account.user.groups.add( -                    *[group for group in new_groups if group not in current_groups] -                ) +                new_groups.append(mapping.group) -                account.user.groups.remove( -                    *[mapping.group for mapping in mappings if mapping.group not in new_groups] -                ) +                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 index 27fc7710..66a67252 100644 --- a/pydis_site/apps/home/tests/test_signal_listener.py +++ b/pydis_site/apps/home/tests/test_signal_listener.py @@ -67,12 +67,14 @@ class SignalListenerTests(TestCase):          cls.admin_mapping = RoleMapping.objects.create(              role=cls.admin_role, -            group=cls.admin_group +            group=cls.admin_group, +            is_staff=True          )          cls.moderator_mapping = RoleMapping.objects.create(              role=cls.moderator_role, -            group=cls.moderator_group +            group=cls.moderator_group, +            is_staff=False          )          cls.discord_user = DiscordUser.objects.create( @@ -166,7 +168,7 @@ class SignalListenerTests(TestCase):          cls.django_moderator = DjangoUser.objects.create(              username="moderator", -            is_staff=True, +            is_staff=False,              is_superuser=False          ) @@ -339,6 +341,33 @@ class SignalListenerTests(TestCase):          self.discord_admin.roles.add(self.admin_role)          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.add(self.moderator_role) +        self.discord_moderator.save() +      def test_apply_groups_other(self):          """Test application of groups by role, relating to non-standard cases."""          handler = AllauthSignalListener() @@ -373,10 +402,25 @@ class SignalListenerTests(TestCase):          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 @@ -388,12 +432,30 @@ class SignalListenerTests(TestCase):          # Test mapping creation          new_mapping = RoleMapping.objects.create(              role=self.admin_role, -            group=self.moderator_group +            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) diff --git a/pydis_site/apps/home/tests/test_views.py b/pydis_site/apps/home/tests/test_views.py index 7aeaddd2..572317a7 100644 --- a/pydis_site/apps/home/tests/test_views.py +++ b/pydis_site/apps/home/tests/test_views.py @@ -1,5 +1,198 @@ +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 reverse +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()  class TestIndexReturns200(TestCase): @@ -16,6 +209,7 @@ class TestLoginCancelledReturns302(TestCase):          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): @@ -24,3 +218,4 @@ class TestLoginErrorReturns302(TestCase):          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 211a7ad1..61e87a39 100644 --- a/pydis_site/apps/home/urls.py +++ b/pydis_site/apps/home/urls.py @@ -1,5 +1,4 @@  from allauth.account.views import LogoutView -from allauth.socialaccount.views import ConnectionsView  from django.conf import settings  from django.conf.urls.static import static  from django.contrib import admin @@ -7,14 +6,18 @@ from django.contrib.messages import ERROR  from django.urls import include, path  from pydis_site.utils.views import MessageRedirectView -from .views import HomeView +from .views import AccountDeleteView, AccountSettingsView, 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('pages/', include('wiki.urls')),      path('accounts/', include('allauth.socialaccount.providers.discord.urls')), +    path('accounts/', include('allauth.socialaccount.providers.github.urls')),      path(          'accounts/login/cancelled', MessageRedirectView.as_view( @@ -28,7 +31,9 @@ urlpatterns = [          ), name='socialaccount_login_error'      ), -    path('connections', ConnectionsView.as_view()), +    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), diff --git a/pydis_site/apps/home/views/__init__.py b/pydis_site/apps/home/views/__init__.py index 971d73a3..801fd398 100644 --- a/pydis_site/apps/home/views/__init__.py +++ b/pydis_site/apps/home/views/__init__.py @@ -1,3 +1,4 @@ +from .account import DeleteView as AccountDeleteView, SettingsView as AccountSettingsView  from .home import HomeView -__all__ = ["HomeView"] +__all__ = ["AccountDeleteView", "AccountSettingsView", "HomeView"] diff --git a/pydis_site/apps/home/views/account/__init__.py b/pydis_site/apps/home/views/account/__init__.py new file mode 100644 index 00000000..3b3250ea --- /dev/null +++ b/pydis_site/apps/home/views/account/__init__.py @@ -0,0 +1,4 @@ +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 new file mode 100644 index 00000000..798b8a33 --- /dev/null +++ b/pydis_site/apps/home/views/account/delete.py @@ -0,0 +1,37 @@ +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 new file mode 100644 index 00000000..3a817dbc --- /dev/null +++ b/pydis_site/apps/home/views/account/settings.py @@ -0,0 +1,59 @@ +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/staff/migrations/0002_add_is_staff_to_role_mappings.py b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py new file mode 100644 index 00000000..0404d270 --- /dev/null +++ b/pydis_site/apps/staff/migrations/0002_add_is_staff_to_role_mappings.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.6 on 2019-10-20 14:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + +    dependencies = [ +        ('staff', '0001_initial'), +    ] + +    operations = [ +        migrations.AddField( +            model_name='rolemapping', +            name='is_staff', +            field=models.BooleanField(default=False, help_text='Whether this role mapping relates to a Django staff group'), +        ), +    ] diff --git a/pydis_site/apps/staff/models/role_mapping.py b/pydis_site/apps/staff/models/role_mapping.py index 10c09cf1..8a1fac2e 100644 --- a/pydis_site/apps/staff/models/role_mapping.py +++ b/pydis_site/apps/staff/models/role_mapping.py @@ -21,6 +21,11 @@ class RoleMapping(models.Model):          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/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py index 32cb6bbf..1415c558 100644 --- a/pydis_site/apps/staff/tests/test_logs_view.py +++ b/pydis_site/apps/staff/tests/test_logs_view.py @@ -37,6 +37,7 @@ class TestLogsView(TestCase):              channel_id=1984,              content='<em>I think my tape has run out...</em>',              embeds=[], +            attachments=[],              deletion_context=cls.deletion_context,          ) @@ -101,6 +102,7 @@ class TestLogsView(TestCase):              channel_id=1984,              content='Does that mean this thing will halt?',              embeds=[cls.embed_one, cls.embed_two], +            attachments=['https://http.cat/100', 'https://http.cat/402'],              deletion_context=cls.deletion_context,          ) @@ -149,6 +151,21 @@ class TestLogsView(TestCase):          self.assertInHTML(embed_colour_needle.format(colour=embed_one_colour), html_response)          self.assertInHTML(embed_colour_needle.format(colour=embed_two_colour), html_response) +    def test_if_both_attachments_are_included_html_response(self): +        url = reverse('logs', host="staff", args=(self.deletion_context.id,)) +        response = self.client.get(url) + +        html_response = response.content.decode() +        attachment_needle = '<img alt="Attachment" class="discord-attachment" src="{url}">' +        self.assertInHTML( +            attachment_needle.format(url=self.deleted_message_two.attachments[0]), +            html_response +        ) +        self.assertInHTML( +            attachment_needle.format(url=self.deleted_message_two.attachments[1]), +            html_response +        ) +      def test_if_html_in_content_is_properly_escaped(self):          url = reverse('logs', host="staff", args=(self.deletion_context.id,))          response = self.client.get(url) diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 94718ec7..72cc0ab9 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -92,6 +92,7 @@ INSTALLED_APPS = [      'allauth.socialaccount',      'allauth.socialaccount.providers.discord', +    'allauth.socialaccount.providers.github',      'crispy_forms',      'django_crispy_bulma', @@ -302,14 +303,14 @@ BULMA_SETTINGS = {      "variables": {  # If you update these colours, please update the notification.css file          "primary": "#7289DA",    # Discord blurple -        "orange": "#ffb39b",     # Bulma default, but at a saturation of 100 -        "yellow": "#ffea9b",     # Bulma default, but at a saturation of 100 -        "green": "#7fd19c",      # Bulma default, but at a saturation of 100 -        "turquoise": "#7289DA",  # Blurple, because Bulma uses this as the default primary -        "cyan": "#91cbee",       # Bulma default, but at a saturation of 100 -        "blue": "#86a7dc",       # Bulma default, but at a saturation of 100 -        "purple": "#b86bff",     # Bulma default, but at a saturation of 100 -        "red": "#ffafc2",        # Bulma default, but at a saturation of 80 +        # "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 +        "cyan": "#2482c1",       # Colour picked after Discord discussion (matches the blue) +        "purple": "#aa55e4",     # Apparently unused, but changed for consistency +        "red": "#d63852",        # Colour picked after Discord discussion          "link": "$primary", @@ -372,10 +373,11 @@ WIKI_MARKDOWN_HTML_ATTRIBUTES = {      'img': ['class', 'id', 'src', 'alt', 'width', 'height'],      'section': ['class', 'id'],      'article': ['class', 'id'], +    'iframe': ['width', 'height', 'src', 'frameborder', 'allow', 'allowfullscreen'],  }  WIKI_MARKDOWN_HTML_WHITELIST = [ -    'article', 'section', 'button' +    'article', 'section', 'button', 'iframe'  ] @@ -407,5 +409,13 @@ AUTHENTICATION_BACKENDS = (      '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" diff --git a/pydis_site/static/css/base/base.css b/pydis_site/static/css/base/base.css index 3ca6b2a7..dc7c504d 100644 --- a/pydis_site/static/css/base/base.css +++ b/pydis_site/static/css/base/base.css @@ -84,7 +84,30 @@ div.card.has-equal-height {  /* Fix for logout form submit button in navbar */ -button.is-size-navbar-menu { +button.is-size-navbar-menu, a.is-size-navbar-menu {      font-size: 14px; +    padding-left: 1.5rem; +    padding-right: 1.5rem;  } +@media screen and (min-width: 1088px) { +    button.is-size-navbar-menu, a.is-size-navbar-menu { +        padding-left: 1rem; +        padding-right: 1rem; +    } +} + +/* Fix for modals being behind the navbar */ + +.modal * { +    z-index: 1020; +} + +.modal-background { +    z-index: 1010; +} + +/* Wiki style tweaks */ +.codehilite-wrap { +    margin-bottom: 1em; +} diff --git a/pydis_site/static/js/base/modal.js b/pydis_site/static/js/base/modal.js new file mode 100644 index 00000000..eccc8845 --- /dev/null +++ b/pydis_site/static/js/base/modal.js @@ -0,0 +1,100 @@ +/* + 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 a9b31c0f..4c70d778 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -28,6 +28,7 @@    {# Font-awesome here is defined explicitly so that we can have Pro #}    <script src="https://kit.fontawesome.com/ae6a3152d8.js"></script> +  <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" %}"> diff --git a/pydis_site/templates/base/navbar.html b/pydis_site/templates/base/navbar.html index 8cdac0de..2ba5bdd4 100644 --- a/pydis_site/templates/base/navbar.html +++ b/pydis_site/templates/base/navbar.html @@ -102,7 +102,15 @@            {% else %}              <form method="post" action="{% url 'logout' %}">                {% csrf_token %} -              <button type="submit" class="navbar-item button is-white is-inline is-fullwidth has-text-left is-size-navbar-menu has-text-grey-dark">Logout</button> + +              <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 %} @@ -116,3 +124,24 @@      </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 new file mode 100644 index 00000000..1020a82b --- /dev/null +++ b/pydis_site/templates/home/account/delete.html @@ -0,0 +1,44 @@ +{% extends 'base/base.html' %} + +{% load crispy_forms_tags %} +{% 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"> +          {% crispy form %} +        </div> +      </div> +    </div> +  </section> +{% endblock %} diff --git a/pydis_site/templates/home/account/settings.html b/pydis_site/templates/home/account/settings.html new file mode 100644 index 00000000..9f48d736 --- /dev/null +++ b/pydis_site/templates/home/account/settings.html @@ -0,0 +1,137 @@ +{% 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/home/index.html b/pydis_site/templates/home/index.html index dfcc6715..3b150767 100644 --- a/pydis_site/templates/home/index.html +++ b/pydis_site/templates/home/index.html @@ -37,11 +37,11 @@            </p>          </div> -        {# Intro video #} +        {# Code Jam banner #}          <div class="column is-half-desktop video-container"> -          <iframe src="https://www.youtube.com/embed/DIBXg8Qh7bA" frameborder="0" -                  allow="accelerometer; encrypted-media; gyroscope; picture-in-picture" -                  allowfullscreen></iframe> +          <a href="https://pythondiscord.com/pages/code-jams/code-jam-6/"> +            <img src="https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_discord_banner/code%20jam%206%20-%20website%20banner.png"/> +          </a>          </div>        </div> diff --git a/pydis_site/templates/staff/logs.html b/pydis_site/templates/staff/logs.html index 907c3327..8c92836a 100644 --- a/pydis_site/templates/staff/logs.html +++ b/pydis_site/templates/staff/logs.html @@ -24,6 +24,11 @@              <div class="discord-message-content">                  {{ message.content | escape | visible_newlines | safe }}              </div> +            <div class="discord-message-attachments"> +                {% for attachment in message.attachments %} +                    <img alt="Attachment" class="discord-attachment" src="{{ attachment }}"> +                {% endfor %} +            </div>              {% for embed in message.embeds %}                  <div class="discord-embed is-size-7">                      <div class="discord-embed-color" style="background-color: {% if embed.color %}{{ embed.color | hex_colour }}{% else %}#cacbce{% endif %}"></div> diff --git a/pydis_site/tests/test_utils_account.py b/pydis_site/tests/test_utils_account.py new file mode 100644 index 00000000..d2946368 --- /dev/null +++ b/pydis_site/tests/test_utils_account.py @@ -0,0 +1,132 @@ +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 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): +        self.django_user = User.objects.create(username="user") + +        self.discord_account = SocialAccount.objects.create( +            user=self.django_user, provider="discord", uid=0 +        ) + +        self.discord_account_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 +        ) + +        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 +        ) + +        self.discord_user_two_roles = DiscordUser.objects.create( +            id=2, +            name="user with both roles", +            discriminator=0 +        ) + +        everyone_role = Role.objects.create( +            id=0, +            name="@everyone", +            colour=0, +            permissions=0, +            position=0 +        ) + +        self.discord_user_role.roles.add(everyone_role) +        self.discord_user_two_roles.roles.add(everyone_role) + +        self.discord_user_two_roles.roles.add(Role.objects.create( +            id=1, +            name="Developers", +            colour=0, +            permissions=0, +            position=1 +        )) + +    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_role) +        discord_login_two_roles = SocialLogin(account=self.discord_account_two_roles) +        discord_login_not_present = SocialLogin(account=self.discord_account_not_present) + +        github_login = SocialLogin(account=self.github_account) + +        messages_request = HttpRequest() +        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.assertEqual(len(messages_request._messages._queued_messages), 4) +                self.assertEqual(mock_redirect.call_count, 4) +            self.assertEqual(mock_reverse.call_count, 4) + +        self.assertTrue(adapter.is_open_for_signup(HttpRequest(), discord_login_two_roles)) + +    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" + +        user = adapter.populate_user( +            HttpRequest(), discord_login, +            {"username": "user"} +        ) + +        self.assertEqual(user.username, "user#0000") +        self.assertEqual(user.first_name, "user#0000") diff --git a/pydis_site/utils/account.py b/pydis_site/utils/account.py new file mode 100644 index 00000000..2d699c88 --- /dev/null +++ b/pydis_site/utils/account.py @@ -0,0 +1,79 @@ +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 user.roles.count() <= 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)  |