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 pydis_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/ 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/ 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/ 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 pydis_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/ 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/` 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/ 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/ 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 pydis_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_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/ 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/ 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/ 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/ 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/ 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 pydis_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/ 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/ 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/ 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)