diff options
| author | 2019-04-19 18:23:06 +0100 | |
|---|---|---|
| committer | 2019-04-19 18:23:06 +0100 | |
| commit | 0cd78b638c48ac14c592398da0ed19f3e5ca2e2e (patch) | |
| tree | d74ebdf98537a6dd65dc926bcc3fed1458515c93 | |
| parent | Char input has placeholder, finish dir listing (diff) | |
| parent | Revert linter to non-verbose. (diff) | |
Merge branch 'django' into django+200/wiki
# Conflicts:
#	Pipfile.lock
#	pydis_site/apps/home/tests.py
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')) | 
