diff options
60 files changed, 1450 insertions, 602 deletions
diff --git a/.coveragerc b/.coveragerc index a49af74e..f5ddf08d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -14,6 +14,7 @@ omit = */urls.py pydis_site/wsgi.py pydis_site/settings.py + pydis_site/utils/resources.py [report] fail_under = 100 diff --git a/.dockerignore b/.dockerignore index 236295ca..61fa291a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,6 @@ .cache .coverage .coveragerc -.git .github .gitignore .gitlab diff --git a/.github/workflows/sentry-release.yml b/.github/workflows/sentry-release.yml new file mode 100644 index 00000000..87c85277 --- /dev/null +++ b/.github/workflows/sentry-release.yml @@ -0,0 +1,23 @@ +name: Create Sentry release + +on: + push: + branches: + - master + +jobs: + createSentryRelease: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@master + - name: Create a Sentry.io release + uses: tclindner/[email protected] + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: python-discord + SENTRY_PROJECT: site + with: + tagName: ${{ github.sha }} + environment: production + releaseNamePrefix: pydis-site@ @@ -9,7 +9,6 @@ django-environ = "~=0.4.5" django-filter = "~=2.1.0" django-hosts = "~=4.0" djangorestframework = "~=3.11.0" -djangorestframework-bulk = "~=0.2.1" psycopg2-binary = "~=2.8" django-simple-bulma = "~=1.2" whitenoise = "~=5.0" @@ -20,6 +19,7 @@ pyyaml = "~=5.1" pyuwsgi = {version = "~=2.0", sys_platform = "!='win32'"} django-allauth = "~=0.41" sentry-sdk = "~=0.14" +gitpython = "~=3.1.7" [dev-packages] coverage = "~=5.0" diff --git a/Pipfile.lock b/Pipfile.lock index 02d81d76..65b9c154 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "edc59f1c711954bd22606d68e00f44c21c68a7b3193b20e44a86438e24c0f54b" + "sha256": "4ecc64deaa82df654479986c9c9569721a66296725e51272608a8294ac562af2" }, "pipfile-spec": 6, "requires": { @@ -56,11 +56,11 @@ }, "django": { "hashes": [ - "sha256:31a5fbbea5fc71c99e288ec0b2f00302a0a92c44b13ede80b73a6a4d6d205582", - "sha256:5457fc953ec560c5521b41fad9e6734a4668b7ba205832191bbdff40ec61073c" + "sha256:2d14be521c3ae24960e5e83d4575e156a8c479a75c935224b671b1c6e66eddaf", + "sha256:313d0b8f96685e99327785cc600a5178ca855f8e6f4ed162e671e8c3cf749739" ], "index": "pypi", - "version": "==3.0.8" + "version": "==3.0.10" }, "django-allauth": { "hashes": [ @@ -71,9 +71,10 @@ }, "django-classy-tags": { "hashes": [ - "sha256:ad6a25fc2b58a098f00d86bd5e5dad47922f5ca4e744bc3cccb7b4be5bc35eb1" + "sha256:25eb4f95afee396148683bfb4811b83b3f5729218d73ad0a3399271a6f9fcc49", + "sha256:d59d98bdf96a764dcf7a2929a86439d023b283a9152492811c7e44fc47555bc9" ], - "version": "==1.0.0" + "version": "==2.0.0" }, "django-environ": { "hashes": [ @@ -123,32 +124,42 @@ }, "django-sekizai": { "hashes": [ - "sha256:e2f6e666d4dd9d3ecc27284acb85ef709e198014f5d5af8c6d54ed04c2d684d9" + "sha256:5c5e16845d37ce822fc655ce79ec02715191b3d03330b550997bcb842cf24fdf", + "sha256:e829f09b0d6bf01ee5cde05de1fb3faf2fbc5df66dc4dc280fbaac224ca4336f" ], - "version": "==1.1.0" + "version": "==2.0.0" }, "django-simple-bulma": { "hashes": [ - "sha256:a1462088791af5c65d2ea3b5a517a481dd8afb35b324979cdeefa6f3e6c58d3d", - "sha256:a93daf425353834db96840ca4aa7744c796899243f114e73b8159724ce4573c1" + "sha256:79928fa983151947c635acf65fa5177ca775db98c8d53ddf1c785fe48c727466", + "sha256:e5cff3fc5f0d45558362ab8d0e11f92887c4fc85616f77daa6174940f94b12c7" ], "index": "pypi", - "version": "==1.2.0" + "version": "==1.3.2" }, "djangorestframework": { "hashes": [ - "sha256:05809fc66e1c997fd9a32ea5730d9f4ba28b109b9da71fccfa5ff241201fd0a4", - "sha256:e782087823c47a26826ee5b6fa0c542968219263fb3976ec3c31edab23a4001f" + "sha256:6dd02d5a4bd2516fb93f80360673bf540c3b6641fec8766b1da2870a5aa00b32", + "sha256:8b1ac62c581dbc5799b03e535854b92fc4053ecfe74bad3f9c05782063d4196b" ], "index": "pypi", - "version": "==3.11.0" + "version": "==3.11.1" }, - "djangorestframework-bulk": { + "gitdb": { "hashes": [ - "sha256:39230d8379acebd86d313df6c9150cafecb636eae1d097c30a26389ab9fee5b1" + "sha256:91f36bfb1ab7949b3b40e23736db18231bf7593edada2ba5c3a174a7b23657ac", + "sha256:c9e1f2d0db7ddb9a704c2a0217be31214e91a4fe1dea1efad19ae42ba0c285c9" + ], + "markers": "python_version >= '3.4'", + "version": "==4.0.5" + }, + "gitpython": { + "hashes": [ + "sha256:080bf8e2cf1a2b907634761c2eaefbe83b69930c94c66ad11b65a8252959f912", + "sha256:1858f4fd089abe92ae465f01d5aaaf55e937eca565fb2c1fce35a51b5f85c910" ], "index": "pypi", - "version": "==0.2.1" + "version": "==3.1.8" }, "idna": { "hashes": [ @@ -168,21 +179,21 @@ }, "libsass": { "hashes": [ - "sha256:107c409524c6a4ed14410fa9dafa9ee59c6bd3ecae75d73af749ab2b75685726", - "sha256:3bc0d68778b30b5fa83199e18795314f64b26ca5871e026343e63934f616f7f7", - "sha256:5c8ff562b233734fbc72b23bb862cc6a6f70b1e9bf85a58422aa75108b94783b", - "sha256:74f6fb8da58179b5d86586bc045c16d93d55074bc7bb48b6354a4da7ac9f9dfd", - "sha256:7555d9b24e79943cfafac44dbb4ca7e62105c038de7c6b999838c9ff7b88645d", - "sha256:794f4f4661667263e7feafe5cc866e3746c7c8a9192b2aa9afffdadcbc91c687", - "sha256:8cf72552b39e78a1852132e16b706406bc76029fe3001583284ece8d8752a60a", - "sha256:98f6dee9850b29e62977a963e3beb3cfeb98b128a267d59d2c3d675e298c8d57", - "sha256:a43f3830d83ad9a7f5013c05ce239ca71744d0780dad906587302ac5257bce60", - "sha256:b077261a04ba1c213e932943208471972c5230222acb7fa97373e55a40872cbb", - "sha256:b7452f1df274b166dc22ee2e9154c4adca619bcbbdf8041a7aa05f372a1dacbc", - "sha256:e6a547c0aa731dcb4ed71f198e814bee0400ce04d553f3f12a53bc3a17f2a481", - "sha256:fd19c8f73f70ffc6cbcca8139da08ea9a71fc48e7dfc4bb236ad88ab2d6558f1" - ], - "version": "==0.20.0" + "sha256:1521d2a8d4b397c6ec90640a1f6b5529077035efc48ef1c2e53095544e713d1b", + "sha256:1b2d415bbf6fa7da33ef46e549db1418498267b459978eff8357e5e823962d35", + "sha256:25ebc2085f5eee574761ccc8d9cd29a9b436fc970546d5ef08c6fa41eb57dff1", + "sha256:2ae806427b28bc1bb7cb0258666d854fcf92ba52a04656b0b17ba5e190fb48a9", + "sha256:4a246e4b88fd279abef8b669206228c92534d96ddcd0770d7012088c408dff23", + "sha256:553e5096414a8d4fb48d0a48f5a038d3411abe254d79deac5e008516c019e63a", + "sha256:697f0f9fa8a1367ca9ec6869437cb235b1c537fc8519983d1d890178614a8903", + "sha256:a8fd4af9f853e8bf42b1425c5e48dd90b504fa2e70d7dac5ac80b8c0a5a5fe85", + "sha256:c9411fec76f480ffbacc97d8188322e02a5abca6fc78e70b86a2a2b421eae8a2", + "sha256:daa98a51086d92aa7e9c8871cf1a8258124b90e2abf4697852a3dca619838618", + "sha256:e0e60836eccbf2d9e24ec978a805cd6642fa92515fbd95e3493fee276af76f8a", + "sha256:e64ae2587f1a683e831409aad03ba547c245ef997e1329fffadf7a866d2510b8", + "sha256:f6852828e9e104d2ce0358b73c550d26dd86cc3a69439438c3b618811b9584f5" + ], + "version": "==0.20.1" }, "markdown": { "hashes": [ @@ -227,11 +238,13 @@ "sha256:94cf49723928eb6070a892cb39d6c156f7b5a2db4e8971cb958f7b6b104fb4c4", "sha256:97f9e7953a77d5a70f49b9a48da7776dc51e9b738151b22dacf101641594a626", "sha256:9ad7f865eebde135d526bb3163d0b23ffff365cf87e767c649550964ad72785d", + "sha256:9c87ef410a58dd54b92424ffd7e28fd2ec65d2f7fc02b76f5e9b2067e355ebf6", "sha256:a060cf8aa332052df2158e5a119303965be92c3da6f2d93b6878f0ebca80b2f6", "sha256:c79f9c5fb846285f943aafeafda3358992d64f0ef58566e23484132ecd8d7d63", "sha256:c92302a33138409e8f1ad16731568c55c9053eee71bb05b6b744067e1b62380f", "sha256:d08b23fdb388c0715990cbc06866db554e1822c4bdcf6d4166cf30ac82df8c41", "sha256:d350f0f2c2421e65fbc62690f26b59b0bcda1b614beb318c81e38647e0f673a1", + "sha256:e901964262a56d9ea3c2693df68bc9860b8bdda2b04768821e4c44ae797de117", "sha256:ec29604081f10f16a7aea809ad42e27764188fc258b02259a03a8ff7ded3808d", "sha256:edf31f1150778abd4322444c393ab9c7bd2af271dd4dafb4208fb613b1f3cdc9", "sha256:f7e30c27477dffc3e85c2463b3e649f751789e0f6c8456099eea7ddd53be4a8a", @@ -242,39 +255,41 @@ }, "psycopg2-binary": { "hashes": [ - "sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac", - "sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a", - "sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5", - "sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04", - "sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1", - "sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5", - "sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce", - "sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434", - "sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9", - "sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057", - "sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98", - "sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522", - "sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505", - "sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa", - "sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3", - "sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f", - "sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4", - "sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4", - "sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266", - "sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66", - "sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38", - "sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3", - "sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389", - "sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab", - "sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb", - "sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6", - "sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d", - "sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162", - "sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e", - "sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd" - ], - "index": "pypi", - "version": "==2.8.5" + "sha256:0deac2af1a587ae12836aa07970f5cb91964f05a7c6cdb69d8425ff4c15d4e2c", + "sha256:0e4dc3d5996760104746e6cfcdb519d9d2cd27c738296525d5867ea695774e67", + "sha256:11b9c0ebce097180129e422379b824ae21c8f2a6596b159c7659e2e5a00e1aa0", + "sha256:1fabed9ea2acc4efe4671b92c669a213db744d2af8a9fc5d69a8e9bc14b7a9db", + "sha256:2dac98e85565d5688e8ab7bdea5446674a83a3945a8f416ad0110018d1501b94", + "sha256:42ec1035841b389e8cc3692277a0bd81cdfe0b65d575a2c8862cec7a80e62e52", + "sha256:6a32f3a4cb2f6e1a0b15215f448e8ce2da192fd4ff35084d80d5e39da683e79b", + "sha256:7312e931b90fe14f925729cde58022f5d034241918a5c4f9797cac62f6b3a9dd", + "sha256:7d92a09b788cbb1aec325af5fcba9fed7203897bbd9269d5691bb1e3bce29550", + "sha256:833709a5c66ca52f1d21d41865a637223b368c0ee76ea54ca5bad6f2526c7679", + "sha256:8cd0fb36c7412996859cb4606a35969dd01f4ea34d9812a141cd920c3b18be77", + "sha256:950bc22bb56ee6ff142a2cb9ee980b571dd0912b0334aa3fe0fe3788d860bea2", + "sha256:a0c50db33c32594305b0ef9abc0cb7db13de7621d2cadf8392a1d9b3c437ef77", + "sha256:a0eb43a07386c3f1f1ebb4dc7aafb13f67188eab896e7397aa1ee95a9c884eb2", + "sha256:aaa4213c862f0ef00022751161df35804127b78adf4a2755b9f991a507e425fd", + "sha256:ac0c682111fbf404525dfc0f18a8b5f11be52657d4f96e9fcb75daf4f3984859", + "sha256:ad20d2eb875aaa1ea6d0f2916949f5c08a19c74d05b16ce6ebf6d24f2c9f75d1", + "sha256:b4afc542c0ac0db720cf516dd20c0846f71c248d2b3d21013aa0d4ef9c71ca25", + "sha256:b8a3715b3c4e604bcc94c90a825cd7f5635417453b253499664f784fc4da0152", + "sha256:ba28584e6bca48c59eecbf7efb1576ca214b47f05194646b081717fa628dfddf", + "sha256:ba381aec3a5dc29634f20692349d73f2d21f17653bda1decf0b52b11d694541f", + "sha256:bd1be66dde2b82f80afb9459fc618216753f67109b859a361cf7def5c7968729", + "sha256:c2507d796fca339c8fb03216364cca68d87e037c1f774977c8fc377627d01c71", + "sha256:cec7e622ebc545dbb4564e483dd20e4e404da17ae07e06f3e780b2dacd5cee66", + "sha256:d14b140a4439d816e3b1229a4a525df917d6ea22a0771a2a78332273fd9528a4", + "sha256:d1b4ab59e02d9008efe10ceabd0b31e79519da6fb67f7d8e8977118832d0f449", + "sha256:d5227b229005a696cc67676e24c214740efd90b148de5733419ac9aaba3773da", + "sha256:e1f57aa70d3f7cc6947fd88636a481638263ba04a742b4a37dd25c373e41491a", + "sha256:e74a55f6bad0e7d3968399deb50f61f4db1926acf4a6d83beaaa7df986f48b1c", + "sha256:e82aba2188b9ba309fd8e271702bd0d0fc9148ae3150532bbb474f4590039ffb", + "sha256:ee69dad2c7155756ad114c02db06002f4cded41132cc51378e57aad79cc8e4f4", + "sha256:f5ab93a2cb2d8338b1674be43b442a7f544a0971da062a5da774ed40587f18f5" + ], + "index": "pypi", + "version": "==2.8.6" }, "pygments": { "hashes": [ @@ -289,7 +304,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], - "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2'", "version": "==2.4.7" }, "python3-openid": { @@ -360,20 +375,28 @@ }, "sentry-sdk": { "hashes": [ - "sha256:2f023ff348359ec5f0b73a840e8b08e6a8d3b2613a98c57d11c222ef43879237", - "sha256:380a280cfc7c4ade5912294e6d9aa71ce776b5fca60a3782e9331b0bcd2866bf" + "sha256:0af429c221670e602f960fca85ca3f607c85510a91f11e8be8f742a978127f78", + "sha256:a088a1054673c6a19ea590045c871c38da029ef743b61a07bfee95e9f3c060f7" ], "index": "pypi", - "version": "==0.16.1" + "version": "==0.17.3" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, + "smmap": { + "hashes": [ + "sha256:54c44c197c819d5ef1991799a7e30b662d1e520f2ac75c9efbeb54a742214cf4", + "sha256:9c98bbd1f9786d22f14b3d4126894d56befb835ec90cef151af566c7e19b5d24" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==3.0.4" + }, "sorl-thumbnail": { "hashes": [ "sha256:66771521f3c0ed771e1ce8e1aaf1639ebff18f7f5a40cfd3083da8f0fe6c7c99", @@ -392,11 +415,11 @@ }, "urllib3": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", - "version": "==1.25.9" + "version": "==1.25.10" }, "webencodings": { "hashes": [ @@ -407,11 +430,11 @@ }, "whitenoise": { "hashes": [ - "sha256:60154b976a13901414a25b0273a841145f77eb34a141f9ae032a0ace3e4d5b27", - "sha256:6dd26bfda3af29177d8ab7333a0c7b7642eb615ce83764f4d15a9aecda3201c4" + "sha256:05ce0be39ad85740a78750c86a93485c40f08ad8c62a6006de0233765996e5c7", + "sha256:05d00198c777028d72d8b0bbd234db605ef6d60e9410125124002518a48e515d" ], "index": "pypi", - "version": "==5.1.0" + "version": "==5.2.0" }, "wiki": { "hashes": [ @@ -440,11 +463,11 @@ }, "attrs": { "hashes": [ - "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", - "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" + "sha256:26b54ddbbb9ee1d34d5d3668dd37d6cf74990ab23c828c2888dccdceee395594", + "sha256:fce7fc47dfc976152e82d53ff92fa0407700c21acd20886a13777a0d20e655dc" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==19.3.0" + "version": "==20.2.0" }, "bandit": { "hashes": [ @@ -455,51 +478,51 @@ }, "cfgv": { "hashes": [ - "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", - "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" + "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", + "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], "markers": "python_full_version >= '3.6.1'", - "version": "==3.1.0" + "version": "==3.2.0" }, "coverage": { "hashes": [ - "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", - "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", - "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", - "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", - "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", - "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", - "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", - "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", - "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", - "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", - "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", - "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", - "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", - "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", - "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", - "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", - "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", - "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", - "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", - "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", - "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", - "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", - "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", - "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", - "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", - "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", - "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", - "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", - "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", - "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", - "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", - "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", - "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", - "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" - ], - "index": "pypi", - "version": "==5.2" + "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", + "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", + "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", + "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", + "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", + "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", + "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", + "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", + "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", + "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", + "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", + "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", + "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", + "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", + "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", + "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", + "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", + "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", + "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", + "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", + "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", + "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", + "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", + "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", + "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", + "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", + "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", + "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", + "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", + "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", + "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", + "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", + "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", + "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" + ], + "index": "pypi", + "version": "==5.2.1" }, "distlib": { "hashes": [ @@ -525,11 +548,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26", - "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414" + "sha256:09fe1aa3f40cb8fef632a0ab3614050a7584bb884b6134e70cf1fc9eeee642fa", + "sha256:5bda552f074fd6e34276c7761756fa07d824ffac91ce9c0a8555eb2bc5b92d7a" ], "index": "pypi", - "version": "==2.3.0" + "version": "==2.4.0" }, "flake8-bandit": { "hashes": [ @@ -602,19 +625,19 @@ }, "gitpython": { "hashes": [ - "sha256:2db287d71a284e22e5c2846042d0602465c7434d910406990d5b74df4afb0858", - "sha256:fa3b92da728a457dd75d62bb5f3eb2816d99a7fe6c67398e260637a40e3fafb5" + "sha256:080bf8e2cf1a2b907634761c2eaefbe83b69930c94c66ad11b65a8252959f912", + "sha256:1858f4fd089abe92ae465f01d5aaaf55e937eca565fb2c1fce35a51b5f85c910" ], - "markers": "python_version >= '3.4'", - "version": "==3.1.7" + "index": "pypi", + "version": "==3.1.8" }, "identify": { "hashes": [ - "sha256:06b4373546ae55eaaefdac54f006951dbd968fe2912846c00e565b09cfaed101", - "sha256:5519601b70c831011fb425ffd214101df7639ba3980f24dc283f7675b19127b3" + "sha256:009f92ba753c467a99f6fd3eb395412cbc34077dd5a64313b62ba04297f2ab8e", + "sha256:0868312cb7402b48cf44fe3f568259f804ef4e983c143d11bf7a51ca311ebc34" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.24" + "version": "==1.5.0" }, "importlib-metadata": { "hashes": [ @@ -634,16 +657,18 @@ }, "nodeenv": { "hashes": [ - "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" + "sha256:5304d424c529c997bc888453aeaa6362d242b6b4631e90f3d4bf1b290f1c84a9", + "sha256:ab45090ae383b716c4ef89e690c41ff8c2b257b85b309f01f3654df3d084bd7c" ], - "version": "==1.4.0" + "version": "==1.5.0" }, "pbr": { "hashes": [ - "sha256:07f558fece33b05caf857474a366dfcc00562bca13dd8b47b2b3e22d9f9bf55c", - "sha256:579170e23f8e0c2f24b0de612f71f648eccb79fb1322c814ae6b3c07b5ba23e8" + "sha256:14bfd98f51c78a3dd22a1ef45cf194ad79eee4a19e8e1a0d5c7f8e81ffe182ea", + "sha256:5adc0f9fc64319d8df5ca1e4e06eea674c26b80e6f00c530b18ce6a6592ead15" ], - "version": "==5.4.5" + "markers": "python_version >= '2.6'", + "version": "==5.5.0" }, "pep8-naming": { "hashes": [ @@ -655,11 +680,11 @@ }, "pre-commit": { "hashes": [ - "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915", - "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626" + "sha256:810aef2a2ba4f31eed1941fc270e72696a1ad5590b9751839c90807d0fff6b9a", + "sha256:c54fd3e574565fe128ecc5e7d2f91279772ddb03f8729645fa812fe809084a70" ], "index": "pypi", - "version": "==2.6.0" + "version": "==2.7.1" }, "pycodestyle": { "hashes": [ @@ -671,11 +696,11 @@ }, "pydocstyle": { "hashes": [ - "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", - "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" + "sha256:19b86fa8617ed916776a11cd8bc0197e5b9856d5433b777f51a3defe13075325", + "sha256:aca749e190a01726a4fb472dd4ef23b5c9da7b9205c0a7857c06533de13fd678" ], "markers": "python_version >= '3.5'", - "version": "==5.0.2" + "version": "==5.1.1" }, "pyflakes": { "hashes": [ @@ -707,7 +732,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], - "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "version": "==1.15.0" }, "smmap": { @@ -727,11 +752,11 @@ }, "stevedore": { "hashes": [ - "sha256:38791aa5bed922b0a844513c5f9ed37774b68edc609e5ab8ab8d8fe0ce4315e5", - "sha256:c8f4f0ebbc394e52ddf49de8bcc3cf8ad2b4425ebac494106bbc5e3661ac7633" + "sha256:a34086819e2c7a7f86d5635363632829dab8014e5fd7be2454c7cba84ac7514e", + "sha256:ddc09a744dc224c84ec8e8efcb70595042d21c97c76df60daee64c9ad53bc7ee" ], "markers": "python_version >= '3.6'", - "version": "==3.2.0" + "version": "==3.2.1" }, "toml": { "hashes": [ @@ -769,19 +794,19 @@ }, "unittest-xml-reporting": { "hashes": [ - "sha256:74eaf7739a7957a74f52b8187c5616f61157372189bef0a32ba5c30bbc00e58a", - "sha256:e09b8ae70cce9904cdd331f53bf929150962869a5324ab7ff3dd6c8b87e01f7d" + "sha256:7bf515ea8cb244255a25100cd29db611a73f8d3d0aaf672ed3266307e14cc1ca", + "sha256:984cebba69e889401bfe3adb9088ca376b3a1f923f0590d005126c1bffd1a695" ], "index": "pypi", - "version": "==3.0.2" + "version": "==3.0.4" }, "virtualenv": { "hashes": [ - "sha256:26cdd725a57fef4c7c22060dba4647ebd8ca377e30d1c1cf547b30a0b79c43b4", - "sha256:c51f1ba727d1614ce8fd62457748b469fbedfdab2c7e5dd480c9ae3fbe1233f1" + "sha256:43add625c53c596d38f971a465553f6318decc39d98512bc100fa1b1e839c8dc", + "sha256:e0305af10299a7fb0d69393d8f04cb2965dda9351140d11ac8db4e5e3970451b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.27" + "version": "==20.0.31" }, "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/docker/Dockerfile b/docker/Dockerfile index aa427947..97cb73d5 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -8,6 +8,12 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_NOSPIN=1 +# Install git +RUN apt-get -y update \ + && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* + # Create non-root user. RUN useradd --system --shell /bin/false --uid 1500 pysite @@ -112,6 +112,19 @@ class SiteManager: print("Database could not be found, exiting.") sys.exit(1) + @staticmethod + def set_dev_site_name() -> None: + """Set the development site domain in admin from default example.""" + # import Site model now after django setup + from django.contrib.sites.models import Site + query = Site.objects.filter(id=1) + site = query.get() + if site.domain == "example.com": + query.update( + domain="pythondiscord.local:8000", + name="pythondiscord.local:8000" + ) + def prepare_server(self) -> None: """Perform preparation tasks before running the server.""" django.setup() @@ -125,6 +138,7 @@ class SiteManager: call_command("collectstatic", interactive=False, clear=True, verbosity=self.verbosity) if self.debug: + self.set_dev_site_name() self.create_superuser() def run_server(self) -> None: diff --git a/pydis_site/apps/api/admin.py b/pydis_site/apps/api/admin.py index 0333fefc..b6fee9d1 100644 --- a/pydis_site/apps/api/admin.py +++ b/pydis_site/apps/api/admin.py @@ -1,68 +1,413 @@ -from typing import Optional +from __future__ import annotations +import json +from typing import Iterable, Optional, Tuple + +from django import urls from django.contrib import admin +from django.db.models import QuerySet from django.http import HttpRequest +from django.utils.html import SafeString, format_html from .models import ( BotSetting, DeletedMessage, DocumentationLink, Infraction, - LogEntry, MessageDeletionContext, Nomination, OffTopicChannelName, OffensiveMessage, Role, - Tag, User ) +admin.site.site_header = "Python Discord | Administration" +admin.site.site_title = "Python Discord" + + [email protected](BotSetting) +class BotSettingAdmin(admin.ModelAdmin): + """Admin formatting for the BotSetting model.""" + + fields = ("name", "data") + list_display = ("name",) + readonly_fields = ("name",) + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + def has_delete_permission(self, *args) -> bool: + """Prevent deleting from django admin.""" + return False + + [email protected](DocumentationLink) +class DocumentationLinkAdmin(admin.ModelAdmin): + """Admin formatting for the DocumentationLink model.""" + + fields = ("package", "base_url", "inventory_url") + list_display = ("package", "base_url", "inventory_url") + list_editable = ("base_url", "inventory_url") + search_fields = ("package",) + + +class InfractionActorFilter(admin.SimpleListFilter): + """Actor Filter for Infraction Admin list page.""" -class LogEntryAdmin(admin.ModelAdmin): - """Allows viewing logs in the Django Admin without allowing edits.""" + title = "Actor" + parameter_name = "actor" + + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + """Selectable values for viewer to filter by.""" + actor_ids = Infraction.objects.order_by().values_list("actor").distinct() + actors = User.objects.filter(id__in=actor_ids) + return ((a.id, a.username) for a in actors) + + def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + """Query to filter the list of Users against.""" + if not self.value(): + return + return queryset.filter(actor__id=self.value()) + + [email protected](Infraction) +class InfractionAdmin(admin.ModelAdmin): + """Admin formatting for the Infraction model.""" - actions = None - list_display = ('timestamp', 'application', 'level', 'message') fieldsets = ( - ('Overview', {'fields': ('timestamp', 'application', 'logger_name')}), - ('Metadata', {'fields': ('level', 'module', 'line')}), - ('Contents', {'fields': ('message',)}) + ("Members", {"fields": ("user", "actor")}), + ("Action", {"fields": ("type", "hidden", "active")}), + ("Dates", {"fields": ("inserted_at", "expires_at")}), + ("Reason", {"fields": ("reason",)}), + ) + readonly_fields = ( + "user", + "actor", + "type", + "inserted_at", + "expires_at", + "active", + "hidden" + ) + list_display = ( + "type", + "active", + "user", + "inserted_at", + "reason", + ) + search_fields = ( + "id", + "user__name", + "user__id", + "actor__name", + "actor__id", + "reason", + "type" + ) + list_filter = ( + "type", + "hidden", + "active", + InfractionActorFilter + ) + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + [email protected](DeletedMessage) +class DeletedMessageAdmin(admin.ModelAdmin): + """Admin formatting for the DeletedMessage model.""" + + fields = ( + "id", + "author", + "channel_id", + "content", + "embed_data", + "context", + "view_full_log" + ) + + exclude = ("embeds", "deletion_context") + + search_fields = ( + "id", + "content", + "author__name", + "author__id", + "deletion_context__actor__name", + "deletion_context__actor__id" + ) + + list_display = ("id", "author", "channel_id") + + def embed_data(self, message: DeletedMessage) -> Optional[str]: + """Format embed data in a code block for better readability.""" + if message.embeds: + return format_html( + "<pre style='word-wrap: break-word; white-space: pre-wrap; overflow-x: auto;'>" + "<code>{0}</code></pre>", + json.dumps(message.embeds, indent=4) + ) + + embed_data.short_description = "Embeds" + + @staticmethod + def context(message: DeletedMessage) -> str: + """Provide full context info with a link through to context admin view.""" + link = urls.reverse( + "admin:api_messagedeletioncontext_change", + args=[message.deletion_context.id] + ) + details = ( + f"Deleted by {message.deletion_context.actor} at " + f"{message.deletion_context.creation}" + ) + return format_html("<a href='{0}'>{1}</a>", link, details) + + @staticmethod + def view_full_log(message: DeletedMessage) -> str: + """Provide a link to the message logs for the relevant context.""" + return format_html( + "<a href='{0}'>Click to view full context log</a>", + message.deletion_context.log_url + ) + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + def has_change_permission(self, *args) -> bool: + """Prevent editing from django admin.""" + return False + + +class DeletedMessageInline(admin.TabularInline): + """Tabular Inline Admin model for Deleted Message to be viewed within Context.""" + + model = DeletedMessage + + [email protected](MessageDeletionContext) +class MessageDeletionContextAdmin(admin.ModelAdmin): + """Admin formatting for the MessageDeletionContext model.""" + + fields = ("actor", "creation") + list_display = ("id", "creation", "actor") + inlines = (DeletedMessageInline,) + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + def has_change_permission(self, *args) -> bool: + """Prevent editing from django admin.""" + return False + + +class NominationActorFilter(admin.SimpleListFilter): + """Actor Filter for Nomination Admin list page.""" + + title = "Actor" + parameter_name = "actor" + + def lookups(self, request: HttpRequest, model: NominationAdmin) -> Iterable[Tuple[int, str]]: + """Selectable values for viewer to filter by.""" + actor_ids = Nomination.objects.order_by().values_list("actor").distinct() + actors = User.objects.filter(id__in=actor_ids) + return ((a.id, a.username) for a in actors) + + def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + """Query to filter the list of Users against.""" + if not self.value(): + return + return queryset.filter(actor__id=self.value()) + + [email protected](Nomination) +class NominationAdmin(admin.ModelAdmin): + """Admin formatting for the Nomination model.""" + + search_fields = ( + "user__name", + "user__id", + "actor__name", + "actor__id", + "reason", + "end_reason" + ) + + list_filter = ("active", NominationActorFilter) + + list_display = ( + "user", + "active", + "reason", + "actor", ) - list_filter = ('application', 'level', 'timestamp') - search_fields = ('message',) + + fields = ( + "user", + "active", + "actor", + "reason", + "inserted_at", + "ended_at", + "end_reason" + ) + + # only allow reason fields to be edited. readonly_fields = ( - 'application', - 'logger_name', - 'timestamp', - 'level', - 'module', - 'line', - 'message' + "user", + "active", + "actor", + "inserted_at", + "ended_at" ) - def has_add_permission(self, request: HttpRequest) -> bool: - """Deny manual LogEntry creation.""" + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + [email protected](OffTopicChannelName) +class OffTopicChannelNameAdmin(admin.ModelAdmin): + """Admin formatting for the OffTopicChannelName model.""" + + search_fields = ("name",) + list_filter = ("used",) + + [email protected](OffensiveMessage) +class OffensiveMessageAdmin(admin.ModelAdmin): + """Admin formatting for the OffensiveMessage model.""" + + def message_jumplink(self, message: OffensiveMessage) -> SafeString: + """Message ID hyperlinked to the direct discord jumplink.""" + return format_html( + '<a href="https://canary.discordapp.com/channels/267624335836053506/{0}/{1}">{1}</a>', + message.channel_id, + message.id + ) + + message_jumplink.short_description = "Message ID" + + search_fields = ("id", "channel_id") + list_display = ("id", "channel_id", "delete_date") + fields = ("message_jumplink", "channel_id", "delete_date") + readonly_fields = ("message_jumplink", "channel_id") + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" return False - def has_delete_permission( - self, - request: HttpRequest, - obj: Optional[LogEntry] = None - ) -> bool: - """Deny LogEntry deletion.""" + [email protected](Role) +class RoleAdmin(admin.ModelAdmin): + """Admin formatting for the Role model.""" + + def coloured_name(self, role: Role) -> SafeString: + """Role name with html style colouring.""" + return format_html( + '<span style="color: {0}!important; font-weight: bold;">{1}</span>', + f"#{role.colour:06X}", + role.name + ) + + coloured_name.short_description = "Name" + + def colour_with_preview(self, role: Role) -> SafeString: + """Show colour value in both int and hex, in bolded and coloured style.""" + return format_html( + "<span style='color: {0}; font-weight: bold;'>{0} ({1})</span>", + f"#{role.colour:06x}", + role.colour + ) + + colour_with_preview.short_description = "Colour" + + def permissions_with_calc_link(self, role: Role) -> SafeString: + """Show permissions with link to API permissions calculator page.""" + return format_html( + "<a href='https://discordapi.com/permissions.html#{0}' target='_blank'>{0}</a>", + role.permissions + ) + + permissions_with_calc_link.short_description = "Permissions" + + search_fields = ("name", "id") + list_display = ("coloured_name",) + fields = ("id", "name", "colour_with_preview", "permissions_with_calc_link", "position") + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + def has_change_permission(self, *args) -> bool: + """Prevent editing from django admin.""" return False -admin.site.register(BotSetting) -admin.site.register(DeletedMessage) -admin.site.register(DocumentationLink) -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) -admin.site.register(User) +class UserRoleFilter(admin.SimpleListFilter): + """List Filter for User list Admin page.""" + + title = "Role" + parameter_name = "role" + + def lookups(self, request: HttpRequest, model: UserAdmin) -> Iterable[Tuple[str, str]]: + """Selectable values for viewer to filter by.""" + roles = Role.objects.all() + return ((r.name, r.name) for r in roles) + + def queryset(self, request: HttpRequest, queryset: QuerySet) -> Optional[QuerySet]: + """Query to filter the list of Users against.""" + if not self.value(): + return + role = Role.objects.get(name=self.value()) + return queryset.filter(roles__contains=[role.id]) + + [email protected](User) +class UserAdmin(admin.ModelAdmin): + """Admin formatting for the User model.""" + + def top_role_coloured(self, user: User) -> SafeString: + """Returns the top role of the user with html style matching role colour.""" + return format_html( + '<span style="color: {0}; font-weight: bold;">{1}</span>', + f"#{user.top_role.colour:06X}", + user.top_role.name + ) + + top_role_coloured.short_description = "Top Role" + + def all_roles_coloured(self, user: User) -> SafeString: + """Returns all user roles with html style matching role colours.""" + roles = Role.objects.filter(id__in=user.roles) + return format_html( + "</br>".join( + f'<span style="color: #{r.colour:06X}; font-weight: bold;">{r.name}</span>' + for r in roles + ) + ) + + all_roles_coloured.short_description = "All Roles" + + search_fields = ("name", "id", "roles") + list_filter = (UserRoleFilter, "in_guild") + list_display = ("username", "top_role_coloured", "in_guild") + fields = ("username", "id", "in_guild", "all_roles_coloured") + sortable_by = ("username",) + + def has_add_permission(self, *args) -> bool: + """Prevent adding from django admin.""" + return False + + def has_change_permission(self, *args) -> bool: + """Prevent editing from django admin.""" + return False diff --git a/pydis_site/apps/api/dblogger.py b/pydis_site/apps/api/dblogger.py deleted file mode 100644 index 4b4e3a9d..00000000 --- a/pydis_site/apps/api/dblogger.py +++ /dev/null @@ -1,22 +0,0 @@ -from logging import LogRecord, StreamHandler - - -class DatabaseLogHandler(StreamHandler): - """Logs entries into the database.""" - - def emit(self, record: LogRecord) -> None: - """Write the given `record` into the database.""" - # This import needs to be deferred due to Django's application - # registry instantiation logic loading this handler before the - # application is ready. - from pydis_site.apps.api.models.log_entry import LogEntry - - entry = LogEntry( - application='site', - logger_name=record.name, - level=record.levelname.lower(), - module=record.module, - line=record.lineno, - message=self.format(record) - ) - entry.save() diff --git a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py index d53ddb90..d92042d2 100644 --- a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py +++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py @@ -1,7 +1,5 @@ # Generated by Django 2.1.1 on 2018-09-23 10:07 -import pydis_site.apps.api.models.bot.tag -import django.contrib.postgres.fields.jsonb from django.db import migrations @@ -12,9 +10,4 @@ class Migration(migrations.Migration): ] operations = [ - migrations.AlterField( - model_name='tag', - name='embed', - field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), - ), ] diff --git a/pydis_site/apps/api/migrations/0019_deletedmessage.py b/pydis_site/apps/api/migrations/0019_deletedmessage.py index 33746253..6b848d64 100644 --- a/pydis_site/apps/api/migrations/0019_deletedmessage.py +++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py @@ -18,7 +18,7 @@ class Migration(migrations.Migration): ('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 this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')])), ('content', models.CharField(help_text='The content of this message, taken from Discord.', max_length=2000)), - ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), help_text='Embeds attached to this message.', size=None)), + ('embeds', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), help_text='Embeds attached to this message.', size=None)), ('author', models.ForeignKey(help_text='The author of this message.', on_delete=django.db.models.deletion.CASCADE, to='api.User')), ('deletion_context', models.ForeignKey(help_text='The deletion context this message is part of.', on_delete=django.db.models.deletion.CASCADE, to='api.MessageDeletionContext')), ], diff --git a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py index e617e1c9..124c6a57 100644 --- a/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py +++ b/pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py @@ -3,7 +3,7 @@ import django.contrib.postgres.fields import django.contrib.postgres.fields.jsonb from django.db import migrations -import pydis_site.apps.api.models.bot.tag +import pydis_site.apps.api.models.utils class Migration(migrations.Migration): @@ -16,6 +16,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='deletedmessage', name='embeds', - field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.bot.tag.validate_tag_embed]), blank=True, help_text='Embeds attached to this message.', size=None), + field=django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.jsonb.JSONField(validators=[pydis_site.apps.api.models.utils.validate_embed]), blank=True, help_text='Embeds attached to this message.', size=None), ), ] diff --git a/pydis_site/apps/api/migrations/0051_delete_tag.py b/pydis_site/apps/api/migrations/0051_delete_tag.py new file mode 100644 index 00000000..bada5788 --- /dev/null +++ b/pydis_site/apps/api/migrations/0051_delete_tag.py @@ -0,0 +1,16 @@ +# Generated by Django 2.2.11 on 2020-04-01 06:15 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0050_remove_infractions_active_default_value'), + ] + + operations = [ + migrations.DeleteModel( + name='Tag', + ), + ] diff --git a/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py new file mode 100644 index 00000000..dfdf3835 --- /dev/null +++ b/pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2.11 on 2020-03-30 10:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0051_create_news_setting'), + ] + + operations = [ + migrations.AddField( + model_name='offtopicchannelname', + name='used', + field=models.BooleanField(default=False, help_text='Whether or not this name has already been used during this rotation'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py new file mode 100644 index 00000000..f0668696 --- /dev/null +++ b/pydis_site/apps/api/migrations/0061_merge_20200830_0526.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.8 on 2020-08-30 05:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0060_populate_filterlists_fix'), + ('api', '0052_offtopicchannelname_used'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py new file mode 100644 index 00000000..d162acf1 --- /dev/null +++ b/pydis_site/apps/api/migrations/0062_merge_20200901_1459.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.8 on 2020-09-01 14:59 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0051_delete_tag'), + ('api', '0061_merge_20200830_0526'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py new file mode 100644 index 00000000..9eb05eaa --- /dev/null +++ b/pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.9 on 2020-09-11 21:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0062_merge_20200901_1459'), + ] + + operations = [ + migrations.AlterField( + model_name='nomination', + name='reason', + field=models.TextField(blank=True, help_text='Why this user was nominated.', null=True), + ), + ] diff --git a/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py new file mode 100644 index 00000000..0080eb42 --- /dev/null +++ b/pydis_site/apps/api/migrations/0064_auto_20200919_1900.py @@ -0,0 +1,76 @@ +# Generated by Django 3.0.9 on 2020-09-19 19:00 + +import django.core.validators +from django.db import migrations, models +import pydis_site.apps.api.models.bot.offensive_message + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0063_Allow_blank_or_null_for_nomination_reason'), + ] + + operations = [ + migrations.AlterModelOptions( + name='deletedmessage', + options={'ordering': ('-id',)}, + ), + migrations.AlterModelOptions( + name='messagedeletioncontext', + options={'ordering': ('-creation',)}, + ), + migrations.AlterModelOptions( + name='nomination', + options={'ordering': ('-inserted_at',)}, + ), + migrations.AlterModelOptions( + name='role', + options={'ordering': ('-position',)}, + ), + migrations.AlterField( + model_name='deletedmessage', + name='channel_id', + field=models.BigIntegerField(help_text='The channel ID that this message was sent in, taken from Discord.', validators=[django.core.validators.MinValueValidator(limit_value=0, message='Channel IDs cannot be negative.')], verbose_name='Channel ID'), + ), + migrations.AlterField( + model_name='deletedmessage', + name='id', + field=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.')], verbose_name='ID'), + ), + migrations.AlterField( + model_name='nomination', + name='end_reason', + field=models.TextField(blank=True, default='', help_text='Why the nomination was ended.'), + ), + migrations.AlterField( + model_name='offensivemessage', + name='channel_id', + field=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.')], verbose_name='Channel ID'), + ), + migrations.AlterField( + model_name='offensivemessage', + name='delete_date', + field=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], verbose_name='To Be Deleted'), + ), + migrations.AlterField( + model_name='offensivemessage', + name='id', + field=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.')], verbose_name='Message ID'), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigIntegerField(help_text='The role ID, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='Role IDs cannot be negative.')], verbose_name='ID'), + ), + migrations.AlterField( + model_name='user', + name='id', + field=models.BigIntegerField(help_text='The ID of this user, taken from Discord.', primary_key=True, serialize=False, validators=[django.core.validators.MinValueValidator(limit_value=0, message='User IDs cannot be negative.')], verbose_name='ID'), + ), + migrations.AlterField( + model_name='user', + name='in_guild', + field=models.BooleanField(default=True, help_text='Whether this user is in our server.', verbose_name='In Guild'), + ), + ] diff --git a/pydis_site/apps/api/migrations/0064_delete_logentry.py b/pydis_site/apps/api/migrations/0064_delete_logentry.py new file mode 100644 index 00000000..a5f344d1 --- /dev/null +++ b/pydis_site/apps/api/migrations/0064_delete_logentry.py @@ -0,0 +1,16 @@ +# Generated by Django 3.0.9 on 2020-10-03 06:57 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0063_Allow_blank_or_null_for_nomination_reason'), + ] + + operations = [ + migrations.DeleteModel( + name='LogEntry', + ), + ] diff --git a/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py new file mode 100644 index 00000000..89bc4e02 --- /dev/null +++ b/pydis_site/apps/api/migrations/0065_auto_20200919_2033.py @@ -0,0 +1,17 @@ +# Generated by Django 3.0.9 on 2020-09-19 20:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0064_auto_20200919_1900'), + ] + + operations = [ + migrations.AlterModelOptions( + name='documentationlink', + options={'ordering': ['package']}, + ), + ] diff --git a/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py new file mode 100644 index 00000000..298416db --- /dev/null +++ b/pydis_site/apps/api/migrations/0066_merge_20201003_0730.py @@ -0,0 +1,14 @@ +# Generated by Django 3.0.9 on 2020-10-03 07:30 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0064_delete_logentry'), + ('api', '0065_auto_20200919_2033'), + ] + + operations = [ + ] diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py index 1d0ab7ea..0a8c90f6 100644 --- a/pydis_site/apps/api/models/__init__.py +++ b/pydis_site/apps/api/models/__init__.py @@ -12,7 +12,5 @@ from .bot import ( OffTopicChannelName, Reminder, Role, - Tag, User ) -from .log_entry import LogEntry diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py index efd98184..1673b434 100644 --- a/pydis_site/apps/api/models/bot/__init__.py +++ b/pydis_site/apps/api/models/bot/__init__.py @@ -11,5 +11,4 @@ from .off_topic_channel_name import OffTopicChannelName from .offensive_message import OffensiveMessage from .reminder import Reminder from .role import Role -from .tag import Tag from .user import User diff --git a/pydis_site/apps/api/models/bot/deleted_message.py b/pydis_site/apps/api/models/bot/deleted_message.py index 1eb4516e..50b70d8c 100644 --- a/pydis_site/apps/api/models/bot/deleted_message.py +++ b/pydis_site/apps/api/models/bot/deleted_message.py @@ -14,6 +14,6 @@ class DeletedMessage(Message): ) class Meta: - """Sets the default ordering for list views to oldest first.""" + """Sets the default ordering for list views to newest first.""" - ordering = ["id"] + ordering = ("-id",) diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py index 5a46460b..2a0ce751 100644 --- a/pydis_site/apps/api/models/bot/documentation_link.py +++ b/pydis_site/apps/api/models/bot/documentation_link.py @@ -24,3 +24,8 @@ class DocumentationLink(ModelReprMixin, models.Model): def __str__(self): """Returns the package and URL for the current documentation link, for display purposes.""" return f"{self.package} - {self.base_url}" + + class Meta: + """Defines the meta options for the documentation link model.""" + + ordering = ['package'] diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py index 78dcbf1d..ff06de21 100644 --- a/pydis_site/apps/api/models/bot/message.py +++ b/pydis_site/apps/api/models/bot/message.py @@ -5,9 +5,9 @@ from django.core.validators import MinValueValidator from django.db import models from django.utils import timezone -from pydis_site.apps.api.models.bot.tag import validate_tag_embed from pydis_site.apps.api.models.bot.user import User from pydis_site.apps.api.models.mixins import ModelReprMixin +from pydis_site.apps.api.models.utils import validate_embed class Message(ModelReprMixin, models.Model): @@ -21,7 +21,8 @@ class Message(ModelReprMixin, models.Model): limit_value=0, message="Message IDs cannot be negative." ), - ) + ), + verbose_name="ID" ) author = models.ForeignKey( User, @@ -38,7 +39,8 @@ class Message(ModelReprMixin, models.Model): limit_value=0, message="Channel IDs cannot be negative." ), - ) + ), + verbose_name="Channel ID" ) content = models.CharField( max_length=2_000, @@ -47,7 +49,7 @@ class Message(ModelReprMixin, models.Model): ) embeds = pgfields.ArrayField( pgfields.JSONField( - validators=(validate_tag_embed,) + validators=(validate_embed,) ), blank=True, help_text="Embeds attached to this message." diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py index 04ae8d34..1410250a 100644 --- a/pydis_site/apps/api/models/bot/message_deletion_context.py +++ b/pydis_site/apps/api/models/bot/message_deletion_context.py @@ -1,4 +1,5 @@ from django.db import models +from django_hosts.resolvers import reverse from pydis_site.apps.api.models.bot.user import User from pydis_site.apps.api.models.mixins import ModelReprMixin @@ -28,3 +29,13 @@ class MessageDeletionContext(ModelReprMixin, models.Model): # the deletion context does not take place in the future. help_text="When this deletion took place." ) + + @property + def log_url(self) -> str: + """Create the url for the deleted message logs.""" + return reverse('logs', host="staff", args=(self.id,)) + + class Meta: + """Set the ordering for list views to newest first.""" + + ordering = ("-creation",) diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py index 21e34e87..11b9e36e 100644 --- a/pydis_site/apps/api/models/bot/nomination.py +++ b/pydis_site/apps/api/models/bot/nomination.py @@ -18,7 +18,9 @@ class Nomination(ModelReprMixin, models.Model): related_name='nomination_set' ) reason = models.TextField( - help_text="Why this user was nominated." + help_text="Why this user was nominated.", + null=True, + blank=True ) user = models.ForeignKey( User, @@ -32,7 +34,8 @@ class Nomination(ModelReprMixin, models.Model): ) end_reason = models.TextField( help_text="Why the nomination was ended.", - default="" + default="", + blank=True ) ended_at = models.DateTimeField( auto_now_add=False, @@ -44,3 +47,8 @@ class Nomination(ModelReprMixin, models.Model): """Representation that makes the target and state of the nomination immediately evident.""" status = "active" if self.active else "ended" return f"Nomination of {self.user} ({status})" + + class Meta: + """Set the ordering of nominations to most recent first.""" + + ordering = ("-inserted_at",) diff --git a/pydis_site/apps/api/models/bot/off_topic_channel_name.py b/pydis_site/apps/api/models/bot/off_topic_channel_name.py index 20e77b9f..403c7465 100644 --- a/pydis_site/apps/api/models/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py @@ -16,6 +16,11 @@ class OffTopicChannelName(ModelReprMixin, models.Model): help_text="The actual channel name that will be used on our Discord server." ) + used = models.BooleanField( + default=False, + help_text="Whether or not this name has already been used during this rotation", + ) + def __str__(self): """Returns the current off-topic name, for display purposes.""" return self.name diff --git a/pydis_site/apps/api/models/bot/offensive_message.py b/pydis_site/apps/api/models/bot/offensive_message.py index 6c0e5ffb..74dab59b 100644 --- a/pydis_site/apps/api/models/bot/offensive_message.py +++ b/pydis_site/apps/api/models/bot/offensive_message.py @@ -24,7 +24,8 @@ class OffensiveMessage(ModelReprMixin, models.Model): limit_value=0, message="Message IDs cannot be negative." ), - ) + ), + verbose_name="Message ID" ) channel_id = models.BigIntegerField( help_text=( @@ -36,11 +37,13 @@ class OffensiveMessage(ModelReprMixin, models.Model): limit_value=0, message="Channel IDs cannot be negative." ), - ) + ), + verbose_name="Channel ID" ) delete_date = models.DateTimeField( help_text="The date on which the message will be auto-deleted.", - validators=(future_date_validator,) + validators=(future_date_validator,), + verbose_name="To Be Deleted" ) def __str__(self): diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py index 721e4815..cfadfec4 100644 --- a/pydis_site/apps/api/models/bot/role.py +++ b/pydis_site/apps/api/models/bot/role.py @@ -22,7 +22,8 @@ class Role(ModelReprMixin, models.Model): message="Role IDs cannot be negative." ), ), - help_text="The role ID, taken from Discord." + help_text="The role ID, taken from Discord.", + verbose_name="ID" ) name = models.CharField( max_length=100, @@ -65,3 +66,8 @@ class Role(ModelReprMixin, models.Model): def __le__(self, other: Role) -> bool: """Compares the roles based on their position in the role hierarchy of the guild.""" return self.position <= other.position + + class Meta: + """Set role ordering from highest to lowest position.""" + + ordering = ("-position",) diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py index cd2d58b9..afc5ba1e 100644 --- a/pydis_site/apps/api/models/bot/user.py +++ b/pydis_site/apps/api/models/bot/user.py @@ -26,11 +26,12 @@ class User(ModelReprMixin, models.Model): message="User IDs cannot be negative." ), ), + verbose_name="ID", help_text="The ID of this user, taken from Discord." ) name = models.CharField( max_length=32, - help_text="The username, taken from Discord." + help_text="The username, taken from Discord.", ) discriminator = models.PositiveSmallIntegerField( validators=( @@ -57,12 +58,13 @@ class User(ModelReprMixin, models.Model): ) in_guild = models.BooleanField( default=True, - help_text="Whether this user is in our server." + help_text="Whether this user is in our server.", + verbose_name="In Guild" ) def __str__(self): """Returns the name and discriminator for the current user, for display purposes.""" - return f"{self.name}#{self.discriminator:0>4}" + return f"{self.name}#{self.discriminator:04d}" @property def top_role(self) -> Role: @@ -75,3 +77,12 @@ class User(ModelReprMixin, models.Model): if not roles: return Role.objects.get(name="Developers") return max(roles) + + @property + def username(self) -> str: + """ + Returns the display version with name and discriminator as a standard attribute. + + For usability in read-only fields such as Django Admin. + """ + return str(self) diff --git a/pydis_site/apps/api/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py deleted file mode 100644 index 752cd2ca..00000000 --- a/pydis_site/apps/api/models/log_entry.py +++ /dev/null @@ -1,55 +0,0 @@ -from django.db import models -from django.utils import timezone - -from pydis_site.apps.api.models.mixins import ModelReprMixin - - -class LogEntry(ModelReprMixin, models.Model): - """A log entry generated by one of the PyDis applications.""" - - application = models.CharField( - max_length=20, - help_text="The application that generated this log entry.", - choices=( - ('bot', 'Bot'), - ('seasonalbot', 'Seasonalbot'), - ('site', 'Website') - ) - ) - logger_name = models.CharField( - max_length=100, - help_text="The name of the logger that generated this log entry." - ) - timestamp = models.DateTimeField( - default=timezone.now, - help_text="The date and time when this entry was created." - ) - level = models.CharField( - max_length=8, # 'critical' - choices=( - ('debug', 'Debug'), - ('info', 'Info'), - ('warning', 'Warning'), - ('error', 'Error'), - ('critical', 'Critical') - ), - help_text=( - "The logger level at which this entry was emitted. The levels " - "correspond to the Python `logging` levels." - ) - ) - module = models.CharField( - max_length=100, - help_text="The fully qualified path of the module generating this log line." - ) - line = models.PositiveSmallIntegerField( - help_text="The line at which the log line was emitted." - ) - message = models.TextField( - help_text="The textual content of the log line." - ) - - class Meta: - """Customizes the default generated plural name to valid English.""" - - verbose_name_plural = 'Log entries' diff --git a/pydis_site/apps/api/models/bot/tag.py b/pydis_site/apps/api/models/utils.py index 5e53582f..107231ba 100644 --- a/pydis_site/apps/api/models/bot/tag.py +++ b/pydis_site/apps/api/models/utils.py @@ -1,12 +1,8 @@ from collections.abc import Mapping from typing import Any, Dict -from django.contrib.postgres import fields as pgfields from django.core.exceptions import ValidationError from django.core.validators import MaxLengthValidator, MinLengthValidator -from django.db import models - -from pydis_site.apps.api.models.mixins import ModelReprMixin def is_bool_validator(value: Any) -> None: @@ -15,7 +11,7 @@ def is_bool_validator(value: Any) -> None: raise ValidationError(f"This field must be of type bool, not {type(value)}.") -def validate_tag_embed_fields(fields: dict) -> None: +def validate_embed_fields(fields: dict) -> None: """Raises a ValidationError if any of the given embed fields is invalid.""" field_validators = { 'name': (MaxLengthValidator(limit_value=256),), @@ -42,7 +38,7 @@ def validate_tag_embed_fields(fields: dict) -> None: validator(value) -def validate_tag_embed_footer(footer: Dict[str, str]) -> None: +def validate_embed_footer(footer: Dict[str, str]) -> None: """Raises a ValidationError if the given footer is invalid.""" field_validators = { 'text': ( @@ -67,7 +63,7 @@ def validate_tag_embed_footer(footer: Dict[str, str]) -> None: validator(value) -def validate_tag_embed_author(author: Any) -> None: +def validate_embed_author(author: Any) -> None: """Raises a ValidationError if the given author is invalid.""" field_validators = { 'name': ( @@ -93,7 +89,7 @@ def validate_tag_embed_author(author: Any) -> None: validator(value) -def validate_tag_embed(embed: Any) -> None: +def validate_embed(embed: Any) -> None: """ Validate a JSON document containing an embed as possible to send on Discord. @@ -109,11 +105,11 @@ def validate_tag_embed(embed: Any) -> None: >>> from django.contrib.postgres import fields as pgfields >>> from django.db import models - >>> from pydis_site.apps.api.models.bot.tag import validate_tag_embed + >>> from pydis_site.apps.api.models.utils import validate_embed >>> class MyMessage(models.Model): ... embed = pgfields.JSONField( ... validators=( - ... validate_tag_embed, + ... validate_embed, ... ) ... ) ... # ... @@ -149,10 +145,10 @@ def validate_tag_embed(embed: Any) -> None: 'description': (MaxLengthValidator(limit_value=2048),), 'fields': ( MaxLengthValidator(limit_value=25), - validate_tag_embed_fields + validate_embed_fields ), - 'footer': (validate_tag_embed_footer,), - 'author': (validate_tag_embed_author,) + 'footer': (validate_embed_footer,), + 'author': (validate_embed_author,) } if not embed: @@ -175,24 +171,3 @@ def validate_tag_embed(embed: Any) -> None: if field_name in field_validators: for validator in field_validators[field_name]: validator(value) - - -class Tag(ModelReprMixin, models.Model): - """A tag providing (hopefully) useful information.""" - - title = models.CharField( - max_length=100, - help_text=( - "The title of this tag, shown in searches and providing " - "a quick overview over what this embed contains." - ), - primary_key=True - ) - embed = pgfields.JSONField( - help_text="The actual embed shown by this tag.", - validators=(validate_tag_embed,) - ) - - def __str__(self): - """Returns the title of this tag, for display purposes.""" - return self.title diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py index 52e0d972..25c5c82e 100644 --- a/pydis_site/apps/api/serializers.py +++ b/pydis_site/apps/api/serializers.py @@ -1,7 +1,16 @@ """Converters from Django models to data interchange formats and back.""" -from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError +from django.db.models.query import QuerySet +from django.db.utils import IntegrityError +from rest_framework.exceptions import NotFound +from rest_framework.serializers import ( + IntegerField, + ListSerializer, + ModelSerializer, + PrimaryKeyRelatedField, + ValidationError +) +from rest_framework.settings import api_settings from rest_framework.validators import UniqueTogetherValidator -from rest_framework_bulk import BulkSerializerMixin from .models import ( BotSetting, @@ -9,14 +18,12 @@ from .models import ( DocumentationLink, FilterList, Infraction, - LogEntry, MessageDeletionContext, Nomination, OffTopicChannelName, OffensiveMessage, Reminder, Role, - Tag, User ) @@ -192,19 +199,6 @@ class ExpandedInfractionSerializer(InfractionSerializer): return ret -class LogEntrySerializer(ModelSerializer): - """A class providing (de-)serialization of `LogEntry` instances.""" - - class Meta: - """Metadata defined for the Django REST Framework.""" - - model = LogEntry - fields = ( - 'application', 'logger_name', 'timestamp', - 'level', 'module', 'line', 'message' - ) - - class OffTopicChannelNameSerializer(ModelSerializer): """A class providing (de-)serialization of `OffTopicChannelName` instances.""" @@ -250,25 +244,98 @@ class RoleSerializer(ModelSerializer): fields = ('id', 'name', 'colour', 'permissions', 'position') -class TagSerializer(ModelSerializer): - """A class providing (de-)serialization of `Tag` instances.""" +class UserListSerializer(ListSerializer): + """List serializer for User model to handle bulk updates.""" - class Meta: - """Metadata defined for the Django REST Framework.""" + def create(self, validated_data: list) -> list: + """Override create method to optimize django queries.""" + new_users = [] + seen = set() + + for user_dict in validated_data: + if user_dict["id"] in seen: + raise ValidationError( + {"id": [f"User with ID {user_dict['id']} given multiple times."]} + ) + seen.add(user_dict["id"]) + new_users.append(User(**user_dict)) + + User.objects.bulk_create(new_users, ignore_conflicts=True) + return [] - model = Tag - fields = ('title', 'embed') + def update(self, queryset: QuerySet, validated_data: list) -> list: + """ + Override update method to support bulk updates. + + ref:https://www.django-rest-framework.org/api-guide/serializers/#customizing-multiple-update + """ + object_ids = set() + + for data in validated_data: + try: + if data["id"] in object_ids: + # If request data contains users with same ID. + raise ValidationError( + {"id": [f"User with ID {data['id']} given multiple times."]} + ) + except KeyError: + # If user ID not provided in request body. + raise ValidationError( + {"id": ["This field is required."]} + ) + object_ids.add(data["id"]) + + # filter queryset + filtered_instances = queryset.filter(id__in=object_ids) + + instance_mapping = {user.id: user for user in filtered_instances} + + updated = [] + fields_to_update = set() + for user_data in validated_data: + for key in user_data: + fields_to_update.add(key) + try: + user = instance_mapping[user_data["id"]] + except KeyError: + raise NotFound({"detail": f"User with id {user_data['id']} not found."}) -class UserSerializer(BulkSerializerMixin, ModelSerializer): + user.__dict__.update(user_data) + updated.append(user) + + fields_to_update.remove("id") + + if not fields_to_update: + # Raise ValidationError when only id field is given. + raise ValidationError( + {api_settings.NON_FIELD_ERRORS_KEY: ["Insufficient data provided."]} + ) + + User.objects.bulk_update(updated, fields_to_update) + return updated + + +class UserSerializer(ModelSerializer): """A class providing (de-)serialization of `User` instances.""" + # ID field must be explicitly set as the default id field is read-only. + id = IntegerField(min_value=0) + class Meta: """Metadata defined for the Django REST Framework.""" model = User fields = ('id', 'name', 'discriminator', 'roles', 'in_guild') depth = 1 + list_serializer_class = UserListSerializer + + def create(self, validated_data: dict) -> User: + """Override create method to catch IntegrityError.""" + try: + return super().create(validated_data) + except IntegrityError: + raise ValidationError({"id": ["User with ID already present."]}) class NominationSerializer(ModelSerializer): diff --git a/pydis_site/apps/api/tests/test_dblogger.py b/pydis_site/apps/api/tests/test_dblogger.py deleted file mode 100644 index bb19f297..00000000 --- a/pydis_site/apps/api/tests/test_dblogger.py +++ /dev/null @@ -1,27 +0,0 @@ -import logging -from datetime import datetime - -from django.test import TestCase - -from ..dblogger import DatabaseLogHandler -from ..models import LogEntry - - -class DatabaseLogHandlerTests(TestCase): - def test_logs_to_database(self): - module_basename = __name__.split('.')[-1] - logger = logging.getLogger(__name__) - logger.handlers = [DatabaseLogHandler()] - logger.warning("I am a test case!") - - # Ensure we only have a single record in the database - # after the logging call above. - [entry] = LogEntry.objects.all() - - self.assertEqual(entry.application, 'site') - self.assertEqual(entry.logger_name, __name__) - self.assertIsInstance(entry.timestamp, datetime) - self.assertEqual(entry.level, 'warning') - self.assertEqual(entry.module, module_basename) - self.assertIsInstance(entry.line, int) - self.assertEqual(entry.message, "I am a test case!") diff --git a/pydis_site/apps/api/tests/test_deleted_messages.py b/pydis_site/apps/api/tests/test_deleted_messages.py index f079a8dd..40450844 100644 --- a/pydis_site/apps/api/tests/test_deleted_messages.py +++ b/pydis_site/apps/api/tests/test_deleted_messages.py @@ -1,5 +1,6 @@ from datetime import datetime +from django.utils import timezone from django_hosts.resolvers import reverse from .base import APISubdomainTestCase @@ -76,3 +77,23 @@ class DeletedMessagesWithActorTests(APISubdomainTestCase): self.assertEqual(response.status_code, 201) [context] = MessageDeletionContext.objects.all() self.assertEqual(context.actor.id, self.actor.id) + + +class DeletedMessagesLogURLTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + cls.author = cls.actor = User.objects.create( + id=324888, + name='Black Knight', + discriminator=1975, + ) + + cls.deletion_context = MessageDeletionContext.objects.create( + actor=cls.actor, + creation=timezone.now() + ) + + def test_valid_log_url(self): + expected_url = reverse('logs', host="staff", args=(1,)) + [context] = MessageDeletionContext.objects.all() + self.assertEqual(context.log_url, expected_url) diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py index e0e347bb..853e6621 100644 --- a/pydis_site/apps/api/tests/test_models.py +++ b/pydis_site/apps/api/tests/test_models.py @@ -14,7 +14,6 @@ from pydis_site.apps.api.models import ( OffensiveMessage, Reminder, Role, - Tag, User ) from pydis_site.apps.api.models.mixins import ModelReprMixin @@ -104,10 +103,6 @@ class StringDunderMethodTests(SimpleTestCase): ), creation=dt.utcnow() ), - Tag( - title='bob', - embed={'content': "the builder"} - ), User( id=5, name='bob', diff --git a/pydis_site/apps/api/tests/test_nominations.py b/pydis_site/apps/api/tests/test_nominations.py index 92c62c87..b37135f8 100644 --- a/pydis_site/apps/api/tests/test_nominations.py +++ b/pydis_site/apps/api/tests/test_nominations.py @@ -80,7 +80,7 @@ class CreationTests(APISubdomainTestCase): 'actor': ['This field is required.'] }) - def test_returns_400_for_missing_reason(self): + def test_returns_201_for_missing_reason(self): url = reverse('bot:nomination-list', host='api') data = { 'user': self.user.id, @@ -88,10 +88,7 @@ class CreationTests(APISubdomainTestCase): } response = self.client.post(url, data=data) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.json(), { - 'reason': ['This field is required.'] - }) + self.assertEqual(response.status_code, 201) def test_returns_400_for_bad_user(self): url = reverse('bot:nomination-list', host='api') diff --git a/pydis_site/apps/api/tests/test_off_topic_channel_names.py b/pydis_site/apps/api/tests/test_off_topic_channel_names.py index bd42cd81..3ab8b22d 100644 --- a/pydis_site/apps/api/tests/test_off_topic_channel_names.py +++ b/pydis_site/apps/api/tests/test_off_topic_channel_names.py @@ -10,12 +10,14 @@ class UnauthenticatedTests(APISubdomainTestCase): self.client.force_authenticate(user=None) def test_cannot_read_off_topic_channel_name_list(self): + """Return a 401 response when not authenticated.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(url) self.assertEqual(response.status_code, 401) def test_cannot_read_off_topic_channel_name_list_with_random_item_param(self): + """Return a 401 response when `random_items` provided and not authenticated.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(f'{url}?random_items=no') @@ -24,6 +26,7 @@ class UnauthenticatedTests(APISubdomainTestCase): class EmptyDatabaseTests(APISubdomainTestCase): def test_returns_empty_object(self): + """Return empty list when no names in database.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(url) @@ -31,6 +34,7 @@ class EmptyDatabaseTests(APISubdomainTestCase): self.assertEqual(response.json(), []) def test_returns_empty_list_with_get_all_param(self): + """Return empty list when no names and `random_items` param provided.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(f'{url}?random_items=5') @@ -38,6 +42,7 @@ class EmptyDatabaseTests(APISubdomainTestCase): self.assertEqual(response.json(), []) def test_returns_400_for_bad_random_items_param(self): + """Return error message when passing not integer as `random_items`.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(f'{url}?random_items=totally-a-valid-integer') @@ -47,6 +52,7 @@ class EmptyDatabaseTests(APISubdomainTestCase): }) def test_returns_400_for_negative_random_items_param(self): + """Return error message when passing negative int as `random_items`.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(f'{url}?random_items=-5') @@ -59,10 +65,11 @@ class EmptyDatabaseTests(APISubdomainTestCase): class ListTests(APISubdomainTestCase): @classmethod def setUpTestData(cls): - cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand') - cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') + cls.test_name = OffTopicChannelName.objects.create(name='lemons-lemonade-stand', used=False) + cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk', used=True) def test_returns_name_in_list(self): + """Return all off-topic channel names.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(url) @@ -76,11 +83,21 @@ class ListTests(APISubdomainTestCase): ) def test_returns_single_item_with_random_items_param_set_to_1(self): + """Return not-used name instead used.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.get(f'{url}?random_items=1') self.assertEqual(response.status_code, 200) self.assertEqual(len(response.json()), 1) + self.assertEqual(response.json(), [self.test_name.name]) + + def test_running_out_of_names_with_random_parameter(self): + """Reset names `used` parameter to `False` when running out of names.""" + url = reverse('bot:offtopicchannelname-list', host='api') + response = self.client.get(f'{url}?random_items=2') + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json(), [self.test_name.name, self.test_name_2.name]) class CreationTests(APISubdomainTestCase): @@ -93,6 +110,7 @@ class CreationTests(APISubdomainTestCase): self.assertEqual(response.status_code, 201) def test_returns_201_for_unicode_chars(self): + """Accept all valid characters.""" url = reverse('bot:offtopicchannelname-list', host='api') names = ( '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹', @@ -104,6 +122,7 @@ class CreationTests(APISubdomainTestCase): self.assertEqual(response.status_code, 201) def test_returns_400_for_missing_name_param(self): + """Return error message when name not provided.""" url = reverse('bot:offtopicchannelname-list', host='api') response = self.client.post(url) self.assertEqual(response.status_code, 400) @@ -112,6 +131,7 @@ class CreationTests(APISubdomainTestCase): }) def test_returns_400_for_bad_name_param(self): + """Return error message when invalid characters provided.""" url = reverse('bot:offtopicchannelname-list', host='api') invalid_names = ( 'space between words', @@ -134,18 +154,21 @@ class DeletionTests(APISubdomainTestCase): cls.test_name_2 = OffTopicChannelName.objects.create(name='bbq-with-bisk') def test_deleting_unknown_name_returns_404(self): + """Return 404 reponse when trying to delete unknown name.""" url = reverse('bot:offtopicchannelname-detail', args=('unknown-name',), host='api') response = self.client.delete(url) self.assertEqual(response.status_code, 404) def test_deleting_known_name_returns_204(self): + """Return 204 response when deleting was successful.""" url = reverse('bot:offtopicchannelname-detail', args=(self.test_name.name,), host='api') response = self.client.delete(url) self.assertEqual(response.status_code, 204) def test_name_gets_deleted(self): + """Name gets actually deleted.""" url = reverse('bot:offtopicchannelname-detail', args=(self.test_name_2.name,), host='api') response = self.client.delete(url) diff --git a/pydis_site/apps/api/tests/test_users.py b/pydis_site/apps/api/tests/test_users.py index 4c0f6e27..825e4edb 100644 --- a/pydis_site/apps/api/tests/test_users.py +++ b/pydis_site/apps/api/tests/test_users.py @@ -45,6 +45,13 @@ class CreationTests(APISubdomainTestCase): position=1 ) + cls.user = User.objects.create( + id=11, + name="Name doesn't matter.", + discriminator=1122, + in_guild=True + ) + def test_accepts_valid_data(self): url = reverse('bot:user-list', host='api') data = { @@ -89,7 +96,7 @@ class CreationTests(APISubdomainTestCase): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 201) - self.assertEqual(response.json(), data) + self.assertEqual(response.json(), []) def test_returns_400_for_unknown_role_id(self): url = reverse('bot:user-list', host='api') @@ -115,6 +122,176 @@ class CreationTests(APISubdomainTestCase): response = self.client.post(url, data=data) self.assertEqual(response.status_code, 400) + def test_returns_400_for_user_recreation(self): + """Return 201 if User is already present in database as it skips User creation.""" + url = reverse('bot:user-list', host='api') + data = [{ + 'id': 11, + 'name': 'You saw nothing.', + 'discriminator': 112, + 'in_guild': True + }] + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 201) + + def test_returns_400_for_duplicate_request_users(self): + """Return 400 if 2 Users with same ID is passed in the request data.""" + url = reverse('bot:user-list', host='api') + data = [ + { + 'id': 11, + 'name': 'You saw nothing.', + 'discriminator': 112, + 'in_guild': True + }, + { + 'id': 11, + 'name': 'You saw nothing part 2.', + 'discriminator': 1122, + 'in_guild': False + } + ] + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_existing_user(self): + """Returns 400 if user is already present in DB.""" + url = reverse('bot:user-list', host='api') + data = { + 'id': 11, + 'name': 'You saw nothing part 3.', + 'discriminator': 1122, + 'in_guild': True + } + response = self.client.post(url, data=data) + self.assertEqual(response.status_code, 400) + + +class MultiPatchTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + cls.role_developer = Role.objects.create( + id=159, + name="Developer", + colour=2, + permissions=0b01010010101, + position=10, + ) + cls.user_1 = User.objects.create( + id=1, + name="Patch test user 1.", + discriminator=1111, + in_guild=True + ) + cls.user_2 = User.objects.create( + id=2, + name="Patch test user 2.", + discriminator=2222, + in_guild=True + ) + + def test_multiple_users_patch(self): + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + "id": 1, + "name": "User 1 patched!", + "discriminator": 1010, + "roles": [self.role_developer.id], + "in_guild": False + }, + { + "id": 2, + "name": "User 2 patched!" + } + ] + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()[0], data[0]) + + user_2 = User.objects.get(id=2) + self.assertEqual(user_2.name, data[1]["name"]) + + def test_returns_400_for_missing_user_id(self): + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + "name": "I am ghost user!", + "discriminator": 1010, + "roles": [self.role_developer.id], + "in_guild": False + }, + { + "name": "patch me? whats my id?" + } + ] + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + + def test_returns_404_for_not_found_user(self): + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + "id": 1, + "name": "User 1 patched again!!!", + "discriminator": 1010, + "roles": [self.role_developer.id], + "in_guild": False + }, + { + "id": 22503405, + "name": "User unknown not patched!" + } + ] + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 404) + + def test_returns_400_for_bad_data(self): + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + "id": 1, + "in_guild": "Catch me!" + }, + { + "id": 2, + "discriminator": "find me!" + } + ] + + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_insufficient_data(self): + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + "id": 1, + }, + { + "id": 2, + } + ] + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + + def test_returns_400_for_duplicate_request_users(self): + """Return 400 if 2 Users with same ID is passed in the request data.""" + url = reverse("bot:user-bulk-patch", host="api") + data = [ + { + 'id': 1, + 'name': 'You saw nothing.', + }, + { + 'id': 1, + 'name': 'You saw nothing part 2.', + } + ] + response = self.client.patch(url, data=data) + self.assertEqual(response.status_code, 400) + class UserModelTests(APISubdomainTestCase): @classmethod @@ -143,7 +320,7 @@ class UserModelTests(APISubdomainTestCase): cls.user_with_roles = User.objects.create( id=1, name="Test User with two roles", - discriminator=1111, + discriminator=1, in_guild=True, ) cls.user_with_roles.roles.extend([cls.role_bottom.id, cls.role_top.id]) @@ -166,3 +343,49 @@ class UserModelTests(APISubdomainTestCase): top_role = self.user_without_roles.top_role self.assertIsInstance(top_role, Role) self.assertEqual(top_role.id, self.developers_role.id) + + def test_correct_username_formatting(self): + """Tests the username property with both name and discriminator formatted together.""" + self.assertEqual(self.user_with_roles.username, "Test User with two roles#0001") + + +class UserPaginatorTests(APISubdomainTestCase): + @classmethod + def setUpTestData(cls): + users = [] + for i in range(1, 10_001): + users.append(User( + id=i, + name=f"user{i}", + discriminator=1111, + in_guild=True + )) + cls.users = User.objects.bulk_create(users) + + def test_returns_single_page_response(self): + url = reverse("bot:user-list", host="api") + response = self.client.get(url).json() + self.assertIsNone(response["next_page_no"]) + self.assertIsNone(response["previous_page_no"]) + + def test_returns_next_page_number(self): + User.objects.create( + id=10_001, + name="user10001", + discriminator=1111, + in_guild=True + ) + url = reverse("bot:user-list", host="api") + response = self.client.get(url).json() + self.assertEqual(2, response["next_page_no"]) + + def test_returns_previous_page_number(self): + User.objects.create( + id=10_001, + name="user10001", + discriminator=1111, + in_guild=True + ) + url = reverse("bot:user-list", host="api") + response = self.client.get(url, {"page": 2}).json() + self.assertEqual(1, response["previous_page_no"]) diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py index 241af08c..8bb7b917 100644 --- a/pydis_site/apps/api/tests/test_validators.py +++ b/pydis_site/apps/api/tests/test_validators.py @@ -5,7 +5,7 @@ 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 +from ..models.utils import validate_embed REQUIRED_KEYS = ( @@ -25,77 +25,77 @@ class BotSettingValidatorTests(TestCase): class TagEmbedValidatorTests(TestCase): def test_rejects_non_mapping(self): with self.assertRaises(ValidationError): - validate_tag_embed('non-empty non-mapping') + validate_embed('non-empty non-mapping') def test_rejects_missing_required_keys(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'unknown': "key" }) def test_rejects_one_correct_one_incorrect(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'provider': "??", 'title': "" }) def test_rejects_empty_required_key(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': '' }) def test_rejects_list_as_embed(self): with self.assertRaises(ValidationError): - validate_tag_embed([]) + validate_embed([]) def test_rejects_required_keys_and_unknown_keys(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "the duck walked up to the lemonade stand", 'and': "he said to the man running the stand" }) def test_rejects_too_long_title(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': 'a' * 257 }) def test_rejects_too_many_fields(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [{} for _ in range(26)] }) def test_rejects_too_long_description(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'description': 'd' * 2049 }) def test_allows_valid_embed(self): - validate_tag_embed({ + validate_embed({ 'title': "My embed", 'description': "look at my embed, my embed is amazing" }) def test_allows_unvalidated_fields(self): - validate_tag_embed({ + validate_embed({ 'title': "My embed", 'provider': "what am I??" }) def test_rejects_fields_as_list_of_non_mappings(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': ['abc'] }) def test_rejects_fields_with_unknown_fields(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'what': "is this field" @@ -105,7 +105,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_fields_with_too_long_name(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'name': "a" * 257 @@ -115,7 +115,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_one_correct_one_incorrect_field(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'name': "Totally valid", @@ -131,7 +131,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_missing_required_field_field(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'name': "Totally valid", @@ -142,7 +142,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_invalid_inline_field_field(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'name': "Totally valid", @@ -153,7 +153,7 @@ class TagEmbedValidatorTests(TestCase): }) def test_allows_valid_fields(self): - validate_tag_embed({ + validate_embed({ 'fields': [ { 'name': "valid", @@ -174,14 +174,14 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_footer_as_non_mapping(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'footer': [] }) def test_rejects_footer_with_unknown_fields(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'footer': { 'duck': "quack" @@ -190,7 +190,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_footer_with_empty_text(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'footer': { 'text': "" @@ -198,7 +198,7 @@ class TagEmbedValidatorTests(TestCase): }) def test_allows_footer_with_proper_values(self): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'footer': { 'text': "django good" @@ -207,14 +207,14 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_author_as_non_mapping(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'author': [] }) def test_rejects_author_with_unknown_field(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'author': { 'field': "that is unknown" @@ -223,7 +223,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_author_with_empty_name(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'author': { 'name': "" @@ -232,7 +232,7 @@ class TagEmbedValidatorTests(TestCase): def test_rejects_author_with_one_correct_one_incorrect(self): with self.assertRaises(ValidationError): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'author': { # Relies on "dictionary insertion order remembering" (D.I.O.R.) behaviour @@ -242,7 +242,7 @@ class TagEmbedValidatorTests(TestCase): }) def test_allows_author_with_proper_values(self): - validate_tag_embed({ + validate_embed({ 'title': "whatever", 'author': { 'name': "Bob" diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py index a4fd5b2e..2e1ef0b4 100644 --- a/pydis_site/apps/api/urls.py +++ b/pydis_site/apps/api/urls.py @@ -8,13 +8,11 @@ from .viewsets import ( DocumentationLinkViewSet, FilterListViewSet, InfractionViewSet, - LogEntryViewSet, NominationViewSet, OffTopicChannelNameViewSet, OffensiveMessageViewSet, ReminderViewSet, RoleViewSet, - TagViewSet, UserViewSet ) @@ -62,10 +60,6 @@ bot_router.register( RoleViewSet ) bot_router.register( - 'tags', - TagViewSet -) -bot_router.register( 'users', UserViewSet ) @@ -76,7 +70,6 @@ urlpatterns = ( # # from django_hosts.resolvers import reverse path('bot/', include((bot_router.urls, 'api'), namespace='bot')), - path('logs', LogEntryViewSet.as_view({'post': 'create'}), name='logs'), path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), path('rules', RulesView.as_view(), name='rules') ) diff --git a/pydis_site/apps/api/views.py b/pydis_site/apps/api/views.py index 7ac56641..0d126051 100644 --- a/pydis_site/apps/api/views.py +++ b/pydis_site/apps/api/views.py @@ -135,8 +135,9 @@ class RulesView(APIView): ), ( "Do not provide or request help on projects that may break laws, " - "breach terms of services, be considered malicious/inappropriate " - "or be for graded coursework/exams." + "breach terms of services, be considered malicious or inappropriate. " + "Do not help with ongoing exams. Do not provide or request solutions " + "for graded assignments, although general guidance is okay." ), ( "No spamming or unapproved advertising, including requests for paid work. " diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py index 8699517e..f133e77f 100644 --- a/pydis_site/apps/api/viewsets/__init__.py +++ b/pydis_site/apps/api/viewsets/__init__.py @@ -10,7 +10,5 @@ from .bot import ( OffTopicChannelNameViewSet, ReminderViewSet, RoleViewSet, - TagViewSet, UserViewSet ) -from .log_entry import LogEntryViewSet diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py index e64e3988..84b87eab 100644 --- a/pydis_site/apps/api/viewsets/bot/__init__.py +++ b/pydis_site/apps/api/viewsets/bot/__init__.py @@ -9,5 +9,4 @@ from .off_topic_channel_name import OffTopicChannelNameViewSet from .offensive_message import OffensiveMessageViewSet from .reminder import ReminderViewSet from .role import RoleViewSet -from .tag import TagViewSet from .user import UserViewSet diff --git a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py index d6da2399..826ad25e 100644 --- a/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py +++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py @@ -1,3 +1,4 @@ +from django.db.models import Case, Value, When from django.db.models.query import QuerySet from django.http.request import HttpRequest from django.shortcuts import get_object_or_404 @@ -20,7 +21,9 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): Return all known off-topic channel names from the database. If the `random_items` query parameter is given, for example using... $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?random_items=5 - ... then the API will return `5` random items from the database. + ... then the API will return `5` random items from the database + that is not used in current rotation. + When running out of names, API will mark all names to not used and start new rotation. #### Response format Return a list of off-topic-channel names: @@ -106,7 +109,27 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): 'random_items': ["Must be a positive integer."] }) - queryset = self.get_queryset().order_by('?')[:random_count] + queryset = self.get_queryset().order_by('used', '?')[:random_count] + + # When any name is used in our listing then this means we reached end of round + # and we need to reset all other names `used` to False + if any(offtopic_name.used for offtopic_name in queryset): + # These names that we just got have to be excluded from updating used to False + self.get_queryset().update( + used=Case( + When( + name__in=(offtopic_name.name for offtopic_name in queryset), + then=Value(True) + ), + default=Value(False) + ) + ) + else: + # Otherwise mark selected names `used` to True + self.get_queryset().filter( + name__in=(offtopic_name.name for offtopic_name in queryset) + ).update(used=True) + serialized = self.serializer_class(queryset, many=True) return Response(serialized.data) diff --git a/pydis_site/apps/api/viewsets/bot/tag.py b/pydis_site/apps/api/viewsets/bot/tag.py deleted file mode 100644 index 7e9ba117..00000000 --- a/pydis_site/apps/api/viewsets/bot/tag.py +++ /dev/null @@ -1,105 +0,0 @@ -from rest_framework.viewsets import ModelViewSet - -from pydis_site.apps.api.models.bot.tag import Tag -from pydis_site.apps.api.serializers import TagSerializer - - -class TagViewSet(ModelViewSet): - """ - View providing CRUD operations on tags shown by our bot. - - ## Routes - ### GET /bot/tags - Returns all tags in the database. - - #### Response format - >>> [ - ... { - ... 'title': "resources", - ... 'embed': { - ... 'content': "Did you really think I'd put something useful here?" - ... } - ... } - ... ] - - #### Status codes - - 200: returned on success - - ### GET /bot/tags/<title:str> - Gets a single tag by its title. - - #### Response format - >>> { - ... 'title': "My awesome tag", - ... 'embed': { - ... 'content': "totally not filler words" - ... } - ... } - - #### Status codes - - 200: returned on success - - 404: if a tag with the given `title` could not be found - - ### POST /bot/tags - Adds a single tag to the database. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 201: returned on success - - 400: if one of the given fields is invalid - - ### PUT /bot/tags/<title:str> - Update the tag with the given `title`. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the tag with the given `title` could not be found - - ### PATCH /bot/tags/<title:str> - Update the tag with the given `title`. - - #### Request body - >>> { - ... 'title': str, - ... 'embed': dict - ... } - - The embed structure is the same as the embed structure that the Discord API - expects. You can view the documentation for it here: - https://discordapp.com/developers/docs/resources/channel#embed-object - - #### Status codes - - 200: returned on success - - 400: if the request body was invalid, see response body for details - - 404: if the tag with the given `title` could not be found - - ### DELETE /bot/tags/<title:str> - Deletes the tag with the given `title`. - - #### Status codes - - 204: returned on success - - 404: if a tag with the given `title` does not exist - """ - - serializer_class = TagSerializer - queryset = Tag.objects.all() diff --git a/pydis_site/apps/api/viewsets/bot/user.py b/pydis_site/apps/api/viewsets/bot/user.py index 9571b3d7..3e4b627e 100644 --- a/pydis_site/apps/api/viewsets/bot/user.py +++ b/pydis_site/apps/api/viewsets/bot/user.py @@ -1,21 +1,64 @@ +import typing +from collections import OrderedDict + +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.pagination import PageNumberPagination +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.serializers import ModelSerializer from rest_framework.viewsets import ModelViewSet -from rest_framework_bulk import BulkCreateModelMixin from pydis_site.apps.api.models.bot.user import User from pydis_site.apps.api.serializers import UserSerializer -class UserViewSet(BulkCreateModelMixin, ModelViewSet): +class UserListPagination(PageNumberPagination): + """Custom pagination class for the User Model.""" + + page_size = 10000 + page_size_query_param = "page_size" + + def get_next_page_number(self) -> typing.Optional[int]: + """Get the next page number.""" + if not self.page.has_next(): + return None + page_number = self.page.next_page_number() + return page_number + + def get_previous_page_number(self) -> typing.Optional[int]: + """Get the previous page number.""" + if not self.page.has_previous(): + return None + + page_number = self.page.previous_page_number() + return page_number + + def get_paginated_response(self, data: list) -> Response: + """Override method to send modified response.""" + return Response(OrderedDict([ + ('count', self.page.paginator.count), + ('next_page_no', self.get_next_page_number()), + ('previous_page_no', self.get_previous_page_number()), + ('results', data) + ])) + + +class UserViewSet(ModelViewSet): """ View providing CRUD operations on Discord users through the bot. ## Routes ### GET /bot/users - Returns all users currently known. + Returns all users currently known with pagination. #### Response format - >>> [ - ... { + >>> { + ... 'count': 95000, + ... 'next_page_no': "2", + ... 'previous_page_no': None, + ... 'results': [ + ... { ... 'id': 409107086526644234, ... 'name': "Python", ... 'discriminator': 4329, @@ -26,8 +69,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet): ... 458226699344019457 ... ], ... 'in_guild': True - ... } - ... ] + ... }, + ... ] + ... } + + #### Optional Query Parameters + - page_size: number of Users in one page, defaults to 10,000 + - page: page number #### Status codes - 200: returned on success @@ -56,6 +104,7 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet): ### POST /bot/users Adds a single or multiple new users. The roles attached to the user(s) must be roles known by the site. + Users that already exist in the database will be skipped. #### Request body >>> { @@ -67,11 +116,13 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet): ... } Alternatively, request users can be POSTed as a list of above objects, - in which case multiple users will be created at once. + in which case multiple users will be created at once. In this case, + the response is an empty list. #### Status codes - 201: returned on success - 400: if one of the given roles does not exist, or one of the given fields is invalid + - 400: if multiple user objects with the same id are given ### PUT /bot/users/<snowflake:int> Update the user with the given `snowflake`. @@ -109,6 +160,34 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet): - 400: if the request body was invalid, see response body for details - 404: if the user with the given `snowflake` could not be found + ### BULK PATCH /bot/users/bulk_patch + Update users with the given `ids` and `details`. + `id` field and at least one other field is mandatory. + + #### Request body + >>> [ + ... { + ... 'id': int, + ... 'name': str, + ... 'discriminator': int, + ... 'roles': List[int], + ... 'in_guild': bool + ... }, + ... { + ... 'id': int, + ... 'name': str, + ... 'discriminator': int, + ... 'roles': List[int], + ... 'in_guild': bool + ... }, + ... ] + + #### Status codes + - 200: returned on success + - 400: if the request body was invalid, see response body for details + - 400: if multiple user objects with the same id are given + - 404: if the user with the given id does not exist + ### DELETE /bot/users/<snowflake:int> Deletes the user with the given `snowflake`. @@ -118,4 +197,27 @@ class UserViewSet(BulkCreateModelMixin, ModelViewSet): """ serializer_class = UserSerializer - queryset = User.objects + queryset = User.objects.all().order_by("id") + pagination_class = UserListPagination + + def get_serializer(self, *args, **kwargs) -> ModelSerializer: + """Set Serializer many attribute to True if request body contains a list.""" + if isinstance(kwargs.get('data', {}), list): + kwargs['many'] = True + + return super().get_serializer(*args, **kwargs) + + @action(detail=False, methods=["PATCH"], name='user-bulk-patch') + def bulk_patch(self, request: Request) -> Response: + """Update multiple User objects in a single request.""" + serializer = self.get_serializer( + instance=self.get_queryset(), + data=request.data, + many=True, + partial=True + ) + + serializer.is_valid(raise_exception=True) + serializer.save() + + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/pydis_site/apps/api/viewsets/log_entry.py b/pydis_site/apps/api/viewsets/log_entry.py deleted file mode 100644 index 9108a4fa..00000000 --- a/pydis_site/apps/api/viewsets/log_entry.py +++ /dev/null @@ -1,36 +0,0 @@ -from rest_framework.mixins import CreateModelMixin -from rest_framework.viewsets import GenericViewSet - -from pydis_site.apps.api.models.log_entry import LogEntry -from pydis_site.apps.api.serializers import LogEntrySerializer - - -class LogEntryViewSet(CreateModelMixin, GenericViewSet): - """ - View supporting the creation of log entries in the database for viewing via the log browser. - - ## Routes - ### POST /logs - Create a new log entry. - - #### Request body - >>> { - ... 'application': str, # 'bot' | 'seasonalbot' | 'site' - ... 'logger_name': str, # such as 'bot.cogs.moderation' - ... 'timestamp': Optional[str], # from `datetime.utcnow().isoformat()` - ... 'level': str, # 'debug' | 'info' | 'warning' | 'error' | 'critical' - ... 'module': str, # such as 'pydis_site.apps.api.serializers' - ... 'line': int, # > 0 - ... 'message': str, # textual formatted content of the logline - ... } - - #### Status codes - - 201: returned on success - - 400: if the request body has invalid fields, see the response for details - - ## Authentication - Requires a API token. - """ - - queryset = LogEntry.objects.all() - serializer_class = LogEntrySerializer diff --git a/pydis_site/apps/home/resources/books/effective_python.yaml b/pydis_site/apps/home/resources/books/effective_python.yaml index ab782704..7f9d0dea 100644 --- a/pydis_site/apps/home/resources/books/effective_python.yaml +++ b/pydis_site/apps/home/resources/books/effective_python.yaml @@ -1,4 +1,4 @@ -description: A book that gives 59 best practices for writing excellent Python. Great +description: A book that gives 90 best practices for writing excellent Python. Great for intermediates. name: Effective Python payment: paid @@ -8,7 +8,7 @@ urls: url: https://effectivepython.com/ - icon: branding/amazon title: Amazon - url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134034287 + url: https://www.amazon.com/Effective-Python-Specific-Software-Development/dp/0134853989 - icon: branding/github title: GitHub url: https://github.com/bslatkin/effectivepython diff --git a/pydis_site/apps/home/tests/mock_github_api_response.json b/pydis_site/apps/home/tests/mock_github_api_response.json index 35604a85..10be4f99 100644 --- a/pydis_site/apps/home/tests/mock_github_api_response.json +++ b/pydis_site/apps/home/tests/mock_github_api_response.json @@ -28,7 +28,7 @@ "forks_count": 31 }, { - "full_name": "python-discord/flake8-annotations", + "full_name": "python-discord/metricity", "description": "test", "stargazers_count": 97, "language": "Python", diff --git a/pydis_site/apps/home/views/home.py b/pydis_site/apps/home/views/home.py index 7ad995cc..09969f1d 100644 --- a/pydis_site/apps/home/views/home.py +++ b/pydis_site/apps/home/views/home.py @@ -23,7 +23,7 @@ class HomeView(View): "python-discord/bot", "python-discord/snekbox", "python-discord/seasonalbot", - "python-discord/flake8-annotations", + "python-discord/metricity", "python-discord/django-simple-bulma", ] diff --git a/pydis_site/apps/staff/tests/test_logs_view.py b/pydis_site/apps/staff/tests/test_logs_view.py index 17910bb6..00e0ab2f 100644 --- a/pydis_site/apps/staff/tests/test_logs_view.py +++ b/pydis_site/apps/staff/tests/test_logs_view.py @@ -133,7 +133,7 @@ class TestLogsView(TestCase): response = self.client.get(url) self.assertIn("messages", response.context) self.assertListEqual( - [self.deleted_message_one, self.deleted_message_two], + [self.deleted_message_two, self.deleted_message_one], list(response.context["deletion_context"].deletedmessage_set.all()) ) diff --git a/pydis_site/constants.py b/pydis_site/constants.py new file mode 100644 index 00000000..0b76694a --- /dev/null +++ b/pydis_site/constants.py @@ -0,0 +1,5 @@ +import git + +# Git SHA +repo = git.Repo(search_parent_directories=True) +GIT_SHA = repo.head.object.hexsha diff --git a/pydis_site/context_processors.py b/pydis_site/context_processors.py new file mode 100644 index 00000000..6937a3db --- /dev/null +++ b/pydis_site/context_processors.py @@ -0,0 +1,8 @@ +from django.template import RequestContext + +from pydis_site.constants import GIT_SHA + + +def git_sha_processor(_: RequestContext) -> dict: + """Expose the git SHA for this repo to all views.""" + return {'git_sha': GIT_SHA} diff --git a/pydis_site/settings.py b/pydis_site/settings.py index 2c87007c..5eb812ac 100644 --- a/pydis_site/settings.py +++ b/pydis_site/settings.py @@ -20,6 +20,7 @@ import sentry_sdk from django.contrib.messages import constants as messages from sentry_sdk.integrations.django import DjangoIntegration +from pydis_site.constants import GIT_SHA if typing.TYPE_CHECKING: from django.contrib.auth.models import User @@ -33,7 +34,8 @@ env = environ.Env( sentry_sdk.init( dsn=env('SITE_SENTRY_DSN'), integrations=[DjangoIntegration()], - send_default_pii=True + send_default_pii=True, + release=f"pydis-site@{GIT_SHA}" ) # Build paths inside the project like this: os.path.join(BASE_DIR, ...) @@ -157,8 +159,8 @@ TEMPLATES = [ 'django.template.context_processors.static', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', - "sekizai.context_processors.sekizai", + "pydis_site.context_processors.git_sha_processor" ], }, }, @@ -258,14 +260,11 @@ LOGGING = { 'handlers': { 'console': { 'class': 'logging.StreamHandler' - }, - 'database': { - 'class': 'pydis_site.apps.api.dblogger.DatabaseLogHandler' } }, 'loggers': { 'django': { - 'handlers': ['console', 'database'], + 'handlers': ['console'], 'propagate': True, 'level': env( 'LOG_LEVEL', @@ -399,3 +398,11 @@ ACCOUNT_USERNAME_VALIDATORS = "pydis_site.VALIDATORS" LOGIN_REDIRECT_URL = "home" SOCIALACCOUNT_ADAPTER = "pydis_site.utils.account.SocialAccountAdapter" +SOCIALACCOUNT_PROVIDERS = { + "discord": { + "SCOPE": [ + "identify", + ], + "AUTH_PARAMS": {"prompt": "none"} + } +} diff --git a/pydis_site/templates/base/base.html b/pydis_site/templates/base/base.html index 4c70d778..70426dc1 100644 --- a/pydis_site/templates/base/base.html +++ b/pydis_site/templates/base/base.html @@ -37,6 +37,7 @@ {% render_block "css" %} </head> <body class="site"> + <!-- Git hash for this release: {{ git_sha }} --> <main class="site-content"> {% if messages %} diff --git a/pydis_site/templates/home/index.html b/pydis_site/templates/home/index.html index 3e96cc91..f31363a4 100644 --- a/pydis_site/templates/home/index.html +++ b/pydis_site/templates/home/index.html @@ -39,9 +39,7 @@ {# Right column container #} <div class="column is-half-desktop"> - <a href="https://pythondiscord.com/pages/code-jams/code-jam-7/"> - <img src="{% static "images/events/summer_code_jam_2020.png" %}"> - </a> + <iframe width="560" height="315" src="https://www.youtube.com/embed/ZH26PuX3re0" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> </div> |