aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.coveragerc1
-rw-r--r--.dockerignore1
-rw-r--r--.github/workflows/sentry-release.yml23
-rw-r--r--Pipfile2
-rw-r--r--Pipfile.lock325
-rw-r--r--README.md2
-rw-r--r--docker/Dockerfile6
-rwxr-xr-xmanage.py14
-rw-r--r--pydis_site/apps/api/admin.py423
-rw-r--r--pydis_site/apps/api/dblogger.py22
-rw-r--r--pydis_site/apps/api/migrations/0008_tag_embed_validator.py7
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py2
-rw-r--r--pydis_site/apps/api/migrations/0051_allow_blank_message_embeds.py4
-rw-r--r--pydis_site/apps/api/migrations/0051_delete_tag.py16
-rw-r--r--pydis_site/apps/api/migrations/0052_offtopicchannelname_used.py18
-rw-r--r--pydis_site/apps/api/migrations/0061_merge_20200830_0526.py14
-rw-r--r--pydis_site/apps/api/migrations/0062_merge_20200901_1459.py14
-rw-r--r--pydis_site/apps/api/migrations/0063_Allow_blank_or_null_for_nomination_reason.py18
-rw-r--r--pydis_site/apps/api/migrations/0064_auto_20200919_1900.py76
-rw-r--r--pydis_site/apps/api/migrations/0064_delete_logentry.py16
-rw-r--r--pydis_site/apps/api/migrations/0065_auto_20200919_2033.py17
-rw-r--r--pydis_site/apps/api/migrations/0066_merge_20201003_0730.py14
-rw-r--r--pydis_site/apps/api/models/__init__.py2
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py1
-rw-r--r--pydis_site/apps/api/models/bot/deleted_message.py4
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py5
-rw-r--r--pydis_site/apps/api/models/bot/message.py10
-rw-r--r--pydis_site/apps/api/models/bot/message_deletion_context.py11
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py12
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py5
-rw-r--r--pydis_site/apps/api/models/bot/offensive_message.py9
-rw-r--r--pydis_site/apps/api/models/bot/role.py8
-rw-r--r--pydis_site/apps/api/models/bot/user.py17
-rw-r--r--pydis_site/apps/api/models/log_entry.py55
-rw-r--r--pydis_site/apps/api/models/utils.py (renamed from pydis_site/apps/api/models/bot/tag.py)43
-rw-r--r--pydis_site/apps/api/serializers.py115
-rw-r--r--pydis_site/apps/api/tests/test_dblogger.py27
-rw-r--r--pydis_site/apps/api/tests/test_deleted_messages.py21
-rw-r--r--pydis_site/apps/api/tests/test_models.py5
-rw-r--r--pydis_site/apps/api/tests/test_nominations.py7
-rw-r--r--pydis_site/apps/api/tests/test_off_topic_channel_names.py27
-rw-r--r--pydis_site/apps/api/tests/test_users.py227
-rw-r--r--pydis_site/apps/api/tests/test_validators.py56
-rw-r--r--pydis_site/apps/api/urls.py7
-rw-r--r--pydis_site/apps/api/views.py5
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py2
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py1
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py27
-rw-r--r--pydis_site/apps/api/viewsets/bot/tag.py105
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py120
-rw-r--r--pydis_site/apps/api/viewsets/log_entry.py36
-rw-r--r--pydis_site/apps/home/resources/books/effective_python.yaml4
-rw-r--r--pydis_site/apps/home/tests/mock_github_api_response.json2
-rw-r--r--pydis_site/apps/home/views/home.py2
-rw-r--r--pydis_site/apps/staff/tests/test_logs_view.py2
-rw-r--r--pydis_site/constants.py5
-rw-r--r--pydis_site/context_processors.py8
-rw-r--r--pydis_site/settings.py19
-rw-r--r--pydis_site/templates/base/base.html1
-rw-r--r--pydis_site/templates/home/index.html4
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@
diff --git a/Pipfile b/Pipfile
index 0f794078..27a2a452 100644
--- a/Pipfile
+++ b/Pipfile
@@ -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": [
diff --git a/README.md b/README.md
index ec2f0af3..616f2edc 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
# Python Discord: Site
-[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
+[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E95k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn)
[![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=2&branchName=master)
[![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/2?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Site?branchName=master)
[![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/2/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
diff --git a/manage.py b/manage.py
index ee071376..d4748a3a 100755
--- a/manage.py
+++ b/manage.py
@@ -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."""
+
+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])
+
+
+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>