aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.hadolint.yaml4
-rw-r--r--CHANGELOG.md39
-rw-r--r--Pipfile1
-rw-r--r--Pipfile.lock29
-rw-r--r--azure-pipelines.yml224
-rw-r--r--docker/app/Dockerfile3
-rw-r--r--docs/setup.md28
-rw-r--r--pydis_site/apps/api/migrations/0008_tag_embed_validator.py4
-rw-r--r--pydis_site/apps/api/migrations/0019_deletedmessage.py3
-rw-r--r--pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py4
-rw-r--r--pydis_site/apps/api/migrations/0035_create_table_log_entry.py29
-rw-r--r--pydis_site/apps/api/models.py452
-rw-r--r--pydis_site/apps/api/models/__init__.py20
-rw-r--r--pydis_site/apps/api/models/bot/__init__.py16
-rw-r--r--pydis_site/apps/api/models/bot/bot_setting.py27
-rw-r--r--pydis_site/apps/api/models/bot/deleted_message.py12
-rw-r--r--pydis_site/apps/api/models/bot/documentation_link.py25
-rw-r--r--pydis_site/apps/api/models/bot/infraction.py67
-rw-r--r--pydis_site/apps/api/models/bot/message.py51
-rw-r--r--pydis_site/apps/api/models/bot/message_deletion_context.py23
-rw-r--r--pydis_site/apps/api/models/bot/nomination.py33
-rw-r--r--pydis_site/apps/api/models/bot/off_topic_channel_name.py16
-rw-r--r--pydis_site/apps/api/models/bot/reminder.py44
-rw-r--r--pydis_site/apps/api/models/bot/role.py48
-rw-r--r--pydis_site/apps/api/models/bot/snake_fact.py16
-rw-r--r--pydis_site/apps/api/models/bot/snake_idiom.py16
-rw-r--r--pydis_site/apps/api/models/bot/snake_name.py23
-rw-r--r--pydis_site/apps/api/models/bot/special_snake.py26
-rw-r--r--pydis_site/apps/api/models/bot/tag.py (renamed from pydis_site/apps/api/validators.py)25
-rw-r--r--pydis_site/apps/api/models/bot/user.py52
-rw-r--r--pydis_site/apps/api/models/log_entry.py50
-rw-r--r--pydis_site/apps/api/models/utils.py20
-rw-r--r--pydis_site/apps/api/serializers.py22
-rw-r--r--pydis_site/apps/api/tests/test_models.py16
-rw-r--r--pydis_site/apps/api/tests/test_validators.py7
-rw-r--r--pydis_site/apps/api/urls.py12
-rw-r--r--pydis_site/apps/api/viewsets.py890
-rw-r--r--pydis_site/apps/api/viewsets/__init__.py17
-rw-r--r--pydis_site/apps/api/viewsets/bot/__init__.py14
-rw-r--r--pydis_site/apps/api/viewsets/bot/bot_setting.py14
-rw-r--r--pydis_site/apps/api/viewsets/bot/deleted_message.py40
-rw-r--r--pydis_site/apps/api/viewsets/bot/documentation_link.py72
-rw-r--r--pydis_site/apps/api/viewsets/bot/infraction.py155
-rw-r--r--pydis_site/apps/api/viewsets/bot/nomination.py25
-rw-r--r--pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py98
-rw-r--r--pydis_site/apps/api/viewsets/bot/reminder.py66
-rw-r--r--pydis_site/apps/api/viewsets/bot/role.py95
-rw-r--r--pydis_site/apps/api/viewsets/bot/snake_fact.py30
-rw-r--r--pydis_site/apps/api/viewsets/bot/snake_idiom.py30
-rw-r--r--pydis_site/apps/api/viewsets/bot/snake_name.py61
-rw-r--r--pydis_site/apps/api/viewsets/bot/special_snake.py33
-rw-r--r--pydis_site/apps/api/viewsets/bot/tag.py105
-rw-r--r--pydis_site/apps/api/viewsets/bot/user.py126
-rw-r--r--pydis_site/apps/api/viewsets/log_entry.py37
-rw-r--r--pydis_site/apps/home/tests.py16
55 files changed, 1855 insertions, 1556 deletions
diff --git a/.hadolint.yaml b/.hadolint.yaml
deleted file mode 100644
index 5a0a0197..00000000
--- a/.hadolint.yaml
+++ /dev/null
@@ -1,4 +0,0 @@
-ignored:
- # Ignore suggestion for pinned versions in `apt-get´ and `apk`
- - DL3008
- - DL3018
diff --git a/CHANGELOG.md b/CHANGELOG.md
deleted file mode 100644
index c59fe469..00000000
--- a/CHANGELOG.md
+++ /dev/null
@@ -1,39 +0,0 @@
-# Changelog
-
-All notable changes to this project will be documented in this file.
-
-The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
-
-## Unreleased
-
-### Added
-
-- Markdown linting
-
-### Changed
-
-- Added unneded files to `.dockerignore`
-
-## [0.3.0] - 2018-18-09
-
-### Added
-
-- Do not recommend pushes to `master` in `CONTRIBUTING.md`
-
-- Documentation about how to set up the site and
- Postgres up locally using Docker and `pip`
-
-- Healthchecks for the `app` container
-
-- Require 100% code coverage in `CONTRIBUTING.md`
-
-- Require `CHANGELOG.md` updates in `CONTRIBUTING.md`
-
-- The `psmgr` console script as a shortcut to `python manage.py`
-
-- This file
-
-### Changed
-
-- Improved build speed by not installing unneeded dependencies.
diff --git a/Pipfile b/Pipfile
index d45db4d5..130fdb80 100644
--- a/Pipfile
+++ b/Pipfile
@@ -23,7 +23,6 @@ django-filter = "~=2.1.0"
django-hosts = "~=3.0"
djangorestframework = "~=3.9.2"
djangorestframework-bulk = "~=0.2.1"
-uwsgi = "~=2.0.18"
psycopg2-binary = "~=2.8"
django-simple-bulma = ">=1.1.7,<2.0"
django-crispy-bulma = ">=0.1.2,<2.0"
diff --git a/Pipfile.lock b/Pipfile.lock
index 071c3887..f076bb16 100644
--- a/Pipfile.lock
+++ b/Pipfile.lock
@@ -196,6 +196,34 @@
},
"psycopg2-binary": {
"hashes": [
+ "sha256:163d3ee445a0b4c0109877da9e46271aacf4e5e3d60ae7368669555c30f13e7c",
+ "sha256:1af0bfe7b0c13a0e613a27311fd4f9c5d024e8fc0f4b3d284e7df02a58a11fc0",
+ "sha256:2169c3a1bf52d5b30cc98625b5919a964c571a32e8646be20be6c7e3e82079de",
+ "sha256:218f079fa48e2ef812dc3d3ce6ec2f67ac56427ba4b038d5d6331f2cceb489c2",
+ "sha256:26a958930687e94c4c6c73c171e4d4783b82ae4e16aa3424e6bcd4529bceedf0",
+ "sha256:2c7c195aef3acdbc853942bc674844031a732890d2fee88a324298ed376b6c2b",
+ "sha256:2ecdbfed7004669472bfa27c8d51012c717c241c7154ae17e4c8f93024043525",
+ "sha256:345fc31b71a90ada1b51826537917b19a1af685a91c0f066787069c184d7d00f",
+ "sha256:378a06649503f548be5f1e9eec2e94cc1d6138250b82a08dcc6151bca8cec107",
+ "sha256:3f300bf2930e501dde09605de85cb2b84c2638e2c954be02a3c86f28176d3525",
+ "sha256:6c2f66c653ce8bbd7e789d0f7f92c3f9fea881b55226f0ae5ee550cce9e3cf0e",
+ "sha256:6fccbac2633831b877a8fbf865f7082d34895e82a015795a9f80f99a2efe2576",
+ "sha256:7a166f8ccb6888358d3e67795b057540ea7caa71ab9e089b0cb0097f01088965",
+ "sha256:8f6b84f887ec6fef6c1796779f8ec2603dc7e9ef52bc9269de719d4bcbdaebbb",
+ "sha256:92cf3ceb7bb90cf35b8bd993c640b15d4832ba0e142a3b9da5006ef217da595d",
+ "sha256:a20dfdf73f56da674926a3811929cff9fd23b9af90be9a6c36ac246a3486eef3",
+ "sha256:a84415df4689251556c961e4fe3b25d30e32f00faa8064ce0909458dbe0d67b2",
+ "sha256:ab1aa1cd50df3860f624c9713ee9e690eefd4e049d3a4d86577bab6e741e9616",
+ "sha256:abc9dcf85e75a8687f2a6d560c0c1a2593e8e34ba6f9ad6721f8212c5de179a2",
+ "sha256:c10454710a81a2f4b1ff4d1c83ac2cec63e0e55845a56324991514af5b1299d0",
+ "sha256:c38f80719e4dfae7a6311a4f091f07f4fb2fb5d602352015d5639f63f8fabb68",
+ "sha256:d75cf00605630b2cfefa5c62373c605dcda1cc0d607902847dbb8e8e9b67c1ce",
+ "sha256:dce15cb6ef604c9e38fdaa848f58f83153ade9f4aa5e4cf5812aa27163561750",
+ "sha256:e7e0db4311bb76bf3f6e0380f71912cfa6d0be7cc635e3772476050b0dabdabd",
+ "sha256:eac59cae78dfe3fbf7ece25c170d7a152f88df7643381aa5e7344c2028a8d8d4",
+ "sha256:ead7b3e1567bd14cacd44279c5e42cd19f54b9feed39180220253f4fbe3abd56",
+ "sha256:ed772a5e8e7e5dd6bede960a86940c17cf653c7f158dafa5d52e919b676f10ba",
+ "sha256:f2d73131acb94afa45de8b6b8a4bfb21bbe3736633d6478e53247f19dd8c299c"
"sha256:007ca0df127b1862fc010125bc4100b7a630efc6841047bd11afceadb4754611",
"sha256:03c49e02adf0b4d68f422fdbd98f7a7c547beb27e99a75ed02298f85cb48406a",
"sha256:0a1232cdd314e08848825edda06600455ad2a7adaa463ebfb12ece2d09f3370e",
@@ -235,6 +263,7 @@
],
"index": "pypi",
"version": "==2.3.1"
+ "version": "==2.8.1"
},
"pytz": {
"hashes": [
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 78bbffae..9ca2b812 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -1,134 +1,96 @@
# https://aka.ms/yaml
jobs:
- - job: lint_misc
- displayName: Lint others
- pool:
- vmImage: ubuntu-16.04
-
- steps:
- - script: |
- curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -
- sudo add-apt-repository deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable
- sudo apt-get update
- sudo apt-get install docker-ce
- displayName: install docker
-
- - script: docker run -v $(pwd):/app:ro --rm ruby:alpine /bin/ash -c "gem install mdl && cd /app && mdl"
- displayName: run markdownlint
-
- - script: docker run -i hadolint/hadolint hadolint --ignore DL3008 --ignore DL3018 --ignore DL3019 - < docker/app/Dockerfile
- displayName: run hadolint
-
- - job: lint_python
- displayName: Lint Python
- pool:
- vmImage: ubuntu-16.04
-
- strategy:
- matrix:
- Python 3.6:
- python.version: 3.6
- Python 3.7:
- python.version: 3.7
-
- variables:
- PIP_CACHE_DIR: .cache/pip
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: $(python.version)
- architecture: x64
-
- - script: >
- python -m pip install $(grep -E '^(coverage|flake8|mccabe|pep8-naming)' Pipfile | cut -d' ' -f1)
- displayName: install lint requirements
-
- - script: flake8
- displayName: lint using flake8
-
- - job: test
- displayName: Test
- dependsOn:
- - lint_misc
- - lint_python
- pool:
- vmImage: ubuntu-16.04
- strategy:
- matrix:
- Python 3.6 with PostgreSQL 10:
- python.version: 3.6
- postgres.version: 10
- Python 3.6 with PostgreSQL 11:
- python.version: 3.6
- postgres.version: 11
- Python 3.7 with PostgreSQL 10:
- python.version: 3.7
- postgres.version: 10
- Python 3.7 with PostgreSQL 11:
- python.version: 3.7
- postgres.version: 11
-
- steps:
- - task: UsePythonVersion@0
- inputs:
- versionSpec: $(python.version)
- architecture: x64
-
- - script: |
- curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
- sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
- sudo apt-get update -y
- sudo apt-get install -y postgresql-$(postgres.version)
- displayName: install postgresql
-
- - script: |
- echo "$USER_CREATE_COMMAND;" > pgscript.sql
- echo "CREATE DATABASE pysite OWNER pysite;" >> pgscript.sql
- sudo su postgres -c "psql < pgscript.sql"
- env:
- USER_CREATE_COMMAND: CREATE USER pysite WITH PASSWORD 'pysite' CREATEDB
- displayName: set up the database
-
- - script: python -m pip install pipenv && python -m pipenv install --dev --system
- displayName: install requirements
-
- - script: |
- python manage.py migrate
- coverage run --branch manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
- env:
- CI: azure
- DATABASE_URL: postgres://pysite:pysite@localhost/pysite
- displayName: run tests
-
- - script: coverage report
- displayName: show coverage results
-
- - task: PublishTestResults@2
- inputs:
- testResultsFiles: "**/TEST-*.xml"
- testRunTitle: 'Python $(python.version) with PostgreSQL $(postgres.version)'
-
- - job: push_image
- displayName: Push Docker image
- dependsOn: test
- condition: and(succeeded(), eq(variables['build.sourceBranch'], 'refs/heads/django'))
- pool:
- vmImage: ubuntu-16.04
-
- steps:
- - task: Docker@1
- displayName: Login to Docker Hub
-
- inputs:
- containerregistrytype: 'Container Registry'
- dockerRegistryEndpoint: 'DockerHub'
- command: 'login'
-
- - script: |
- docker build -t pythondiscord/django:latest docker/app/Dockerfile
- docker push pythondiscord/django:latest
- displayName: Build and push the image
-
-# vim: sw=2 ts=2:
+- job: python_lint
+ displayName: 'Lint Job'
+ pool:
+ vmImage: ubuntu-16.04
+
+ variables:
+ PIP_CACHE_DIR: .cache/pip
+
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Set Python Version'
+ inputs:
+ versionSpec: '3.7.x'
+ addToPath: true
+
+ - script: python3 -m pip install pipenv && pipenv install --dev --system && python3 -m pip install flake8-formatter-junit-xml
+ displayName: 'Install Project Environment'
+
+ - script: python3 -m flake8 --format junit-xml --output-file test-lint.xml
+ displayName: 'Run Linter'
+
+ - task: PublishTestResults@2
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: '**/test-*.xml'
+ testRunTitle: 'Site-Django Lint Results'
+
+- job: coverage_test
+ displayName: 'Test Job'
+ dependsOn: python_lint
+ pool:
+ vmImage: ubuntu-16.04
+
+ steps:
+ - task: UsePythonVersion@0
+ inputs:
+ versionSpec: '3.7.x'
+ architecture: x64
+
+ - script: |
+ curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
+ sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
+ sudo apt-get update -y
+ sudo apt-get install -y postgresql-11
+ displayName: 'Install PostgreSQL'
+
+ - script: |
+ echo "$USER_CREATE_COMMAND;" > pgscript.sql
+ echo "CREATE DATABASE pysite OWNER pysite;" >> pgscript.sql
+ sudo su postgres -c "psql < pgscript.sql"
+ env:
+ USER_CREATE_COMMAND: CREATE USER pysite WITH PASSWORD 'pysite' CREATEDB
+ displayName: 'Setup Database'
+
+ - script: python3 -m pip install pipenv && pipenv install --dev --system
+ displayName: 'Install Project Environment'
+
+ - script: |
+ python manage.py migrate
+ coverage run --branch manage.py test --testrunner xmlrunner.extra.djangotestrunner.XMLTestRunner --no-input
+ env:
+ CI: azure
+ DATABASE_URL: postgres://pysite:pysite@localhost/pysite
+ displayName: 'Run Test'
+
+ - script: coverage report
+ displayName: 'Show Coverage Results'
+
+ - task: PublishTestResults@2
+ inputs:
+ testResultsFiles: "**/TEST-*.xml"
+ testRunTitle: 'Site-Django Test Results'
+
+- job: docker
+ displayName: 'Build & Push Job'
+ dependsOn: coverage_test
+ condition: and(succeeded(), ne(variables['Build.Reason'], 'PullRequest'))
+ pool:
+ vmImage: ubuntu-16.04
+
+ steps:
+ - task: Docker@1
+ displayName: Login to Docker Hub
+
+ inputs:
+ containerregistrytype: 'Container Registry'
+ dockerRegistryEndpoint: 'DockerHub'
+ command: 'login'
+
+ - script: |
+ docker build -t pythondiscord/django:latest -f docker/app/Dockerfile .
+ docker push pythondiscord/django:latest
+ displayName: 'Build & Push Docker Image'
diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile
index 93d0c378..5b965740 100644
--- a/docker/app/Dockerfile
+++ b/docker/app/Dockerfile
@@ -28,7 +28,8 @@ RUN rm -r /opt/bitnami/python/lib/python3.*/site-packages/setuptools* && \
pip install --no-cache-dir -U setuptools
RUN python3 -m pip install pipenv \
- && python3 -m pipenv install --dev --system --deploy
+ && python3 -m pipenv install --system --deploy \
+ && pip install uwsgi==2.0.18
COPY . .
diff --git a/docs/setup.md b/docs/setup.md
index d6e5a7bf..18f5ee97 100644
--- a/docs/setup.md
+++ b/docs/setup.md
@@ -26,6 +26,8 @@ in your environment variables:
export DATABASE_URL=postgres://pysite@localhost/pysite
```
+After this step, inside the `.env` file, set the `SECRET_KEY` variable which can be anything you like.
+
A simpler approach to automatically configuring this might come in the
near future - if you have any suggestions, please let us know!
@@ -66,6 +68,26 @@ pip install -e .[lint,test]
to install base dependencies along with lint and test dependencies.
-You can either use `python manage.py` directly, or you can use the console
-entrypoint for it, `psmgr`. For example, to run tests, you could use either
-`python manage.py test` or `psmgr test`. Happy hacking!
+To run tests, use `python manage.py test`.
+
+## Hosts file
+
+Make sure you add the following to your hosts file:
+
+```sh
+127.0.0.1 pythondiscord.local
+127.0.0.1 api.pythondiscord.local
+127.0.0.1 staff.pythondiscord.local
+127.0.0.1 admin.pythondiscord.local
+127.0.0.1 wiki.pythondiscord.local
+127.0.0.1 ws.pythondiscord.local
+```
+When trying to access the site, you'll be using the domains above instead of the usual `localhost:8000`.
+
+Finally, you will need to set the environment variable `DEBUG=1`. When using `pipenv`, you can
+set put this into an `.env` file to have it exported automatically. It's also recommended to
+export `LOG_LEVEL=INFO` when using `DEBUG=1` if you don't want super verbose logs.
+
+To run the server, run `python manage.py runserver`. If it gives you an error saying
+`django.core.exceptions.ImproperlyConfigured: Set the DATABASE_URL environment variable` please make sure the server that your postgres database is located at is running
+and run the command `$(export cat .env)`. Happy hacking!
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 ea8f03d2..d53ddb90 100644
--- a/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
+++ b/pydis_site/apps/api/migrations/0008_tag_embed_validator.py
@@ -1,6 +1,6 @@
# Generated by Django 2.1.1 on 2018-09-23 10:07
-import pydis_site.apps.api.validators
+import pydis_site.apps.api.models.bot.tag
import django.contrib.postgres.fields.jsonb
from django.db import migrations
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
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.validators.validate_tag_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 f451ecf4..4b028f0c 100644
--- a/pydis_site/apps/api/migrations/0019_deletedmessage.py
+++ b/pydis_site/apps/api/migrations/0019_deletedmessage.py
@@ -1,7 +1,6 @@
# Generated by Django 2.1.1 on 2018-11-18 20:26
import pydis_site.apps.api.models
-import pydis_site.apps.api.validators
from django.db import migrations, models
import django.db.models.deletion
@@ -19,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.validators.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.bot.tag.validate_tag_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/0034_add_botsetting_name_validator.py b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py
index bd370d8e..d2a98e5d 100644
--- a/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py
+++ b/pydis_site/apps/api/migrations/0034_add_botsetting_name_validator.py
@@ -1,6 +1,6 @@
# Generated by Django 2.1.5 on 2019-02-18 19:41
-import pydis_site.apps.api.validators
+import pydis_site.apps.api.models.bot.bot_setting
from django.db import migrations, models
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
model_name='botsetting',
name='name',
field=models.CharField(max_length=50, primary_key=True, serialize=False, validators=[
- pydis_site.apps.api.validators.validate_bot_setting_name]),
+ pydis_site.apps.api.models.bot.bot_setting.validate_bot_setting_name]),
),
]
diff --git a/pydis_site/apps/api/migrations/0035_create_table_log_entry.py b/pydis_site/apps/api/migrations/0035_create_table_log_entry.py
new file mode 100644
index 00000000..a8256a0e
--- /dev/null
+++ b/pydis_site/apps/api/migrations/0035_create_table_log_entry.py
@@ -0,0 +1,29 @@
+# Generated by Django 2.1.5 on 2019-04-08 18:27
+
+from django.db import migrations, models
+import django.utils.timezone
+import pydis_site.apps.api.models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0034_add_botsetting_name_validator'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='LogEntry',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('application', models.CharField(choices=[('bot', 'Bot'), ('seasonalbot', 'Seasonalbot'), ('site', 'Website')], help_text='The application that generated this log entry.', max_length=20)),
+ ('logger_name', models.CharField(help_text='The name of the logger that generated this log entry.', max_length=100)),
+ ('timestamp', models.DateTimeField(default=django.utils.timezone.now, help_text='The date and time when this entry was created.')),
+ ('level', models.CharField(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.', max_length=8)),
+ ('module', models.CharField(help_text='The fully qualified path of the module generating this log line.', max_length=100)),
+ ('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.')),
+ ],
+ bases=(pydis_site.apps.api.models.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/pydis_site/apps/api/models.py b/pydis_site/apps/api/models.py
deleted file mode 100644
index 86c99f86..00000000
--- a/pydis_site/apps/api/models.py
+++ /dev/null
@@ -1,452 +0,0 @@
-from operator import itemgetter
-
-from django.contrib.postgres import fields as pgfields
-from django.core.validators import MaxValueValidator, MinValueValidator, RegexValidator
-from django.db import models
-from django.utils import timezone
-
-from .validators import validate_bot_setting_name, validate_tag_embed
-
-
-class ModelReprMixin:
- """
- Adds a `__repr__` method to the model subclassing this
- mixin which will display the model's class name along
- with all parameters used to construct the object.
- """
-
- def __repr__(self):
- attributes = ' '.join(
- f'{attribute}={value!r}'
- for attribute, value in sorted(
- self.__dict__.items(),
- key=itemgetter(0)
- )
- if not attribute.startswith('_')
- )
- return f'<{self.__class__.__name__}({attributes})>'
-
-
-class BotSetting(ModelReprMixin, models.Model):
- """A configuration entry for the bot."""
-
- name = models.CharField(
- primary_key=True,
- max_length=50,
- validators=(validate_bot_setting_name,)
- )
- data = pgfields.JSONField(
- help_text="The actual settings of this setting."
- )
-
-
-class DocumentationLink(ModelReprMixin, models.Model):
- """A documentation link used by the `!docs` command of the bot."""
-
- package = models.CharField(
- primary_key=True,
- max_length=50,
- help_text="The Python package name that this documentation link belongs to."
- )
- base_url = models.URLField(
- help_text=(
- "The base URL from which documentation will be available for this project. "
- "Used to generate links to various symbols within this package."
- )
- )
- inventory_url = models.URLField(
- help_text="The URL at which the Sphinx inventory is available for this package."
- )
-
- def __str__(self):
- return f"{self.package} - {self.base_url}"
-
-
-class OffTopicChannelName(ModelReprMixin, models.Model):
- name = models.CharField(
- primary_key=True,
- max_length=96,
- validators=(RegexValidator(regex=r'^[a-z0-9-]+$'),),
- help_text="The actual channel name that will be used on our Discord server."
- )
-
- def __str__(self):
- return self.name
-
-
-class Role(ModelReprMixin, models.Model):
- """A role on our Discord server."""
-
- id = models.BigIntegerField( # noqa
- primary_key=True,
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Role IDs cannot be negative."
- ),
- ),
- help_text="The role ID, taken from Discord."
- )
- name = models.CharField(
- max_length=100,
- help_text="The role name, taken from Discord."
- )
- colour = models.IntegerField(
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Colour hex cannot be negative."
- ),
- ),
- help_text="The integer value of the colour of this role from Discord."
- )
- permissions = models.IntegerField(
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Role permissions cannot be negative."
- ),
- MaxValueValidator(
- limit_value=2 << 32,
- message="Role permission bitset exceeds value of having all permissions"
- )
- ),
- help_text="The integer value of the permission bitset of this role from Discord."
- )
-
- def __str__(self):
- return self.name
-
-
-class SnakeFact(ModelReprMixin, models.Model):
- """A snake fact used by the bot's snake cog."""
-
- fact = models.CharField(
- primary_key=True,
- max_length=200,
- help_text="A fact about snakes."
- )
-
- def __str__(self):
- return self.fact
-
-
-class SnakeIdiom(ModelReprMixin, models.Model):
- """A snake idiom used by the snake cog."""
-
- idiom = models.CharField(
- primary_key=True,
- max_length=140,
- help_text="A saying about a snake."
- )
-
- def __str__(self):
- return self.idiom
-
-
-class SnakeName(ModelReprMixin, models.Model):
- """A snake name used by the bot's snake cog."""
-
- name = models.CharField(
- primary_key=True,
- max_length=100,
- help_text="The regular name for this snake, e.g. 'Python'.",
- validators=[RegexValidator(regex=r'^([^0-9])+$')]
- )
- scientific = models.CharField(
- max_length=150,
- help_text="The scientific name for this snake, e.g. 'Python bivittatus'.",
- validators=[RegexValidator(regex=r'^([^0-9])+$')]
- )
-
- def __str__(self):
- return f"{self.name} ({self.scientific})"
-
-
-class SpecialSnake(ModelReprMixin, models.Model):
- """A special snake's name, info and image from our database used by the bot's snake cog."""
-
- name = models.CharField(
- max_length=140,
- primary_key=True,
- help_text='A special snake name.',
- validators=[RegexValidator(regex=r'^([^0-9])+$')]
- )
- info = models.TextField(
- help_text='Info about a special snake.'
- )
- images = pgfields.ArrayField(
- models.URLField(),
- help_text='Images displaying this special snake.'
- )
-
- def __str__(self):
- return self.name
-
-
-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):
- return self.title
-
-
-class User(ModelReprMixin, models.Model):
- """A Discord user."""
-
- id = models.BigIntegerField( # noqa
- primary_key=True,
- validators=(
- MinValueValidator(
- limit_value=0,
- message="User IDs cannot be negative."
- ),
- ),
- help_text="The ID of this user, taken from Discord."
- )
- name = models.CharField(
- max_length=32,
- help_text="The username, taken from Discord."
- )
- discriminator = models.PositiveSmallIntegerField(
- validators=(
- MaxValueValidator(
- limit_value=9999,
- message="Discriminators may not exceed `9999`."
- ),
- ),
- help_text="The discriminator of this user, taken from Discord."
- )
- avatar_hash = models.CharField(
- max_length=100,
- help_text=(
- "The user's avatar hash, taken from Discord. "
- "Null if the user does not have any custom avatar."
- ),
- null=True
- )
- roles = models.ManyToManyField(
- Role,
- help_text="Any roles this user has on our server."
- )
- in_guild = models.BooleanField(
- default=True,
- help_text="Whether this user is in our server."
- )
-
- def __str__(self):
- return f"{self.name}#{self.discriminator}"
-
-
-class Message(ModelReprMixin, models.Model):
- id = models.BigIntegerField(
- primary_key=True,
- help_text="The message ID as taken from Discord.",
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Message IDs cannot be negative."
- ),
- )
- )
- author = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text="The author of this message."
- )
- channel_id = models.BigIntegerField(
- help_text=(
- "The channel ID that this message was "
- "sent in, taken from Discord."
- ),
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Channel IDs cannot be negative."
- ),
- )
- )
- content = models.CharField(
- max_length=2_000,
- help_text="The content of this message, taken from Discord.",
- blank=True
- )
- embeds = pgfields.ArrayField(
- pgfields.JSONField(
- validators=(validate_tag_embed,)
- ),
- help_text="Embeds attached to this message."
- )
-
- class Meta:
- abstract = True
-
-
-class MessageDeletionContext(ModelReprMixin, models.Model):
- actor = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text=(
- "The original actor causing this deletion. Could be the author "
- "of a manual clean command invocation, the bot when executing "
- "automatic actions, or nothing to indicate that the bulk "
- "deletion was not issued by us."
- ),
- null=True
- )
- creation = models.DateTimeField(
- # Consider whether we want to add a validator here that ensures
- # the deletion context does not take place in the future.
- help_text="When this deletion took place."
- )
-
-
-class DeletedMessage(Message):
- deletion_context = models.ForeignKey(
- MessageDeletionContext,
- help_text="The deletion context this message is part of.",
- on_delete=models.CASCADE
- )
-
-
-class Infraction(ModelReprMixin, models.Model):
- """An infraction for a Discord user."""
-
- TYPE_CHOICES = (
- ("note", "Note"),
- ("warning", "Warning"),
- ("watch", "Watch"),
- ("mute", "Mute"),
- ("kick", "Kick"),
- ("ban", "Ban"),
- ("superstar", "Superstar")
- )
- inserted_at = models.DateTimeField(
- default=timezone.now,
- help_text="The date and time of the creation of this infraction."
- )
- expires_at = models.DateTimeField(
- null=True,
- help_text=(
- "The date and time of the expiration of this infraction. "
- "Null if the infraction is permanent or it can't expire."
- )
- )
- active = models.BooleanField(
- default=True,
- help_text="Whether the infraction is still active."
- )
- user = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name='infractions_received',
- help_text="The user to which the infraction was applied."
- )
- actor = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- related_name='infractions_given',
- help_text="The user which applied the infraction."
- )
- type = models.CharField(
- max_length=9,
- choices=TYPE_CHOICES,
- help_text="The type of the infraction."
- )
- reason = models.TextField(
- null=True,
- help_text="The reason for the infraction."
- )
- hidden = models.BooleanField(
- default=False,
- help_text="Whether the infraction is a shadow infraction."
- )
-
- def __str__(self):
- s = f"#{self.id}: {self.type} on {self.user_id}"
- if self.expires_at:
- s += f" until {self.expires_at}"
- if self.hidden:
- s += " (hidden)"
- return s
-
-
-class Reminder(ModelReprMixin, models.Model):
- """A reminder created by a user."""
-
- active = models.BooleanField(
- default=True,
- help_text=(
- "Whether this reminder is still active. "
- "If not, it has been sent out to the user."
- )
- )
- author = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text="The creator of this reminder."
- )
- channel_id = models.BigIntegerField(
- help_text=(
- "The channel ID that this message was "
- "sent in, taken from Discord."
- ),
- validators=(
- MinValueValidator(
- limit_value=0,
- message="Channel IDs cannot be negative."
- ),
- )
- )
- content = models.CharField(
- max_length=1500,
- help_text="The content that the user wants to be reminded of."
- )
- expiration = models.DateTimeField(
- help_text="When this reminder should be sent."
- )
-
- def __str__(self):
- return f"{self.content} on {self.expiration} by {self.author}"
-
-
-class Nomination(ModelReprMixin, models.Model):
- """A helper nomination created by staff."""
-
- active = models.BooleanField(
- default=True,
- help_text="Whether this nomination is still relevant."
- )
- author = models.ForeignKey(
- User,
- on_delete=models.CASCADE,
- help_text="The staff member that nominated this user.",
- related_name='nomination_set'
- )
- reason = models.TextField(
- help_text="Why this user was nominated."
- )
- user = models.OneToOneField(
- User,
- on_delete=models.CASCADE,
- help_text="The nominated user.",
- primary_key=True,
- related_name='nomination'
- )
- inserted_at = models.DateTimeField(
- auto_now_add=True,
- help_text="The creation date of this nomination."
- )
diff --git a/pydis_site/apps/api/models/__init__.py b/pydis_site/apps/api/models/__init__.py
new file mode 100644
index 00000000..4645bda2
--- /dev/null
+++ b/pydis_site/apps/api/models/__init__.py
@@ -0,0 +1,20 @@
+from .bot import ( # noqa
+ BotSetting,
+ DocumentationLink,
+ DeletedMessage,
+ Infraction,
+ Message,
+ MessageDeletionContext,
+ Nomination,
+ OffTopicChannelName,
+ Reminder,
+ Role,
+ SnakeFact,
+ SnakeIdiom,
+ SnakeName,
+ SpecialSnake,
+ Tag,
+ User
+)
+from .log_entry import LogEntry # noqa
+from .utils import ModelReprMixin # noqa
diff --git a/pydis_site/apps/api/models/bot/__init__.py b/pydis_site/apps/api/models/bot/__init__.py
new file mode 100644
index 00000000..fb313193
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/__init__.py
@@ -0,0 +1,16 @@
+from .bot_setting import BotSetting # noqa
+from .deleted_message import DeletedMessage # noqa
+from .documentation_link import DocumentationLink # noqa
+from .infraction import Infraction # noqa
+from .message import Message # noqa
+from .message_deletion_context import MessageDeletionContext # noqa
+from .nomination import Nomination # noqa
+from .off_topic_channel_name import OffTopicChannelName # noqa
+from .reminder import Reminder # noqa
+from .role import Role # noqa
+from .snake_fact import SnakeFact # noqa
+from .snake_idiom import SnakeIdiom # noqa
+from .snake_name import SnakeName # noqa
+from .special_snake import SpecialSnake # noqa
+from .tag import Tag # noqa
+from .user import User # noqa
diff --git a/pydis_site/apps/api/models/bot/bot_setting.py b/pydis_site/apps/api/models/bot/bot_setting.py
new file mode 100644
index 00000000..a6eeaa1f
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/bot_setting.py
@@ -0,0 +1,27 @@
+from django.contrib.postgres import fields as pgfields
+from django.core.exceptions import ValidationError
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+def validate_bot_setting_name(name):
+ known_settings = (
+ 'defcon',
+ )
+
+ if name not in known_settings:
+ raise ValidationError(f"`{name}` is not a known setting name.")
+
+
+class BotSetting(ModelReprMixin, models.Model):
+ """A configuration entry for the bot."""
+
+ name = models.CharField(
+ primary_key=True,
+ max_length=50,
+ validators=(validate_bot_setting_name,)
+ )
+ data = pgfields.JSONField(
+ help_text="The actual settings of this setting."
+ )
diff --git a/pydis_site/apps/api/models/bot/deleted_message.py b/pydis_site/apps/api/models/bot/deleted_message.py
new file mode 100644
index 00000000..0f46cd12
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/deleted_message.py
@@ -0,0 +1,12 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.message import Message
+from pydis_site.apps.api.models.bot.message_deletion_context import MessageDeletionContext
+
+
+class DeletedMessage(Message):
+ deletion_context = models.ForeignKey(
+ MessageDeletionContext,
+ help_text="The deletion context this message is part of.",
+ on_delete=models.CASCADE
+ )
diff --git a/pydis_site/apps/api/models/bot/documentation_link.py b/pydis_site/apps/api/models/bot/documentation_link.py
new file mode 100644
index 00000000..d7df22ad
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/documentation_link.py
@@ -0,0 +1,25 @@
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class DocumentationLink(ModelReprMixin, models.Model):
+ """A documentation link used by the `!docs` command of the bot."""
+
+ package = models.CharField(
+ primary_key=True,
+ max_length=50,
+ help_text="The Python package name that this documentation link belongs to."
+ )
+ base_url = models.URLField(
+ help_text=(
+ "The base URL from which documentation will be available for this project. "
+ "Used to generate links to various symbols within this package."
+ )
+ )
+ inventory_url = models.URLField(
+ help_text="The URL at which the Sphinx inventory is available for this package."
+ )
+
+ def __str__(self):
+ return f"{self.package} - {self.base_url}"
diff --git a/pydis_site/apps/api/models/bot/infraction.py b/pydis_site/apps/api/models/bot/infraction.py
new file mode 100644
index 00000000..911ca589
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/infraction.py
@@ -0,0 +1,67 @@
+from django.db import models
+from django.utils import timezone
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class Infraction(ModelReprMixin, models.Model):
+ """An infraction for a Discord user."""
+
+ TYPE_CHOICES = (
+ ("note", "Note"),
+ ("warning", "Warning"),
+ ("watch", "Watch"),
+ ("mute", "Mute"),
+ ("kick", "Kick"),
+ ("ban", "Ban"),
+ ("superstar", "Superstar")
+ )
+ inserted_at = models.DateTimeField(
+ default=timezone.now,
+ help_text="The date and time of the creation of this infraction."
+ )
+ expires_at = models.DateTimeField(
+ null=True,
+ help_text=(
+ "The date and time of the expiration of this infraction. "
+ "Null if the infraction is permanent or it can't expire."
+ )
+ )
+ active = models.BooleanField(
+ default=True,
+ help_text="Whether the infraction is still active."
+ )
+ user = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='infractions_received',
+ help_text="The user to which the infraction was applied."
+ )
+ actor = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ related_name='infractions_given',
+ help_text="The user which applied the infraction."
+ )
+ type = models.CharField(
+ max_length=9,
+ choices=TYPE_CHOICES,
+ help_text="The type of the infraction."
+ )
+ reason = models.TextField(
+ null=True,
+ help_text="The reason for the infraction."
+ )
+ hidden = models.BooleanField(
+ default=False,
+ help_text="Whether the infraction is a shadow infraction."
+ )
+
+ def __str__(self):
+ s = f"#{self.id}: {self.type} on {self.user_id}"
+ if self.expires_at:
+ s += f" until {self.expires_at}"
+ if self.hidden:
+ s += " (hidden)"
+ return s
diff --git a/pydis_site/apps/api/models/bot/message.py b/pydis_site/apps/api/models/bot/message.py
new file mode 100644
index 00000000..2578d5c6
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/message.py
@@ -0,0 +1,51 @@
+from django.contrib.postgres import fields as pgfields
+from django.core.validators import MinValueValidator
+from django.db import models
+
+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.utils import ModelReprMixin
+
+
+class Message(ModelReprMixin, models.Model):
+ id = models.BigIntegerField(
+ primary_key=True,
+ help_text="The message ID as taken from Discord.",
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Message IDs cannot be negative."
+ ),
+ )
+ )
+ author = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The author of this message."
+ )
+ channel_id = models.BigIntegerField(
+ help_text=(
+ "The channel ID that this message was "
+ "sent in, taken from Discord."
+ ),
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ )
+ )
+ content = models.CharField(
+ max_length=2_000,
+ help_text="The content of this message, taken from Discord.",
+ blank=True
+ )
+ embeds = pgfields.ArrayField(
+ pgfields.JSONField(
+ validators=(validate_tag_embed,)
+ ),
+ help_text="Embeds attached to this message."
+ )
+
+ class Meta:
+ abstract = True
diff --git a/pydis_site/apps/api/models/bot/message_deletion_context.py b/pydis_site/apps/api/models/bot/message_deletion_context.py
new file mode 100644
index 00000000..9904ef71
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/message_deletion_context.py
@@ -0,0 +1,23 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class MessageDeletionContext(ModelReprMixin, models.Model):
+ actor = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text=(
+ "The original actor causing this deletion. Could be the author "
+ "of a manual clean command invocation, the bot when executing "
+ "automatic actions, or nothing to indicate that the bulk "
+ "deletion was not issued by us."
+ ),
+ null=True
+ )
+ creation = models.DateTimeField(
+ # Consider whether we want to add a validator here that ensures
+ # the deletion context does not take place in the future.
+ help_text="When this deletion took place."
+ )
diff --git a/pydis_site/apps/api/models/bot/nomination.py b/pydis_site/apps/api/models/bot/nomination.py
new file mode 100644
index 00000000..5ebb9759
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/nomination.py
@@ -0,0 +1,33 @@
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class Nomination(ModelReprMixin, models.Model):
+ """A helper nomination created by staff."""
+
+ active = models.BooleanField(
+ default=True,
+ help_text="Whether this nomination is still relevant."
+ )
+ author = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The staff member that nominated this user.",
+ related_name='nomination_set'
+ )
+ reason = models.TextField(
+ help_text="Why this user was nominated."
+ )
+ user = models.OneToOneField(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The nominated user.",
+ primary_key=True,
+ related_name='nomination'
+ )
+ inserted_at = models.DateTimeField(
+ auto_now_add=True,
+ help_text="The creation date of this nomination."
+ )
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
new file mode 100644
index 00000000..dff7eaf8
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/off_topic_channel_name.py
@@ -0,0 +1,16 @@
+from django.core.validators import RegexValidator
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class OffTopicChannelName(ModelReprMixin, models.Model):
+ name = models.CharField(
+ primary_key=True,
+ max_length=96,
+ validators=(RegexValidator(regex=r'^[a-z0-9-]+$'),),
+ help_text="The actual channel name that will be used on our Discord server."
+ )
+
+ def __str__(self):
+ return self.name
diff --git a/pydis_site/apps/api/models/bot/reminder.py b/pydis_site/apps/api/models/bot/reminder.py
new file mode 100644
index 00000000..abccdf82
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/reminder.py
@@ -0,0 +1,44 @@
+from django.core.validators import MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.bot.user import User
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class Reminder(ModelReprMixin, models.Model):
+ """A reminder created by a user."""
+
+ active = models.BooleanField(
+ default=True,
+ help_text=(
+ "Whether this reminder is still active. "
+ "If not, it has been sent out to the user."
+ )
+ )
+ author = models.ForeignKey(
+ User,
+ on_delete=models.CASCADE,
+ help_text="The creator of this reminder."
+ )
+ channel_id = models.BigIntegerField(
+ help_text=(
+ "The channel ID that this message was "
+ "sent in, taken from Discord."
+ ),
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Channel IDs cannot be negative."
+ ),
+ )
+ )
+ content = models.CharField(
+ max_length=1500,
+ help_text="The content that the user wants to be reminded of."
+ )
+ expiration = models.DateTimeField(
+ help_text="When this reminder should be sent."
+ )
+
+ def __str__(self):
+ return f"{self.content} on {self.expiration} by {self.author}"
diff --git a/pydis_site/apps/api/models/bot/role.py b/pydis_site/apps/api/models/bot/role.py
new file mode 100644
index 00000000..8106394f
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/role.py
@@ -0,0 +1,48 @@
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class Role(ModelReprMixin, models.Model):
+ """A role on our Discord server."""
+
+ id = models.BigIntegerField( # noqa
+ primary_key=True,
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Role IDs cannot be negative."
+ ),
+ ),
+ help_text="The role ID, taken from Discord."
+ )
+ name = models.CharField(
+ max_length=100,
+ help_text="The role name, taken from Discord."
+ )
+ colour = models.IntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Colour hex cannot be negative."
+ ),
+ ),
+ help_text="The integer value of the colour of this role from Discord."
+ )
+ permissions = models.IntegerField(
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="Role permissions cannot be negative."
+ ),
+ MaxValueValidator(
+ limit_value=2 << 32,
+ message="Role permission bitset exceeds value of having all permissions"
+ )
+ ),
+ help_text="The integer value of the permission bitset of this role from Discord."
+ )
+
+ def __str__(self):
+ return self.name
diff --git a/pydis_site/apps/api/models/bot/snake_fact.py b/pydis_site/apps/api/models/bot/snake_fact.py
new file mode 100644
index 00000000..4398620a
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/snake_fact.py
@@ -0,0 +1,16 @@
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class SnakeFact(ModelReprMixin, models.Model):
+ """A snake fact used by the bot's snake cog."""
+
+ fact = models.CharField(
+ primary_key=True,
+ max_length=200,
+ help_text="A fact about snakes."
+ )
+
+ def __str__(self):
+ return self.fact
diff --git a/pydis_site/apps/api/models/bot/snake_idiom.py b/pydis_site/apps/api/models/bot/snake_idiom.py
new file mode 100644
index 00000000..e4db00e0
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/snake_idiom.py
@@ -0,0 +1,16 @@
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class SnakeIdiom(ModelReprMixin, models.Model):
+ """A snake idiom used by the snake cog."""
+
+ idiom = models.CharField(
+ primary_key=True,
+ max_length=140,
+ help_text="A saying about a snake."
+ )
+
+ def __str__(self):
+ return self.idiom
diff --git a/pydis_site/apps/api/models/bot/snake_name.py b/pydis_site/apps/api/models/bot/snake_name.py
new file mode 100644
index 00000000..045a7faa
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/snake_name.py
@@ -0,0 +1,23 @@
+from django.core.validators import RegexValidator
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class SnakeName(ModelReprMixin, models.Model):
+ """A snake name used by the bot's snake cog."""
+
+ name = models.CharField(
+ primary_key=True,
+ max_length=100,
+ help_text="The regular name for this snake, e.g. 'Python'.",
+ validators=[RegexValidator(regex=r'^([^0-9])+$')]
+ )
+ scientific = models.CharField(
+ max_length=150,
+ help_text="The scientific name for this snake, e.g. 'Python bivittatus'.",
+ validators=[RegexValidator(regex=r'^([^0-9])+$')]
+ )
+
+ def __str__(self):
+ return f"{self.name} ({self.scientific})"
diff --git a/pydis_site/apps/api/models/bot/special_snake.py b/pydis_site/apps/api/models/bot/special_snake.py
new file mode 100644
index 00000000..1406b9e0
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/special_snake.py
@@ -0,0 +1,26 @@
+from django.contrib.postgres import fields as pgfields
+from django.core.validators import RegexValidator
+from django.db import models
+
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class SpecialSnake(ModelReprMixin, models.Model):
+ """A special snake's name, info and image from our database used by the bot's snake cog."""
+
+ name = models.CharField(
+ max_length=140,
+ primary_key=True,
+ help_text='A special snake name.',
+ validators=[RegexValidator(regex=r'^([^0-9])+$')]
+ )
+ info = models.TextField(
+ help_text='Info about a special snake.'
+ )
+ images = pgfields.ArrayField(
+ models.URLField(),
+ help_text='Images displaying this special snake.'
+ )
+
+ def __str__(self):
+ return self.name
diff --git a/pydis_site/apps/api/validators.py b/pydis_site/apps/api/models/bot/tag.py
index 69a8d1ef..62881ca2 100644
--- a/pydis_site/apps/api/validators.py
+++ b/pydis_site/apps/api/models/bot/tag.py
@@ -1,7 +1,11 @@
from collections.abc import Mapping
+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.utils import ModelReprMixin
def validate_tag_embed_fields(fields):
@@ -155,10 +159,21 @@ def validate_tag_embed(embed):
validator(value)
-def validate_bot_setting_name(name):
- KNOWN_SETTINGS = (
- 'defcon',
+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,)
)
- if name not in KNOWN_SETTINGS:
- raise ValidationError(f"`{name}` is not a known setting name.")
+ def __str__(self):
+ return self.title
diff --git a/pydis_site/apps/api/models/bot/user.py b/pydis_site/apps/api/models/bot/user.py
new file mode 100644
index 00000000..f5365ed1
--- /dev/null
+++ b/pydis_site/apps/api/models/bot/user.py
@@ -0,0 +1,52 @@
+from django.core.validators import MaxValueValidator, MinValueValidator
+from django.db import models
+
+from pydis_site.apps.api.models.bot.role import Role
+from pydis_site.apps.api.models.utils import ModelReprMixin
+
+
+class User(ModelReprMixin, models.Model):
+ """A Discord user."""
+
+ id = models.BigIntegerField( # noqa
+ primary_key=True,
+ validators=(
+ MinValueValidator(
+ limit_value=0,
+ message="User IDs cannot be negative."
+ ),
+ ),
+ help_text="The ID of this user, taken from Discord."
+ )
+ name = models.CharField(
+ max_length=32,
+ help_text="The username, taken from Discord."
+ )
+ discriminator = models.PositiveSmallIntegerField(
+ validators=(
+ MaxValueValidator(
+ limit_value=9999,
+ message="Discriminators may not exceed `9999`."
+ ),
+ ),
+ help_text="The discriminator of this user, taken from Discord."
+ )
+ avatar_hash = models.CharField(
+ max_length=100,
+ help_text=(
+ "The user's avatar hash, taken from Discord. "
+ "Null if the user does not have any custom avatar."
+ ),
+ null=True
+ )
+ roles = models.ManyToManyField(
+ Role,
+ help_text="Any roles this user has on our server."
+ )
+ in_guild = models.BooleanField(
+ default=True,
+ help_text="Whether this user is in our server."
+ )
+
+ def __str__(self):
+ return f"{self.name}#{self.discriminator}"
diff --git a/pydis_site/apps/api/models/log_entry.py b/pydis_site/apps/api/models/log_entry.py
new file mode 100644
index 00000000..acd7953a
--- /dev/null
+++ b/pydis_site/apps/api/models/log_entry.py
@@ -0,0 +1,50 @@
+from django.db import models
+from django.utils import timezone
+
+from pydis_site.apps.api.models.utils 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."
+ )
diff --git a/pydis_site/apps/api/models/utils.py b/pydis_site/apps/api/models/utils.py
new file mode 100644
index 00000000..731486e7
--- /dev/null
+++ b/pydis_site/apps/api/models/utils.py
@@ -0,0 +1,20 @@
+from operator import itemgetter
+
+
+class ModelReprMixin:
+ """
+ Adds a `__repr__` method to the model subclassing this
+ mixin which will display the model's class name along
+ with all parameters used to construct the object.
+ """
+
+ def __repr__(self):
+ attributes = ' '.join(
+ f'{attribute}={value!r}'
+ for attribute, value in sorted(
+ self.__dict__.items(),
+ key=itemgetter(0)
+ )
+ if not attribute.startswith('_')
+ )
+ return f'<{self.__class__.__name__}({attributes})>'
diff --git a/pydis_site/apps/api/serializers.py b/pydis_site/apps/api/serializers.py
index 9a92313a..8f045044 100644
--- a/pydis_site/apps/api/serializers.py
+++ b/pydis_site/apps/api/serializers.py
@@ -1,16 +1,15 @@
from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField, ValidationError
-from rest_framework.validators import UniqueValidator
from rest_framework_bulk import BulkSerializerMixin
from .models import (
BotSetting, DeletedMessage,
DocumentationLink, Infraction,
- MessageDeletionContext, Nomination,
- OffTopicChannelName, Reminder,
- Role, SnakeFact,
- SnakeIdiom, SnakeName,
- SpecialSnake, Tag,
- User
+ LogEntry, MessageDeletionContext,
+ Nomination, OffTopicChannelName,
+ Reminder, Role,
+ SnakeFact, SnakeIdiom,
+ SnakeName, SpecialSnake,
+ Tag, User
)
@@ -102,6 +101,15 @@ class ExpandedInfractionSerializer(InfractionSerializer):
return ret
+class LogEntrySerializer(ModelSerializer):
+ class Meta:
+ model = LogEntry
+ fields = (
+ 'application', 'logger_name', 'timestamp',
+ 'level', 'module', 'line', 'message'
+ )
+
+
class OffTopicChannelNameSerializer(ModelSerializer):
class Meta:
model = OffTopicChannelName
diff --git a/pydis_site/apps/api/tests/test_models.py b/pydis_site/apps/api/tests/test_models.py
index 43d1eb41..a958419d 100644
--- a/pydis_site/apps/api/tests/test_models.py
+++ b/pydis_site/apps/api/tests/test_models.py
@@ -3,14 +3,14 @@ from datetime import datetime as dt, timezone
from django.test import SimpleTestCase
from ..models import (
- BotSetting, DeletedMessage,
- DocumentationLink, Infraction,
- Message, MessageDeletionContext,
- ModelReprMixin, OffTopicChannelName,
- Reminder, Role,
- SnakeFact, SnakeIdiom,
- SnakeName, SpecialSnake,
- Tag, User
+ DeletedMessage, DocumentationLink,
+ Infraction, Message,
+ MessageDeletionContext, ModelReprMixin,
+ OffTopicChannelName, Reminder,
+ Role, SnakeFact,
+ SnakeIdiom, SnakeName,
+ SpecialSnake, Tag,
+ User
)
diff --git a/pydis_site/apps/api/tests/test_validators.py b/pydis_site/apps/api/tests/test_validators.py
index d2c0a136..ffa2f61e 100644
--- a/pydis_site/apps/api/tests/test_validators.py
+++ b/pydis_site/apps/api/tests/test_validators.py
@@ -1,10 +1,8 @@
from django.core.exceptions import ValidationError
from django.test import TestCase
-from ..validators import (
- validate_bot_setting_name,
- validate_tag_embed
-)
+from ..models.bot.bot_setting import validate_bot_setting_name
+from ..models.bot.tag import validate_tag_embed
REQUIRED_KEYS = (
@@ -20,6 +18,7 @@ class BotSettingValidatorTests(TestCase):
with self.assertRaises(ValidationError):
validate_bot_setting_name('bad name')
+
class TagEmbedValidatorTests(TestCase):
def test_rejects_non_mapping(self):
with self.assertRaises(ValidationError):
diff --git a/pydis_site/apps/api/urls.py b/pydis_site/apps/api/urls.py
index 6c89a52e..724d7e2b 100644
--- a/pydis_site/apps/api/urls.py
+++ b/pydis_site/apps/api/urls.py
@@ -5,11 +5,12 @@ from .views import HealthcheckView, RulesView
from .viewsets import (
BotSettingViewSet, DeletedMessageViewSet,
DocumentationLinkViewSet, InfractionViewSet,
- NominationViewSet, OffTopicChannelNameViewSet,
- ReminderViewSet, RoleViewSet,
- SnakeFactViewSet, SnakeIdiomViewSet,
- SnakeNameViewSet, SpecialSnakeViewSet,
- TagViewSet, UserViewSet
+ LogEntryViewSet, NominationViewSet,
+ OffTopicChannelNameViewSet, ReminderViewSet,
+ RoleViewSet, SnakeFactViewSet,
+ SnakeIdiomViewSet, SnakeNameViewSet,
+ SpecialSnakeViewSet, TagViewSet,
+ UserViewSet
)
@@ -81,6 +82,7 @@ urlpatterns = (
# from django_hosts.resolvers import reverse
# snake_name_endpoint = reverse('bot:snakename-list', host='api') # `bot/` endpoints
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/viewsets.py b/pydis_site/apps/api/viewsets.py
deleted file mode 100644
index 949ffaaa..00000000
--- a/pydis_site/apps/api/viewsets.py
+++ /dev/null
@@ -1,890 +0,0 @@
-from django.shortcuts import get_object_or_404
-from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework.decorators import action
-from rest_framework.exceptions import ParseError, ValidationError
-from rest_framework.filters import SearchFilter
-from rest_framework.mixins import (
- CreateModelMixin, DestroyModelMixin,
- ListModelMixin, RetrieveModelMixin,
- UpdateModelMixin
-)
-from rest_framework.response import Response
-from rest_framework.status import HTTP_201_CREATED
-from rest_framework.viewsets import GenericViewSet, ModelViewSet, ViewSet
-from rest_framework_bulk import BulkCreateModelMixin
-
-from .models import (
- BotSetting, DocumentationLink,
- Infraction, MessageDeletionContext,
- Nomination, OffTopicChannelName,
- Reminder, Role,
- SnakeFact, SnakeIdiom,
- SnakeName, SpecialSnake,
- Tag, User
-)
-from .serializers import (
- BotSettingSerializer, DocumentationLinkSerializer,
- ExpandedInfractionSerializer, InfractionSerializer,
- MessageDeletionContextSerializer, NominationSerializer,
- OffTopicChannelNameSerializer, ReminderSerializer,
- RoleSerializer, SnakeFactSerializer,
- SnakeIdiomSerializer, SnakeNameSerializer,
- SpecialSnakeSerializer, TagSerializer,
- UserSerializer
-)
-
-
-class BotSettingViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
- """
- View providing update operations on bot setting routes.
- """
-
- serializer_class = BotSettingSerializer
- queryset = BotSetting.objects.all()
-
-
-class DeletedMessageViewSet(CreateModelMixin, GenericViewSet):
- """
- View providing support for posting bulk deletion logs generated by the bot.
-
- ## Routes
- ### POST /bot/deleted-messages
- Post messages from bulk deletion logs.
-
- #### Body schema
- >>> {
- ... # The member ID of the original actor, if applicable.
- ... # If a member ID is given, it must be present on the site.
- ... 'actor': Optional[int]
- ... 'creation': datetime,
- ... 'messages': [
- ... {
- ... 'id': int,
- ... 'author': int,
- ... 'channel_id': int,
- ... 'content': str,
- ... 'embeds': [
- ... # Discord embed objects
- ... ]
- ... }
- ... ]
- ... }
-
- #### Status codes
- - 204: returned on success
- """
-
- queryset = MessageDeletionContext.objects.all()
- serializer_class = MessageDeletionContextSerializer
-
-
-class DocumentationLinkViewSet(
- CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet
-):
- """
- View providing management of documentation links used in the bot's `Doc` cog.
-
- ## Routes
- ### GET /bot/documentation-links
- Retrieve all currently stored entries from the database.
-
- #### Response format
- >>> [
- ... {
- ... 'package': 'flask',
- ... 'base_url': 'https://flask.pocoo.org/docs/dev',
- ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv'
- ... },
- ... # ...
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### GET /bot/documentation-links/<package:str>
- Look up the documentation object for the given `package`.
-
- #### Response format
- >>> {
- ... 'package': 'flask',
- ... 'base_url': 'https://flask.pocoo.org/docs/dev',
- ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv'
- ... }
-
- #### Status codes
- - 200: returned on success
- - 404: if no entry for the given `package` exists
-
- ### POST /bot/documentation-links
- Create a new documentation link object.
-
- #### Body schema
- >>> {
- ... 'package': str,
- ... 'base_url': URL,
- ... 'inventory_url': URL
- ... }
-
- #### Status codes
- - 201: returned on success
- - 400: if the request body has invalid fields, see the response for details
-
- ### DELETE /bot/documentation-links/<package:str>
- Delete the entry for the given `package`.
-
- #### Status codes
- - 204: returned on success
- - 404: if the given `package` could not be found
- """
-
- queryset = DocumentationLink.objects.all()
- serializer_class = DocumentationLinkSerializer
- lookup_field = 'package'
-
-
-class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
- """
- View providing CRUD operations on infractions for Discord users.
-
- ## Routes
- ### GET /bot/infractions
- Retrieve all infractions.
- May be filtered by the query parameters.
-
- #### Query parameters
- - **active** `bool`: whether the infraction is still active
- - **actor** `int`: snowflake of the user which applied the infraction
- - **hidden** `bool`: whether the infraction is a shadow infraction
- - **search** `str`: regular expression applied to the infraction's reason
- - **type** `str`: the type of the infraction
- - **user** `int`: snowflake of the user to which the infraction was applied
-
- Invalid query parameters are ignored.
-
- #### Response format
- >>> [
- ... {
- ... 'id': 5,
- ... 'inserted_at': '2018-11-22T07:24:06.132307Z',
- ... 'expires_at': '5018-11-20T15:52:00Z',
- ... 'active': False,
- ... 'user': 172395097705414656,
- ... 'actor': 125435062127820800,
- ... 'type': 'ban',
- ... 'reason': 'He terk my jerb!',
- ... 'hidden': True
- ... }
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### GET /bot/infractions/<id:int>
- Retrieve a single infraction by ID.
-
- #### Response format
- See `GET /bot/infractions`.
-
- #### Status codes
- - 200: returned on success
- - 404: if an infraction with the given `id` could not be found
-
- ### POST /bot/infractions
- Create a new infraction and return the created infraction.
- Only `actor`, `type`, and `user` are required.
- The `actor` and `user` must be users known by the site.
-
- #### Request body
- >>> {
- ... 'active': False,
- ... 'actor': 125435062127820800,
- ... 'expires_at': '5018-11-20T15:52:00+00:00',
- ... 'hidden': True,
- ... 'type': 'ban',
- ... 'reason': 'He terk my jerb!',
- ... 'user': 172395097705414656
- ... }
-
- #### Response format
- See `GET /bot/infractions`.
-
- #### Status codes
- - 201: returned on success
- - 400: if a given user is unknown or a field in the request body is invalid
-
- ### PATCH /bot/infractions/<id:int>
- Update the infraction with the given `id` and return the updated infraction.
- Only `active`, `reason`, and `expires_at` may be updated.
-
- #### Request body
- >>> {
- ... 'active': True,
- ... 'expires_at': '4143-02-15T21:04:31+00:00',
- ... 'reason': 'durka derr'
- ... }
-
- #### Response format
- See `GET /bot/infractions`.
-
- #### Status codes
- - 200: returned on success
- - 400: if a field in the request body is invalid or disallowed
- - 404: if an infraction with the given `id` could not be found
-
- ### Expanded routes
- All routes support expansion of `user` and `actor` in responses. To use an expanded route,
- append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`.
-
- #### Response format
- See `GET /bot/users/<snowflake:int>` for the expanded formats of `user` and `actor`. Responses
- are otherwise identical to their non-expanded counterparts.
- """
-
- serializer_class = InfractionSerializer
- queryset = Infraction.objects.all()
- filter_backends = (DjangoFilterBackend, SearchFilter)
- filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')
- search_fields = ('$reason',)
- frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden')
-
- def partial_update(self, request, *args, **kwargs):
- for field in request.data:
- if field in self.frozen_fields:
- raise ValidationError({field: ['This field cannot be updated.']})
-
- instance = self.get_object()
- serializer = self.get_serializer(instance, data=request.data, partial=True)
- serializer.is_valid(raise_exception=True)
- serializer.save()
-
- return Response(serializer.data)
-
- @action(url_path='expanded', detail=False)
- def list_expanded(self, *args, **kwargs):
- self.serializer_class = ExpandedInfractionSerializer
- return self.list(*args, **kwargs)
-
- @list_expanded.mapping.post
- def create_expanded(self, *args, **kwargs):
- self.serializer_class = ExpandedInfractionSerializer
- return self.create(*args, **kwargs)
-
- @action(url_path='expanded', url_name='detail-expanded', detail=True)
- def retrieve_expanded(self, *args, **kwargs):
- self.serializer_class = ExpandedInfractionSerializer
- return self.retrieve(*args, **kwargs)
-
- @retrieve_expanded.mapping.patch
- def partial_update_expanded(self, *args, **kwargs):
- self.serializer_class = ExpandedInfractionSerializer
- return self.partial_update(*args, **kwargs)
-
-
-class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
- """
- View of off-topic channel names used by the bot
- to rotate our off-topic names on a daily basis.
-
- ## Routes
- ### GET /bot/off-topic-channel-names
- 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.
-
- #### Response format
- Return a list of off-topic-channel names:
- >>> [
- ... "lemons-lemonade-stand",
- ... "bbq-with-bisk"
- ... ]
-
- #### Status codes
- - 200: returned on success
- - 400: returned when `random_items` is not a positive integer
-
- ### POST /bot/off-topic-channel-names
- Create a new off-topic-channel name in the database.
- The name must be given as a query parameter, for example:
- $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?name=lemons-lemonade-shop
-
- #### Status codes
- - 201: returned on success
- - 400: if the request body has invalid fields, see the response for details
-
- ### DELETE /bot/off-topic-channel-names/<name:str>
- Delete the off-topic-channel name with the given `name`.
-
- #### Status codes
- - 204: returned on success
- - 404: returned when the given `name` was not found
-
- ## Authentication
- Requires a API token.
- """
-
- lookup_field = 'name'
- serializer_class = OffTopicChannelNameSerializer
-
- def get_object(self):
- queryset = self.get_queryset()
- name = self.kwargs[self.lookup_field]
- return get_object_or_404(queryset, name=name)
-
- def get_queryset(self):
- return OffTopicChannelName.objects.all()
-
- def create(self, request):
- if 'name' in request.query_params:
- create_data = {'name': request.query_params['name']}
- serializer = OffTopicChannelNameSerializer(data=create_data)
- serializer.is_valid(raise_exception=True)
- serializer.save()
- return Response(create_data, status=HTTP_201_CREATED)
-
- else:
- raise ParseError(detail={
- 'name': ["This query parameter is required."]
- })
-
- def list(self, request): # noqa
- if 'random_items' in request.query_params:
- param = request.query_params['random_items']
- try:
- random_count = int(param)
- except ValueError:
- raise ParseError(detail={'random_items': ["Must be a valid integer."]})
-
- if random_count <= 0:
- raise ParseError(detail={
- 'random_items': ["Must be a positive integer."]
- })
-
- queryset = self.get_queryset().order_by('?')[:random_count]
- serialized = self.serializer_class(queryset, many=True)
- return Response(serialized.data)
-
- queryset = self.get_queryset()
- serialized = self.serializer_class(queryset, many=True)
- return Response(serialized.data)
-
-
-class ReminderViewSet(CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet):
- """
- View providing CRUD access to reminders.
-
- ## Routes
- ### GET /bot/reminders
- Returns all reminders in the database.
-
- #### Response format
- >>> [
- ... {
- ... 'active': True,
- ... 'author': 1020103901030,
- ... 'content': "Make dinner",
- ... 'expiration': '5018-11-20T15:52:00Z',
- ... 'id': 11
- ... },
- ... ...
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### POST /bot/reminders
- Create a new reminder.
-
- #### Request body
- >>> {
- ... 'author': int,
- ... 'content': str,
- ... 'expiration': str # ISO-formatted datetime
- ... }
-
- #### Status codes
- - 201: returned on success
- - 400: if the body format is invalid
- - 404: if no user with the given ID could be found
-
- ### DELETE /bot/reminders/<id:int>
- Delete the reminder with the given `id`.
-
- #### Status codes
- - 204: returned on success
- - 404: if a reminder with the given `id` does not exist
-
- ## Authentication
- Requires an API token.
- """
-
- serializer_class = ReminderSerializer
- queryset = Reminder.objects.prefetch_related('author')
- filter_backends = (DjangoFilterBackend, SearchFilter)
- filter_fields = ('active', 'author__id')
-
-
-class RoleViewSet(ModelViewSet):
- """
- View providing CRUD access to the roles on our server, used
- by the bot to keep a mirror of our server's roles on the site.
-
- ## Routes
- ### GET /bot/roles
- Returns all roles in the database.
-
- #### Response format
- >>> [
- ... {
- ... 'id': 267628507062992896,
- ... 'name': "Admins",
- ... 'colour': 1337,
- ... 'permissions': 8
- ... }
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### GET /bot/roles/<snowflake:int>
- Gets a single role by ID.
-
- #### Response format
- >>> {
- ... 'id': 267628507062992896,
- ... 'name': "Admins",
- ... 'colour': 1337,
- ... 'permissions': 8
- ... }
-
- #### Status codes
- - 200: returned on success
- - 404: if a role with the given `snowflake` could not be found
-
- ### POST /bot/roles
- Adds a single new role.
-
- #### Request body
- >>> {
- ... 'id': int,
- ... 'name': str,
- ... 'colour': int,
- ... 'permissions': int,
- ... }
-
- #### Status codes
- - 201: returned on success
- - 400: if the body format is invalid
-
- ### PUT /bot/roles/<snowflake:int>
- Update the role with the given `snowflake`.
- All fields in the request body are required.
-
- #### Request body
- >>> {
- ... 'id': int,
- ... 'name': str,
- ... 'colour': int,
- ... 'permissions': int
- ... }
-
- #### Status codes
- - 200: returned on success
- - 400: if the request body was invalid
-
- ### PATCH /bot/roles/<snowflake:int>
- Update the role with the given `snowflake`.
- All fields in the request body are required.
-
- >>> {
- ... 'id': int,
- ... 'name': str,
- ... 'colour': int,
- ... 'permissions': int
- ... }
-
- ### DELETE /bot/roles/<snowflake:int>
- Deletes the role with the given `snowflake`.
-
- #### Status codes
- - 204: returned on success
- - 404: if a role with the given `snowflake` does not exist
- """
-
- queryset = Role.objects.all()
- serializer_class = RoleSerializer
-
-
-class SnakeFactViewSet(ListModelMixin, GenericViewSet):
- """
- View providing snake facts created by the Pydis community in the first code jam.
-
- ## Routes
- ### GET /bot/snake-facts
- Returns snake facts from the database.
-
- #### Response format
- >>> [
- ... {'fact': 'Snakes are dangerous'},
- ... {'fact': 'Except for Python, we all love it'}
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ## Authentication
- Requires an API token.
- """
-
- serializer_class = SnakeFactSerializer
- queryset = SnakeFact.objects.all()
-
-
-class SnakeIdiomViewSet(ListModelMixin, GenericViewSet):
- """
- View providing snake idioms for the snake cog.
-
- ## Routes
- ### GET /bot/snake-idioms
- Returns snake idioms from the database.
-
- #### Response format
- >>> [
- ... {'idiom': 'Sneky snek'},
- ... {'idiom': 'Snooky Snake'}
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ## Authentication
- Requires an API token
- """
-
- serializer_class = SnakeIdiomSerializer
- queryset = SnakeIdiom.objects.all()
-
-
-class SnakeNameViewSet(ViewSet):
- """
- View providing snake names for the bot's snake cog from our first code jam's winners.
-
- ## Routes
- ### GET /bot/snake-names
- By default, return a single random snake name along with its name and scientific name.
- If the `get_all` query parameter is given, for example using...
- $ curl api.pythondiscord.local:8000/bot/snake-names?get_all=yes
- ... then the API will return all snake names and scientific names in the database.
-
- #### Response format
- Without `get_all` query parameter:
- >>> {
- ... 'name': "Python",
- ... 'scientific': "Langus greatus"
- ... }
-
- If the database is empty for whatever reason, this will return an empty dictionary.
-
- With `get_all` query parameter:
- >>> [
- ... {'name': "Python 3", 'scientific': "Langus greatus"},
- ... {'name': "Python 2", 'scientific': "Langus decentus"}
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ## Authentication
- Requires a API token.
- """
-
- serializer_class = SnakeNameSerializer
-
- def get_queryset(self):
- return SnakeName.objects.all()
-
- def list(self, request): # noqa
- if request.query_params.get('get_all'):
- queryset = self.get_queryset()
- serialized = self.serializer_class(queryset, many=True)
- return Response(serialized.data)
-
- single_snake = SnakeName.objects.order_by('?').first()
- if single_snake is not None:
- body = {
- 'name': single_snake.name,
- 'scientific': single_snake.scientific
- }
-
- return Response(body)
-
- return Response({})
-
-
-class SpecialSnakeViewSet(ListModelMixin, GenericViewSet):
- """
- View providing special snake names for our bot's snake cog.
-
- ## Routes
- ### GET /bot/special-snakes
- Returns a list of special snake names.
-
- #### Response Format
- >>> [
- ... {
- ... 'name': 'Snakky sneakatus',
- ... 'info': 'Scary snek',
- ... 'image': 'https://discordapp.com/assets/53ef346458017da2062aca5c7955946b.svg'
- ... }
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ## Authentication
- Requires an API token.
- """
-
- serializer_class = SpecialSnakeSerializer
- queryset = SpecialSnake.objects.all()
-
-
-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()
-
-
-class UserViewSet(BulkCreateModelMixin, ModelViewSet):
- """
- View providing CRUD operations on Discord users through the bot.
-
- ## Routes
- ### GET /bot/users
- Returns all users currently known.
-
- #### Response format
- >>> [
- ... {
- ... 'id': 409107086526644234,
- ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
- ... 'name': "Python",
- ... 'discriminator': 4329,
- ... 'roles': [
- ... 352427296948486144,
- ... 270988689419665409,
- ... 277546923144249364,
- ... 458226699344019457
- ... ],
- ... 'in_guild': True
- ... }
- ... ]
-
- #### Status codes
- - 200: returned on success
-
- ### GET /bot/users/<snowflake:int>
- Gets a single user by ID.
-
- #### Response format
- >>> {
- ... 'id': 409107086526644234,
- ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
- ... 'name': "Python",
- ... 'discriminator': 4329,
- ... 'roles': [
- ... 352427296948486144,
- ... 270988689419665409,
- ... 277546923144249364,
- ... 458226699344019457
- ... ],
- ... 'in_guild': True
- ... }
-
- #### Status codes
- - 200: returned on success
- - 404: if a user with the given `snowflake` could not be found
-
- ### POST /bot/users
- Adds a single or multiple new users.
- The roles attached to the user(s) must be roles known by the site.
-
- #### Request body
- >>> {
- ... 'id': int,
- ... 'avatar': str,
- ... 'name': str,
- ... 'discriminator': int,
- ... 'roles': List[int],
- ... 'in_guild': bool
- ... }
-
- Alternatively, request users can be POSTed as a list of above objects,
- in which case multiple users will be created at once.
-
- #### Status codes
- - 201: returned on success
- - 400: if one of the given roles does not exist, or one of the given fields is invalid
-
- ### PUT /bot/users/<snowflake:int>
- Update the user with the given `snowflake`.
- All fields in the request body are required.
-
- #### Request body
- >>> {
- ... 'id': int,
- ... 'avatar': str,
- ... '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
- - 404: if the user with the given `snowflake` could not be found
-
- ### PATCH /bot/users/<snowflake:int>
- Update the user with the given `snowflake`.
- All fields in the request body are optional.
-
- #### Request body
- >>> {
- ... 'id': int,
- ... 'avatar': str,
- ... '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
- - 404: if the user with the given `snowflake` could not be found
-
- ### DELETE /bot/users/<snowflake:int>
- Deletes the user with the given `snowflake`.
-
- #### Status codes
- - 204: returned on success
- - 404: if a user with the given `snowflake` does not exist
- """
-
- serializer_class = UserSerializer
- queryset = User.objects.prefetch_related('roles')
-
-
-class NominationViewSet(ModelViewSet):
- # TODO: doc me
- serializer_class = NominationSerializer
- queryset = Nomination.objects.prefetch_related('author', 'user')
- frozen_fields = ('author', 'inserted_at', 'user')
-
- def update(self, request, *args, **kwargs):
- for field in request.data:
- if field in self.frozen_fields:
- raise ValidationError({field: ['This field cannot be updated.']})
-
- instance = self.get_object()
- serializer = self.get_serializer(instance, data=request.data, partial=True)
- serializer.is_valid(raise_exception=True)
- serializer.save()
-
- return Response(serializer.data)
diff --git a/pydis_site/apps/api/viewsets/__init__.py b/pydis_site/apps/api/viewsets/__init__.py
new file mode 100644
index 00000000..553ca2c3
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/__init__.py
@@ -0,0 +1,17 @@
+from .bot import ( # noqa
+ BotSettingViewSet,
+ DeletedMessageViewSet,
+ DocumentationLinkViewSet,
+ InfractionViewSet,
+ NominationViewSet,
+ OffTopicChannelNameViewSet,
+ ReminderViewSet,
+ RoleViewSet,
+ SnakeFactViewSet,
+ SnakeIdiomViewSet,
+ SnakeNameViewSet,
+ SpecialSnakeViewSet,
+ TagViewSet,
+ UserViewSet
+)
+from .log_entry import LogEntryViewSet # noqa
diff --git a/pydis_site/apps/api/viewsets/bot/__init__.py b/pydis_site/apps/api/viewsets/bot/__init__.py
new file mode 100644
index 00000000..8e7d1290
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/__init__.py
@@ -0,0 +1,14 @@
+from .bot_setting import BotSettingViewSet # noqa
+from .deleted_message import DeletedMessageViewSet # noqa
+from .documentation_link import DocumentationLinkViewSet # noqa
+from .infraction import InfractionViewSet # noqa
+from .nomination import NominationViewSet # noqa
+from .off_topic_channel_name import OffTopicChannelNameViewSet # noqa
+from .reminder import ReminderViewSet # noqa
+from .role import RoleViewSet # noqa
+from .snake_fact import SnakeFactViewSet # noqa
+from .snake_idiom import SnakeIdiomViewSet # noqa
+from .snake_name import SnakeNameViewSet # noqa
+from .special_snake import SpecialSnakeViewSet # noqa
+from .tag import TagViewSet # noqa
+from .user import UserViewSet # noqa
diff --git a/pydis_site/apps/api/viewsets/bot/bot_setting.py b/pydis_site/apps/api/viewsets/bot/bot_setting.py
new file mode 100644
index 00000000..5464018a
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/bot_setting.py
@@ -0,0 +1,14 @@
+from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.bot_setting import BotSetting
+from pydis_site.apps.api.serializers import BotSettingSerializer
+
+
+class BotSettingViewSet(RetrieveModelMixin, UpdateModelMixin, GenericViewSet):
+ """
+ View providing update operations on bot setting routes.
+ """
+
+ serializer_class = BotSettingSerializer
+ queryset = BotSetting.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/deleted_message.py b/pydis_site/apps/api/viewsets/bot/deleted_message.py
new file mode 100644
index 00000000..c14171bd
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/deleted_message.py
@@ -0,0 +1,40 @@
+from rest_framework.mixins import CreateModelMixin
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.message_deletion_context import MessageDeletionContext
+from pydis_site.apps.api.serializers import MessageDeletionContextSerializer
+
+
+class DeletedMessageViewSet(CreateModelMixin, GenericViewSet):
+ """
+ View providing support for posting bulk deletion logs generated by the bot.
+
+ ## Routes
+ ### POST /bot/deleted-messages
+ Post messages from bulk deletion logs.
+
+ #### Body schema
+ >>> {
+ ... # The member ID of the original actor, if applicable.
+ ... # If a member ID is given, it must be present on the site.
+ ... 'actor': Optional[int]
+ ... 'creation': datetime,
+ ... 'messages': [
+ ... {
+ ... 'id': int,
+ ... 'author': int,
+ ... 'channel_id': int,
+ ... 'content': str,
+ ... 'embeds': [
+ ... # Discord embed objects
+ ... ]
+ ... }
+ ... ]
+ ... }
+
+ #### Status codes
+ - 204: returned on success
+ """
+
+ queryset = MessageDeletionContext.objects.all()
+ serializer_class = MessageDeletionContextSerializer
diff --git a/pydis_site/apps/api/viewsets/bot/documentation_link.py b/pydis_site/apps/api/viewsets/bot/documentation_link.py
new file mode 100644
index 00000000..6432d344
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/documentation_link.py
@@ -0,0 +1,72 @@
+from rest_framework.mixins import (
+ CreateModelMixin, DestroyModelMixin,
+ ListModelMixin, RetrieveModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.documentation_link import DocumentationLink
+from pydis_site.apps.api.serializers import DocumentationLinkSerializer
+
+
+class DocumentationLinkViewSet(
+ CreateModelMixin, DestroyModelMixin, ListModelMixin, RetrieveModelMixin, GenericViewSet
+):
+ """
+ View providing management of documentation links used in the bot's `Doc` cog.
+
+ ## Routes
+ ### GET /bot/documentation-links
+ Retrieve all currently stored entries from the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'package': 'flask',
+ ... 'base_url': 'https://flask.pocoo.org/docs/dev',
+ ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv'
+ ... },
+ ... # ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### GET /bot/documentation-links/<package:str>
+ Look up the documentation object for the given `package`.
+
+ #### Response format
+ >>> {
+ ... 'package': 'flask',
+ ... 'base_url': 'https://flask.pocoo.org/docs/dev',
+ ... 'inventory_url': 'https://flask.pocoo.org/docs/objects.inv'
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if no entry for the given `package` exists
+
+ ### POST /bot/documentation-links
+ Create a new documentation link object.
+
+ #### Body schema
+ >>> {
+ ... 'package': str,
+ ... 'base_url': URL,
+ ... 'inventory_url': URL
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if the request body has invalid fields, see the response for details
+
+ ### DELETE /bot/documentation-links/<package:str>
+ Delete the entry for the given `package`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if the given `package` could not be found
+ """
+
+ queryset = DocumentationLink.objects.all()
+ serializer_class = DocumentationLinkSerializer
+ lookup_field = 'package'
diff --git a/pydis_site/apps/api/viewsets/bot/infraction.py b/pydis_site/apps/api/viewsets/bot/infraction.py
new file mode 100644
index 00000000..8eacf5c1
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/infraction.py
@@ -0,0 +1,155 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.decorators import action
+from rest_framework.exceptions import ValidationError
+from rest_framework.filters import SearchFilter
+from rest_framework.mixins import (
+ CreateModelMixin,
+ ListModelMixin,
+ RetrieveModelMixin
+)
+from rest_framework.response import Response
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.infraction import Infraction
+from pydis_site.apps.api.serializers import (
+ ExpandedInfractionSerializer,
+ InfractionSerializer
+)
+
+
+class InfractionViewSet(CreateModelMixin, RetrieveModelMixin, ListModelMixin, GenericViewSet):
+ """
+ View providing CRUD operations on infractions for Discord users.
+
+ ## Routes
+ ### GET /bot/infractions
+ Retrieve all infractions.
+ May be filtered by the query parameters.
+
+ #### Query parameters
+ - **active** `bool`: whether the infraction is still active
+ - **actor** `int`: snowflake of the user which applied the infraction
+ - **hidden** `bool`: whether the infraction is a shadow infraction
+ - **search** `str`: regular expression applied to the infraction's reason
+ - **type** `str`: the type of the infraction
+ - **user** `int`: snowflake of the user to which the infraction was applied
+
+ Invalid query parameters are ignored.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'id': 5,
+ ... 'inserted_at': '2018-11-22T07:24:06.132307Z',
+ ... 'expires_at': '5018-11-20T15:52:00Z',
+ ... 'active': False,
+ ... 'user': 172395097705414656,
+ ... 'actor': 125435062127820800,
+ ... 'type': 'ban',
+ ... 'reason': 'He terk my jerb!',
+ ... 'hidden': True
+ ... }
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### GET /bot/infractions/<id:int>
+ Retrieve a single infraction by ID.
+
+ #### Response format
+ See `GET /bot/infractions`.
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if an infraction with the given `id` could not be found
+
+ ### POST /bot/infractions
+ Create a new infraction and return the created infraction.
+ Only `actor`, `type`, and `user` are required.
+ The `actor` and `user` must be users known by the site.
+
+ #### Request body
+ >>> {
+ ... 'active': False,
+ ... 'actor': 125435062127820800,
+ ... 'expires_at': '5018-11-20T15:52:00+00:00',
+ ... 'hidden': True,
+ ... 'type': 'ban',
+ ... 'reason': 'He terk my jerb!',
+ ... 'user': 172395097705414656
+ ... }
+
+ #### Response format
+ See `GET /bot/infractions`.
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if a given user is unknown or a field in the request body is invalid
+
+ ### PATCH /bot/infractions/<id:int>
+ Update the infraction with the given `id` and return the updated infraction.
+ Only `active`, `reason`, and `expires_at` may be updated.
+
+ #### Request body
+ >>> {
+ ... 'active': True,
+ ... 'expires_at': '4143-02-15T21:04:31+00:00',
+ ... 'reason': 'durka derr'
+ ... }
+
+ #### Response format
+ See `GET /bot/infractions`.
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if a field in the request body is invalid or disallowed
+ - 404: if an infraction with the given `id` could not be found
+
+ ### Expanded routes
+ All routes support expansion of `user` and `actor` in responses. To use an expanded route,
+ append `/expanded` to the end of the route e.g. `GET /bot/infractions/expanded`.
+
+ #### Response format
+ See `GET /bot/users/<snowflake:int>` for the expanded formats of `user` and `actor`. Responses
+ are otherwise identical to their non-expanded counterparts.
+ """
+
+ serializer_class = InfractionSerializer
+ queryset = Infraction.objects.all()
+ filter_backends = (DjangoFilterBackend, SearchFilter)
+ filter_fields = ('user__id', 'actor__id', 'active', 'hidden', 'type')
+ search_fields = ('$reason',)
+ frozen_fields = ('id', 'inserted_at', 'type', 'user', 'actor', 'hidden')
+
+ def partial_update(self, request, *args, **kwargs):
+ for field in request.data:
+ if field in self.frozen_fields:
+ raise ValidationError({field: ['This field cannot be updated.']})
+
+ instance = self.get_object()
+ serializer = self.get_serializer(instance, data=request.data, partial=True)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+
+ return Response(serializer.data)
+
+ @action(url_path='expanded', detail=False)
+ def list_expanded(self, *args, **kwargs):
+ self.serializer_class = ExpandedInfractionSerializer
+ return self.list(*args, **kwargs)
+
+ @list_expanded.mapping.post
+ def create_expanded(self, *args, **kwargs):
+ self.serializer_class = ExpandedInfractionSerializer
+ return self.create(*args, **kwargs)
+
+ @action(url_path='expanded', url_name='detail-expanded', detail=True)
+ def retrieve_expanded(self, *args, **kwargs):
+ self.serializer_class = ExpandedInfractionSerializer
+ return self.retrieve(*args, **kwargs)
+
+ @retrieve_expanded.mapping.patch
+ def partial_update_expanded(self, *args, **kwargs):
+ self.serializer_class = ExpandedInfractionSerializer
+ return self.partial_update(*args, **kwargs)
diff --git a/pydis_site/apps/api/viewsets/bot/nomination.py b/pydis_site/apps/api/viewsets/bot/nomination.py
new file mode 100644
index 00000000..725ae176
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/nomination.py
@@ -0,0 +1,25 @@
+from rest_framework.exceptions import ValidationError
+from rest_framework.response import Response
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot import Nomination
+from pydis_site.apps.api.serializers import NominationSerializer
+
+
+class NominationViewSet(ModelViewSet):
+ # TODO: doc me
+ serializer_class = NominationSerializer
+ queryset = Nomination.objects.prefetch_related('author', 'user')
+ frozen_fields = ('author', 'inserted_at', 'user')
+
+ def update(self, request, *args, **kwargs):
+ for field in request.data:
+ if field in self.frozen_fields:
+ raise ValidationError({field: ['This field cannot be updated.']})
+
+ instance = self.get_object()
+ serializer = self.get_serializer(instance, data=request.data, partial=True)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+
+ return Response(serializer.data)
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
new file mode 100644
index 00000000..df51917d
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/off_topic_channel_name.py
@@ -0,0 +1,98 @@
+from django.shortcuts import get_object_or_404
+from rest_framework.exceptions import ParseError
+from rest_framework.mixins import DestroyModelMixin
+from rest_framework.response import Response
+from rest_framework.status import HTTP_201_CREATED
+from rest_framework.viewsets import ViewSet
+
+from pydis_site.apps.api.models.bot.off_topic_channel_name import OffTopicChannelName
+from pydis_site.apps.api.serializers import OffTopicChannelNameSerializer
+
+
+class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet):
+ """
+ View of off-topic channel names used by the bot
+ to rotate our off-topic names on a daily basis.
+
+ ## Routes
+ ### GET /bot/off-topic-channel-names
+ 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.
+
+ #### Response format
+ Return a list of off-topic-channel names:
+ >>> [
+ ... "lemons-lemonade-stand",
+ ... "bbq-with-bisk"
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+ - 400: returned when `random_items` is not a positive integer
+
+ ### POST /bot/off-topic-channel-names
+ Create a new off-topic-channel name in the database.
+ The name must be given as a query parameter, for example:
+ $ curl api.pythondiscord.local:8000/bot/off-topic-channel-names?name=lemons-lemonade-shop
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if the request body has invalid fields, see the response for details
+
+ ### DELETE /bot/off-topic-channel-names/<name:str>
+ Delete the off-topic-channel name with the given `name`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: returned when the given `name` was not found
+
+ ## Authentication
+ Requires a API token.
+ """
+
+ lookup_field = 'name'
+ serializer_class = OffTopicChannelNameSerializer
+
+ def get_object(self):
+ queryset = self.get_queryset()
+ name = self.kwargs[self.lookup_field]
+ return get_object_or_404(queryset, name=name)
+
+ def get_queryset(self):
+ return OffTopicChannelName.objects.all()
+
+ def create(self, request):
+ if 'name' in request.query_params:
+ create_data = {'name': request.query_params['name']}
+ serializer = OffTopicChannelNameSerializer(data=create_data)
+ serializer.is_valid(raise_exception=True)
+ serializer.save()
+ return Response(create_data, status=HTTP_201_CREATED)
+
+ else:
+ raise ParseError(detail={
+ 'name': ["This query parameter is required."]
+ })
+
+ def list(self, request): # noqa
+ if 'random_items' in request.query_params:
+ param = request.query_params['random_items']
+ try:
+ random_count = int(param)
+ except ValueError:
+ raise ParseError(detail={'random_items': ["Must be a valid integer."]})
+
+ if random_count <= 0:
+ raise ParseError(detail={
+ 'random_items': ["Must be a positive integer."]
+ })
+
+ queryset = self.get_queryset().order_by('?')[:random_count]
+ serialized = self.serializer_class(queryset, many=True)
+ return Response(serialized.data)
+
+ queryset = self.get_queryset()
+ serialized = self.serializer_class(queryset, many=True)
+ return Response(serialized.data)
diff --git a/pydis_site/apps/api/viewsets/bot/reminder.py b/pydis_site/apps/api/viewsets/bot/reminder.py
new file mode 100644
index 00000000..d4ac8c76
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/reminder.py
@@ -0,0 +1,66 @@
+from django_filters.rest_framework import DjangoFilterBackend
+from rest_framework.filters import SearchFilter
+from rest_framework.mixins import (
+ CreateModelMixin,
+ DestroyModelMixin,
+ ListModelMixin
+)
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.reminder import Reminder
+from pydis_site.apps.api.serializers import ReminderSerializer
+
+
+class ReminderViewSet(CreateModelMixin, ListModelMixin, DestroyModelMixin, GenericViewSet):
+ """
+ View providing CRUD access to reminders.
+
+ ## Routes
+ ### GET /bot/reminders
+ Returns all reminders in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'active': True,
+ ... 'author': 1020103901030,
+ ... 'content': "Make dinner",
+ ... 'expiration': '5018-11-20T15:52:00Z',
+ ... 'id': 11
+ ... },
+ ... ...
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### POST /bot/reminders
+ Create a new reminder.
+
+ #### Request body
+ >>> {
+ ... 'author': int,
+ ... 'content': str,
+ ... 'expiration': str # ISO-formatted datetime
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if the body format is invalid
+ - 404: if no user with the given ID could be found
+
+ ### DELETE /bot/reminders/<id:int>
+ Delete the reminder with the given `id`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a reminder with the given `id` does not exist
+
+ ## Authentication
+ Requires an API token.
+ """
+
+ serializer_class = ReminderSerializer
+ queryset = Reminder.objects.prefetch_related('author')
+ filter_backends = (DjangoFilterBackend, SearchFilter)
+ filter_fields = ('active', 'author__id')
diff --git a/pydis_site/apps/api/viewsets/bot/role.py b/pydis_site/apps/api/viewsets/bot/role.py
new file mode 100644
index 00000000..0131b374
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/role.py
@@ -0,0 +1,95 @@
+from rest_framework.viewsets import ModelViewSet
+
+from pydis_site.apps.api.models.bot.role import Role
+from pydis_site.apps.api.serializers import RoleSerializer
+
+
+class RoleViewSet(ModelViewSet):
+ """
+ View providing CRUD access to the roles on our server, used
+ by the bot to keep a mirror of our server's roles on the site.
+
+ ## Routes
+ ### GET /bot/roles
+ Returns all roles in the database.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'id': 267628507062992896,
+ ... 'name': "Admins",
+ ... 'colour': 1337,
+ ... 'permissions': 8
+ ... }
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### GET /bot/roles/<snowflake:int>
+ Gets a single role by ID.
+
+ #### Response format
+ >>> {
+ ... 'id': 267628507062992896,
+ ... 'name': "Admins",
+ ... 'colour': 1337,
+ ... 'permissions': 8
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if a role with the given `snowflake` could not be found
+
+ ### POST /bot/roles
+ Adds a single new role.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'name': str,
+ ... 'colour': int,
+ ... 'permissions': int,
+ ... }
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if the body format is invalid
+
+ ### PUT /bot/roles/<snowflake:int>
+ Update the role with the given `snowflake`.
+ All fields in the request body are required.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'name': str,
+ ... 'colour': int,
+ ... 'permissions': int
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 400: if the request body was invalid
+
+ ### PATCH /bot/roles/<snowflake:int>
+ Update the role with the given `snowflake`.
+ All fields in the request body are required.
+
+ >>> {
+ ... 'id': int,
+ ... 'name': str,
+ ... 'colour': int,
+ ... 'permissions': int
+ ... }
+
+ ### DELETE /bot/roles/<snowflake:int>
+ Deletes the role with the given `snowflake`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a role with the given `snowflake` does not exist
+ """
+
+ queryset = Role.objects.all()
+ serializer_class = RoleSerializer
diff --git a/pydis_site/apps/api/viewsets/bot/snake_fact.py b/pydis_site/apps/api/viewsets/bot/snake_fact.py
new file mode 100644
index 00000000..0b2e8ede
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/snake_fact.py
@@ -0,0 +1,30 @@
+from rest_framework.mixins import ListModelMixin
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.snake_fact import SnakeFact
+from pydis_site.apps.api.serializers import SnakeFactSerializer
+
+
+class SnakeFactViewSet(ListModelMixin, GenericViewSet):
+ """
+ View providing snake facts created by the Pydis community in the first code jam.
+
+ ## Routes
+ ### GET /bot/snake-facts
+ Returns snake facts from the database.
+
+ #### Response format
+ >>> [
+ ... {'fact': 'Snakes are dangerous'},
+ ... {'fact': 'Except for Python, we all love it'}
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ## Authentication
+ Requires an API token.
+ """
+
+ serializer_class = SnakeFactSerializer
+ queryset = SnakeFact.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/snake_idiom.py b/pydis_site/apps/api/viewsets/bot/snake_idiom.py
new file mode 100644
index 00000000..9f274d2f
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/snake_idiom.py
@@ -0,0 +1,30 @@
+from rest_framework.mixins import ListModelMixin
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot.snake_idiom import SnakeIdiom
+from pydis_site.apps.api.serializers import SnakeIdiomSerializer
+
+
+class SnakeIdiomViewSet(ListModelMixin, GenericViewSet):
+ """
+ View providing snake idioms for the snake cog.
+
+ ## Routes
+ ### GET /bot/snake-idioms
+ Returns snake idioms from the database.
+
+ #### Response format
+ >>> [
+ ... {'idiom': 'Sneky snek'},
+ ... {'idiom': 'Snooky Snake'}
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ## Authentication
+ Requires an API token
+ """
+
+ serializer_class = SnakeIdiomSerializer
+ queryset = SnakeIdiom.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/snake_name.py b/pydis_site/apps/api/viewsets/bot/snake_name.py
new file mode 100644
index 00000000..991706f5
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/snake_name.py
@@ -0,0 +1,61 @@
+from rest_framework.response import Response
+from rest_framework.viewsets import ViewSet
+
+from pydis_site.apps.api.models.bot.snake_name import SnakeName
+from pydis_site.apps.api.serializers import SnakeNameSerializer
+
+
+class SnakeNameViewSet(ViewSet):
+ """
+ View providing snake names for the bot's snake cog from our first code jam's winners.
+
+ ## Routes
+ ### GET /bot/snake-names
+ By default, return a single random snake name along with its name and scientific name.
+ If the `get_all` query parameter is given, for example using...
+ $ curl api.pythondiscord.local:8000/bot/snake-names?get_all=yes
+ ... then the API will return all snake names and scientific names in the database.
+
+ #### Response format
+ Without `get_all` query parameter:
+ >>> {
+ ... 'name': "Python",
+ ... 'scientific': "Langus greatus"
+ ... }
+
+ If the database is empty for whatever reason, this will return an empty dictionary.
+
+ With `get_all` query parameter:
+ >>> [
+ ... {'name': "Python 3", 'scientific': "Langus greatus"},
+ ... {'name': "Python 2", 'scientific': "Langus decentus"}
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ## Authentication
+ Requires a API token.
+ """
+
+ serializer_class = SnakeNameSerializer
+
+ def get_queryset(self):
+ return SnakeName.objects.all()
+
+ def list(self, request): # noqa
+ if request.query_params.get('get_all'):
+ queryset = self.get_queryset()
+ serialized = self.serializer_class(queryset, many=True)
+ return Response(serialized.data)
+
+ single_snake = SnakeName.objects.order_by('?').first()
+ if single_snake is not None:
+ body = {
+ 'name': single_snake.name,
+ 'scientific': single_snake.scientific
+ }
+
+ return Response(body)
+
+ return Response({})
diff --git a/pydis_site/apps/api/viewsets/bot/special_snake.py b/pydis_site/apps/api/viewsets/bot/special_snake.py
new file mode 100644
index 00000000..446c79a1
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/special_snake.py
@@ -0,0 +1,33 @@
+from rest_framework.mixins import ListModelMixin
+from rest_framework.viewsets import GenericViewSet
+
+from pydis_site.apps.api.models.bot import SpecialSnake
+from pydis_site.apps.api.serializers import SpecialSnakeSerializer
+
+
+class SpecialSnakeViewSet(ListModelMixin, GenericViewSet):
+ """
+ View providing special snake names for our bot's snake cog.
+
+ ## Routes
+ ### GET /bot/special-snakes
+ Returns a list of special snake names.
+
+ #### Response Format
+ >>> [
+ ... {
+ ... 'name': 'Snakky sneakatus',
+ ... 'info': 'Scary snek',
+ ... 'image': 'https://discordapp.com/assets/53ef346458017da2062aca5c7955946b.svg'
+ ... }
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ## Authentication
+ Requires an API token.
+ """
+
+ serializer_class = SpecialSnakeSerializer
+ queryset = SpecialSnake.objects.all()
diff --git a/pydis_site/apps/api/viewsets/bot/tag.py b/pydis_site/apps/api/viewsets/bot/tag.py
new file mode 100644
index 00000000..7e9ba117
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/tag.py
@@ -0,0 +1,105 @@
+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
new file mode 100644
index 00000000..a407787e
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/bot/user.py
@@ -0,0 +1,126 @@
+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):
+ """
+ View providing CRUD operations on Discord users through the bot.
+
+ ## Routes
+ ### GET /bot/users
+ Returns all users currently known.
+
+ #### Response format
+ >>> [
+ ... {
+ ... 'id': 409107086526644234,
+ ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
+ ... 'name': "Python",
+ ... 'discriminator': 4329,
+ ... 'roles': [
+ ... 352427296948486144,
+ ... 270988689419665409,
+ ... 277546923144249364,
+ ... 458226699344019457
+ ... ],
+ ... 'in_guild': True
+ ... }
+ ... ]
+
+ #### Status codes
+ - 200: returned on success
+
+ ### GET /bot/users/<snowflake:int>
+ Gets a single user by ID.
+
+ #### Response format
+ >>> {
+ ... 'id': 409107086526644234,
+ ... 'avatar': "3ba3c1acce584c20b1e96fc04bbe80eb",
+ ... 'name': "Python",
+ ... 'discriminator': 4329,
+ ... 'roles': [
+ ... 352427296948486144,
+ ... 270988689419665409,
+ ... 277546923144249364,
+ ... 458226699344019457
+ ... ],
+ ... 'in_guild': True
+ ... }
+
+ #### Status codes
+ - 200: returned on success
+ - 404: if a user with the given `snowflake` could not be found
+
+ ### POST /bot/users
+ Adds a single or multiple new users.
+ The roles attached to the user(s) must be roles known by the site.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... 'name': str,
+ ... 'discriminator': int,
+ ... 'roles': List[int],
+ ... 'in_guild': bool
+ ... }
+
+ Alternatively, request users can be POSTed as a list of above objects,
+ in which case multiple users will be created at once.
+
+ #### Status codes
+ - 201: returned on success
+ - 400: if one of the given roles does not exist, or one of the given fields is invalid
+
+ ### PUT /bot/users/<snowflake:int>
+ Update the user with the given `snowflake`.
+ All fields in the request body are required.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... '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
+ - 404: if the user with the given `snowflake` could not be found
+
+ ### PATCH /bot/users/<snowflake:int>
+ Update the user with the given `snowflake`.
+ All fields in the request body are optional.
+
+ #### Request body
+ >>> {
+ ... 'id': int,
+ ... 'avatar': str,
+ ... '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
+ - 404: if the user with the given `snowflake` could not be found
+
+ ### DELETE /bot/users/<snowflake:int>
+ Deletes the user with the given `snowflake`.
+
+ #### Status codes
+ - 204: returned on success
+ - 404: if a user with the given `snowflake` does not exist
+ """
+
+ serializer_class = UserSerializer
+ queryset = User.objects.prefetch_related('roles')
diff --git a/pydis_site/apps/api/viewsets/log_entry.py b/pydis_site/apps/api/viewsets/log_entry.py
new file mode 100644
index 00000000..4aa7dffa
--- /dev/null
+++ b/pydis_site/apps/api/viewsets/log_entry.py
@@ -0,0 +1,37 @@
+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 providing support for creating log entries in the site 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/tests.py b/pydis_site/apps/home/tests.py
new file mode 100644
index 00000000..733ddaa3
--- /dev/null
+++ b/pydis_site/apps/home/tests.py
@@ -0,0 +1,16 @@
+from django.test import TestCase
+from django_hosts.resolvers import reverse
+
+from pydis_site.apps.home.templatetags import extra_filters
+
+
+class TestIndexReturns200(TestCase):
+ def test_index_returns_200(self):
+ url = reverse('home.index')
+ resp = self.client.get(url)
+ self.assertEqual(resp.status_code, 200)
+
+
+class TestExtraFilterTemplateTags(TestCase):
+ def test_starts_with(self):
+ self.assertTrue(extra_filters.starts_with('foo', 'f'))