diff options
| -rw-r--r-- | .coveragerc | 15 | ||||
| -rw-r--r-- | api/admin.py | 3 | ||||
| -rw-r--r-- | api/migrations/0007_tag.py | 23 | ||||
| -rw-r--r-- | api/migrations/0008_tag_embed_validator.py | 20 | ||||
| -rw-r--r-- | api/models.py | 20 | ||||
| -rw-r--r-- | api/serializers.py | 12 | ||||
| -rw-r--r-- | api/tests/test_validators.py | 132 | ||||
| -rw-r--r-- | api/urls.py | 14 | ||||
| -rw-r--r-- | api/validators.py | 155 | ||||
| -rw-r--r-- | api/viewsets.py | 110 | 
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. | 
