From 9b0eeff865bb39454f201eb82b460fdc27899a90 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 29 Nov 2018 12:27:07 -0800 Subject: Django - Add Infractions API (#149) * add Infraction model and serialiser The model in not finalised. * fix mix up of serialiser fields * remove explicit id field and add foreign keys * remove unused import * disallow null for user * add view set and route * fix model and create migration * fix typo choice => choices * specify names for reverse accessors for User FKs * add django-filter * add filters to view set * add string dunder method to model * add list/retrieve tests * make reason nullable * add creation tests * remove support for PUT and DELETE * add support for PATCH * assert timestamps using strings rather than datetimes This is done to keep 3.6 support; datetime.fromisoformat() is 3.7+ * assert inserted_at * add unauthenticated tests * add bad value tests for list filters and retrieve * remove prefetch cache invalidation * make __str__ more descriptive * add field validation & remove note type * add tests for field validation * fix coverage for Infraction string dunder test * fix coverage (for sure this time) * return 400 for partial updates with frozen fields * add expanded serialiser and endpoints * test expanded endpoints * remove extra retrieve call * remove unnecessary try-finally blocks * remove extra blank line * document endpoints (except expanded) * document expanded routes * fix wrong routes in docstring (/infraction -> /infractions) * make merge migration --- api/urls.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) (limited to 'api/urls.py') diff --git a/api/urls.py b/api/urls.py index 59853934..af275381 100644 --- a/api/urls.py +++ b/api/urls.py @@ -3,7 +3,7 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView from .viewsets import ( - DocumentationLinkViewSet, + DocumentationLinkViewSet, InfractionViewSet, OffTopicChannelNameViewSet, SnakeFactViewSet, SnakeIdiomViewSet, SnakeNameViewSet, SpecialSnakeViewSet, @@ -17,15 +17,15 @@ bot_router.register( 'documentation-links', DocumentationLinkViewSet ) +bot_router.register( + 'infractions', + InfractionViewSet +) bot_router.register( 'off-topic-channel-names', OffTopicChannelNameViewSet, base_name='offtopicchannelname' ) -bot_router.register( - 'users', - UserViewSet -) bot_router.register( 'snake-facts', SnakeFactViewSet @@ -47,6 +47,10 @@ bot_router.register( 'tags', TagViewSet ) +bot_router.register( + 'users', + UserViewSet +) app_name = 'api' urlpatterns = ( -- cgit v1.2.3 From 8caf2e65fb084abbcede29cc977bdd6c5a6d0d9f Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Tue, 1 Jan 2019 20:52:45 +0100 Subject: apply stash --- api/urls.py | 14 +++++--- api/viewsets.py | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 108 insertions(+), 13 deletions(-) (limited to 'api/urls.py') diff --git a/api/urls.py b/api/urls.py index 8229b08c..203b6b00 100644 --- a/api/urls.py +++ b/api/urls.py @@ -3,11 +3,11 @@ from rest_framework.routers import DefaultRouter from .views import HealthcheckView from .viewsets import ( - DocumentationLinkViewSet, MemberViewSet, - OffTopicChannelNameViewSet, - SnakeFactViewSet, SnakeIdiomViewSet, - SnakeNameViewSet, SpecialSnakeViewSet, - TagViewSet + DeletedMessageViewSet, DocumentationLinkViewSet, + MemberViewSet, OffTopicChannelNameViewSet, + RoleViewSet, SnakeFactViewSet, + SnakeIdiomViewSet, SnakeNameViewSet, + SpecialSnakeViewSet, TagViewSet ) @@ -26,6 +26,10 @@ bot_router.register( 'members', MemberViewSet ) +bot_router.register( + 'roles', + RoleViewSet +) bot_router.register( 'snake-facts', SnakeFactViewSet diff --git a/api/viewsets.py b/api/viewsets.py index 08660810..67e89ea6 100644 --- a/api/viewsets.py +++ b/api/viewsets.py @@ -12,16 +12,16 @@ from rest_framework_bulk import BulkCreateModelMixin from .models import ( DocumentationLink, Member, OffTopicChannelName, - SnakeFact, SnakeIdiom, - SnakeName, SpecialSnake, - Tag + Role, SnakeFact, + SnakeIdiom, SnakeName, + SpecialSnake, Tag ) from .serializers import ( - DocumentationLinkSerializer, - MemberSerializer, OffTopicChannelNameSerializer, - SnakeFactSerializer, SnakeIdiomSerializer, - SnakeNameSerializer, SpecialSnakeSerializer, - TagSerializer + DocumentationLinkSerializer, MemberSerializer, + OffTopicChannelNameSerializer, + RoleSerializer, SnakeFactSerializer, + SnakeIdiomSerializer, SnakeNameSerializer, + SpecialSnakeSerializer, TagSerializer ) @@ -178,6 +178,97 @@ class OffTopicChannelNameViewSet(DestroyModelMixin, ViewSet): return Response(serialized.data) +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/ + 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/ + 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/ + 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/ + 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 = RoleSerializer + + class SnakeFactViewSet(ListModelMixin, GenericViewSet): """ View providing snake facts created by the Pydis community in the first code jam. -- cgit v1.2.3 From 1d67beb7938635044aff75c819a7c78dde81ac63 Mon Sep 17 00:00:00 2001 From: Johannes Christ Date: Wed, 9 Jan 2019 20:52:06 +0100 Subject: Add a view returning the server rules. Closes #171. --- api/tests/test_rules.py | 35 +++++++++++++ api/urls.py | 5 +- api/views.py | 135 +++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 api/tests/test_rules.py (limited to 'api/urls.py') diff --git a/api/tests/test_rules.py b/api/tests/test_rules.py new file mode 100644 index 00000000..6552333c --- /dev/null +++ b/api/tests/test_rules.py @@ -0,0 +1,35 @@ +from django_hosts.resolvers import reverse + +from .base import APISubdomainTestCase +from ..views import RulesView + + +class HealthcheckAPITests(APISubdomainTestCase): + def setUp(self): + super().setUp() + self.client.force_authenticate(user=None) + + def test_can_access_rules_view(self): + url = reverse('rules', host='api') + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertIsInstance(response.json(), list) + + def test_link_format_query_param_produces_different_results(self): + url = reverse('rules', host='api') + markdown_links_response = self.client.get(url + '?link_format=md') + html_links_response = self.client.get(url + '?link_format=html') + self.assertNotEqual( + markdown_links_response.json(), + html_links_response.json() + ) + + def test_format_link_raises_value_error_for_invalid_target(self): + with self.assertRaises(ValueError): + RulesView._format_link("a", "b", "c") + + def test_get_returns_400_for_wrong_link_format(self): + url = reverse('rules', host='api') + response = self.client.get(url + '?link_format=unknown') + self.assertEqual(response.status_code, 400) diff --git a/api/urls.py b/api/urls.py index 7d6a4f7d..66d3fb9e 100644 --- a/api/urls.py +++ b/api/urls.py @@ -1,7 +1,7 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from .views import HealthcheckView +from .views import HealthcheckView, RulesView from .viewsets import ( DocumentationLinkViewSet, InfractionViewSet, OffTopicChannelNameViewSet, RoleViewSet, @@ -63,5 +63,6 @@ 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('healthcheck', HealthcheckView.as_view(), name='healthcheck') + path('healthcheck', HealthcheckView.as_view(), name='healthcheck'), + path('rules', RulesView.as_view(), name='rules') ) diff --git a/api/views.py b/api/views.py index c5582ec0..6a269618 100644 --- a/api/views.py +++ b/api/views.py @@ -1,3 +1,4 @@ +from rest_framework.exceptions import ParseError from rest_framework.response import Response from rest_framework.views import APIView @@ -17,7 +18,7 @@ class HealthcheckView(APIView): Seems to be. ## Authentication - Does not require any authentication nor permissions.. + Does not require any authentication nor permissions. """ authentication_classes = () @@ -25,3 +26,135 @@ class HealthcheckView(APIView): def get(self, request, format=None): # noqa return Response({'status': 'ok'}) + + +class RulesView(APIView): + """ + Return a list of the server's rules. + + ## Routes + ### GET /rules + Returns a JSON array containing the server's rules: + + >>> [ + ... "Eat candy.", + ... "Wake up at 4 AM.", + ... "Take your medicine." + ... ] + + Since some of the the rules require links, this view + gives you the option to return rules in either Markdown + or HTML format by specifying the `format`. + + ## Authentication + Does not require any authentication nor permissions. + """ + + authentication_classes = () + permission_classes = () + + @staticmethod + def _format_link(description, link, target): + """ + Build the markup necessary to render `link` with `description` + as its description in the given `target` language. + + Arguments: + description (str): + A textual description of the string. Represents the content + between the `` tags in HTML, or the content between the + array brackets in Markdown. + + link (str): + The resulting link that a user should be redirected to + upon clicking the generated element. + + target (str): + One of `{'md', 'html'}`, denoting the target format that the + link should be rendered in. + + Returns: + str: + The link, rendered appropriately for the given `target` format + using `description` as its textual description. + + Raises: + ValueError: + If `target` is not `'md'` or `'html'`. + """ + + if target == 'html': + return f'{description}' + elif target == 'md': + return f'[{description}]({link})' + else: + raise ValueError( + f"Can only template links to `html` or `md`, got `{target}`" + ) + + + # `format` here is the result format, we have a link format here instead. + def get(self, request, format=None): # noqa + link_format = request.query_params.get('link_format', 'md') + if link_format not in ('html', 'md'): + raise ParseError( + f"`format` must be `html` or `md`, got `{format}`." + ) + + discord_community_guidelines_link = self._format_link( + 'Discord Community Guidelines', + 'https://discordapp.com/guidelines', + link_format + ) + channels_page_link = self._format_link( + 'channels page', + 'https://pythondiscord.com/about/channels', + link_format + ) + google_translate_link = self._format_link( + 'Google Translate', + 'https://translate.google.com/', + link_format + ) + + return Response([ + "Be polite, and do not spam.", + f"Follow the {discord_community_guidelines_link}.", + ( + "Don't intentionally make other people uncomfortable - if " + "someone asks you to stop discussing something, you should stop." + ), + ( + "Be patient both with users asking " + "questions, and the users answering them." + ), + ( + "We will not help you with anything that might break a law or the " + "terms of service of any other community, site, service, or " + "otherwise - No piracy, brute-forcing, captcha circumvention, " + "sneaker bots, or anything else of that nature." + ), + ( + "Listen to and respect the staff members - we're " + "here to help, but we're all human beings." + ), + ( + "All discussion should be kept within the relevant " + "channels for the subject - See the " + f"{channels_page_link} for more information." + ), + ( + "This is an English-speaking server, so please speak English " + f"to the best of your ability - {google_translate_link} " + "should be fine if you're not sure." + ), + ( + "Keep all discussions safe for work - No gore, nudity, sexual " + "soliciting, references to suicide, or anything else of that nature" + ), + ( + "We do not allow advertisements for communities (including " + "other Discord servers) or commercial projects - Contact " + "us directly if you want to discuss a partnership!" + ) + ]) -- cgit v1.2.3