aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.coveragerc15
-rw-r--r--api/admin.py3
-rw-r--r--api/migrations/0007_tag.py23
-rw-r--r--api/migrations/0008_tag_embed_validator.py20
-rw-r--r--api/models.py20
-rw-r--r--api/serializers.py12
-rw-r--r--api/tests/test_validators.py132
-rw-r--r--api/urls.py14
-rw-r--r--api/validators.py155
-rw-r--r--api/viewsets.py110
10 files changed, 492 insertions, 12 deletions
diff --git a/.coveragerc b/.coveragerc
index 93f53edf..806e2cfe 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -1,4 +1,13 @@
[run]
-omit = */apps.py
- pysite/wsgi.py
- pysite/settings.py
+source =
+ admin
+ api
+ home
+ pysite
+ wiki
+
+omit =
+ */admin.py
+ */apps.py
+ pysite/wsgi.py
+ pysite/settings.py
diff --git a/api/admin.py b/api/admin.py
index 54cb33ea..526bea52 100644
--- a/api/admin.py
+++ b/api/admin.py
@@ -3,7 +3,7 @@ from django.contrib import admin
from .models import (
DocumentationLink, Member,
OffTopicChannelName, Role,
- SnakeName
+ SnakeName, Tag
)
@@ -12,3 +12,4 @@ admin.site.register(Member)
admin.site.register(OffTopicChannelName)
admin.site.register(Role)
admin.site.register(SnakeName)
+admin.site.register(Tag)
diff --git a/api/migrations/0007_tag.py b/api/migrations/0007_tag.py
new file mode 100644
index 00000000..fdb3b9cc
--- /dev/null
+++ b/api/migrations/0007_tag.py
@@ -0,0 +1,23 @@
+# Generated by Django 2.1.1 on 2018-09-21 22:05
+
+import api.models
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0006_add_help_texts'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Tag',
+ fields=[
+ ('title', models.CharField(help_text='The title of this tag, shown in searches and providing a quick overview over what this embed contains.', max_length=100, primary_key=True, serialize=False)),
+ ('embed', django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.')),
+ ],
+ bases=(api.models.ModelReprMixin, models.Model),
+ ),
+ ]
diff --git a/api/migrations/0008_tag_embed_validator.py b/api/migrations/0008_tag_embed_validator.py
new file mode 100644
index 00000000..4c580294
--- /dev/null
+++ b/api/migrations/0008_tag_embed_validator.py
@@ -0,0 +1,20 @@
+# Generated by Django 2.1.1 on 2018-09-23 10:07
+
+import api.validators
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('api', '0007_tag'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='tag',
+ name='embed',
+ field=django.contrib.postgres.fields.jsonb.JSONField(help_text='The actual embed shown by this tag.', validators=[api.validators.validate_tag_embed]),
+ ),
+ ]
diff --git a/api/models.py b/api/models.py
index 6b681ebc..e84e28c0 100644
--- a/api/models.py
+++ b/api/models.py
@@ -1,8 +1,11 @@
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 .validators import validate_tag_embed
+
class ModelReprMixin:
"""
@@ -159,3 +162,20 @@ class Member(ModelReprMixin, models.Model):
def __str__(self):
return f"{self.name}#{self.discriminator}"
+
+
+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,)
+ )
diff --git a/api/serializers.py b/api/serializers.py
index dc4d4a78..c36cce5f 100644
--- a/api/serializers.py
+++ b/api/serializers.py
@@ -1,7 +1,11 @@
from rest_framework.serializers import ModelSerializer, PrimaryKeyRelatedField
from rest_framework_bulk import BulkSerializerMixin
-from .models import DocumentationLink, Member, OffTopicChannelName, Role, SnakeName
+from .models import (
+ DocumentationLink, Member,
+ OffTopicChannelName, Role,
+ SnakeName, Tag
+)
class DocumentationLinkSerializer(ModelSerializer):
@@ -31,6 +35,12 @@ class RoleSerializer(ModelSerializer):
fields = ('id', 'name', 'colour', 'permissions')
+class TagSerializer(ModelSerializer):
+ class Meta:
+ model = Tag
+ fields = ('title', 'embed')
+
+
class MemberSerializer(BulkSerializerMixin, ModelSerializer):
roles = PrimaryKeyRelatedField(many=True, queryset=Role.objects.all())
diff --git a/api/tests/test_validators.py b/api/tests/test_validators.py
new file mode 100644
index 00000000..51f02412
--- /dev/null
+++ b/api/tests/test_validators.py
@@ -0,0 +1,132 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+
+from ..validators import validate_tag_embed
+
+
+REQUIRED_KEYS = (
+ 'content', 'fields', 'image', 'title', 'video'
+)
+
+
+class TagEmbedValidatorTests(TestCase):
+ def test_rejects_non_mapping(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed('non-empty non-mapping')
+
+ def test_rejects_missing_required_keys(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'unknown': "key"
+ })
+
+ def test_rejects_empty_required_key(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': ''
+ })
+
+ def test_rejects_list_as_embed(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed([])
+
+ def test_rejects_required_keys_and_unknown_keys(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "the duck walked up to the lemonade stand",
+ 'and': "he said to the man running the stand"
+ })
+
+ def test_rejects_too_long_title(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': 'a' * 257
+ })
+
+ def test_rejects_too_many_fields(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': [{} for _ in range(26)]
+ })
+
+ def test_rejects_too_long_description(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'description': 'd' * 2049
+ })
+
+ def test_rejects_fields_as_list_of_non_mappings(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': ['abc']
+ })
+
+ def test_rejects_fields_with_unknown_fields(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': [
+ {
+ 'what': "is this field"
+ }
+ ]
+ })
+
+ def test_rejects_fields_with_too_long_name(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'fields': [
+ {
+ 'name': "a" * 257
+ }
+ ]
+ })
+
+ def test_rejects_footer_as_non_mapping(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': []
+ })
+
+ def test_rejects_footer_with_unknown_fields(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': {
+ 'duck': "quack"
+ }
+ })
+
+ def test_rejects_footer_with_empty_text(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'footer': {
+ 'text': ""
+ }
+ })
+
+ def test_rejects_author_as_non_mapping(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': []
+ })
+
+ def test_rejects_author_with_unknown_field(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': {
+ 'field': "that is unknown"
+ }
+ })
+
+ def test_rejects_author_with_empty_name(self):
+ with self.assertRaises(ValidationError):
+ validate_tag_embed({
+ 'title': "whatever",
+ 'author': {
+ 'name': ""
+ }
+ })
diff --git a/api/urls.py b/api/urls.py
index f4ed641c..1648471c 100644
--- a/api/urls.py
+++ b/api/urls.py
@@ -1,15 +1,16 @@
from django.urls import include, path
-from rest_framework.routers import SimpleRouter
+from rest_framework.routers import DefaultRouter
from .views import HealthcheckView
from .viewsets import (
DocumentationLinkViewSet, MemberViewSet,
- OffTopicChannelNameViewSet, SnakeNameViewSet
+ OffTopicChannelNameViewSet, SnakeNameViewSet,
+ TagViewSet
)
-# http://www.django-rest-framework.org/api-guide/routers/#simplerouter
-bot_router = SimpleRouter(trailing_slash=False)
+# http://www.django-rest-framework.org/api-guide/routers/#defaultrouter
+bot_router = DefaultRouter(trailing_slash=False)
bot_router.register(
'documentation-links',
DocumentationLinkViewSet
@@ -28,7 +29,10 @@ bot_router.register(
SnakeNameViewSet,
base_name='snakename'
)
-
+bot_router.register(
+ 'tags',
+ TagViewSet,
+)
app_name = 'api'
urlpatterns = (
diff --git a/api/validators.py b/api/validators.py
new file mode 100644
index 00000000..2c4ffe4b
--- /dev/null
+++ b/api/validators.py
@@ -0,0 +1,155 @@
+from collections.abc import Mapping
+
+from django.core.exceptions import ValidationError
+from django.core.validators import MaxLengthValidator, MinLengthValidator
+
+
+def validate_tag_embed_fields(fields):
+ field_validators = {
+ 'name': (MaxLengthValidator(limit_value=256),),
+ 'value': (MaxLengthValidator(limit_value=1024),)
+ }
+
+ for field in fields:
+ if not isinstance(field, Mapping):
+ raise ValidationError("Embed fields must be a mapping.")
+
+ for field_name, value in field.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed field field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed_footer(footer):
+ field_validators = {
+ 'text': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Footer text must not be empty."
+ ),
+ MaxLengthValidator(limit_value=2048)
+ ),
+ 'icon_url': (),
+ 'proxy_icon_url': ()
+ }
+
+ if not isinstance(footer, Mapping):
+ raise ValidationError("Embed footer must be a mapping.")
+
+ for field_name, value in footer.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed footer field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed_author(author):
+ field_validators = {
+ 'name': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Embed author name must not be empty."
+ ),
+ MaxLengthValidator(limit_value=256)
+ ),
+ 'url': (),
+ 'icon_url': (),
+ 'proxy_icon_url': ()
+ }
+
+ if not isinstance(author, Mapping):
+ raise ValidationError("Embed author must be a mapping.")
+
+ for field_name, value in author.items():
+ if field_name not in field_validators:
+ raise ValidationError(f"Unknown embed author field: {field_name!r}.")
+
+ for validator in field_validators[field_name]:
+ validator(value)
+
+
+def validate_tag_embed(embed):
+ """
+ Validate a JSON document containing an embed as possible to send
+ on Discord. This attempts to rebuild the validation used by Discord
+ as well as possible by checking for various embed limits so we can
+ ensure that any embed we store here will also be accepted as a
+ valid embed by the Discord API.
+
+ Using this directly is possible, although not intended - you usually
+ stick this onto the `validators` keyword argument of model fields.
+
+ Example:
+
+ >>> from django.contrib.postgres import fields as pgfields
+ >>> from django.db import models
+ >>> from api.validators import validate_tag_embed
+ >>> class MyMessage(models.Model):
+ ... embed = pgfields.JSONField(
+ ... validators=(
+ ... validate_tag_embed,
+ ... )
+ ... )
+ ... # ...
+ ...
+
+ Args:
+ embed (Dict[str, Union[str, List[dict], dict]]):
+ A dictionary describing the contents of this embed.
+ See the official documentation for a full reference
+ of accepted keys by this dictionary:
+ https://discordapp.com/developers/docs/resources/channel#embed-object
+
+ Raises:
+ ValidationError:
+ In case the given embed is deemed invalid, a `ValidationError`
+ is raised which in turn will allow Django to display errors
+ as appropriate.
+ """
+
+ all_keys = {
+ 'title', 'type', 'description', 'url', 'timestamp',
+ 'color', 'footer', 'image', 'thumbnail', 'video',
+ 'provider', 'author', 'fields'
+ }
+ one_required_of = {'content', 'fields', 'image', 'title', 'video'}
+ field_validators = {
+ 'title': (
+ MinLengthValidator(
+ limit_value=1,
+ message="Embed title must not be empty."
+ ),
+ MaxLengthValidator(limit_value=256)
+ ),
+ 'description': (MaxLengthValidator(limit_value=2048),),
+ 'fields': (
+ MaxLengthValidator(limit_value=25),
+ validate_tag_embed_fields
+ ),
+ 'footer': (validate_tag_embed_footer,),
+ 'author': (validate_tag_embed_author,)
+ }
+
+ if not embed:
+ raise ValidationError("Tag embed must not be empty.")
+
+ elif not isinstance(embed, Mapping):
+ raise ValidationError("Tag embed must be a mapping.")
+
+ elif not any(field in embed for field in one_required_of):
+ raise ValidationError(f"Tag embed must contain one of the fields {one_required_of}.")
+
+ for required_key in one_required_of:
+ if required_key in embed and not embed[required_key]:
+ raise ValidationError(f"Key {required_key!r} must not be empty.")
+
+ for field_name, value in embed.items():
+ if field_name not in all_keys:
+ raise ValidationError(f"Unknown field name: {field_name!r}")
+
+ if field_name in field_validators:
+ for validator in field_validators[field_name]:
+ validator(value)
diff --git a/api/viewsets.py b/api/viewsets.py
index 9eec3a03..e3fa219c 100644
--- a/api/viewsets.py
+++ b/api/viewsets.py
@@ -9,10 +9,15 @@ 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 DocumentationLink, Member, OffTopicChannelName, SnakeName
+from .models import (
+ DocumentationLink, Member,
+ OffTopicChannelName, SnakeName,
+ Tag
+)
from .serializers import (
DocumentationLinkSerializer, MemberSerializer,
- OffTopicChannelNameSerializer, SnakeNameSerializer
+ OffTopicChannelNameSerializer, SnakeNameSerializer,
+ TagSerializer
)
@@ -225,6 +230,107 @@ class SnakeNameViewSet(ViewSet):
return Response({})
+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/members/<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/members/<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/members/<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 MemberViewSet(BulkCreateModelMixin, ModelViewSet):
"""
View providing CRUD operations on our Discord server's members through the bot.