diff options
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. @@ -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')) |