diff --git a/backend/api/models/team_member.py b/backend/api/models/team_member.py index 2543cca2..58897afb 100644 --- a/backend/api/models/team_member.py +++ b/backend/api/models/team_member.py @@ -5,6 +5,42 @@ from django.db import models +class TeamMemberManager(models.Manager): + + def get_queryset(self): + return super().get_queryset().select_related( + 'character', + 'character__user', + 'bis_list', + 'bis_list__bis_body', + 'bis_list__bis_bracelet', + 'bis_list__bis_earrings', + 'bis_list__bis_feet', + 'bis_list__bis_hands', + 'bis_list__bis_head', + 'bis_list__bis_left_ring', + 'bis_list__bis_legs', + 'bis_list__bis_mainhand', + 'bis_list__bis_necklace', + 'bis_list__bis_offhand', + 'bis_list__bis_right_ring', + 'bis_list__current_body', + 'bis_list__current_bracelet', + 'bis_list__current_earrings', + 'bis_list__current_feet', + 'bis_list__current_hands', + 'bis_list__current_head', + 'bis_list__current_left_ring', + 'bis_list__current_legs', + 'bis_list__current_mainhand', + 'bis_list__current_necklace', + 'bis_list__current_offhand', + 'bis_list__current_right_ring', + 'bis_list__job', + 'bis_list__owner', + ) + + class TeamMember(models.Model): # Map of permission name to the number to compare against bitwise PERMISSION_FLAGS = { diff --git a/backend/api/serializers/character.py b/backend/api/serializers/character.py index d89ba413..a9203586 100644 --- a/backend/api/serializers/character.py +++ b/backend/api/serializers/character.py @@ -5,6 +5,7 @@ from re import compile from typing import Dict, List, Union # lib +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers # local from api.models import Character @@ -35,6 +36,12 @@ def get_proxy(self, char: Character) -> bool: """ return char.user is None + @extend_schema_field( + inline_serializer( + 'CharacterCollectionBISListSummary', + {'id': serializers.IntegerField(), 'name': serializers.CharField()}, + ), + ) def get_bis_lists(self, char: Character) -> List[Dict[str, Union[str, int]]]: """ Return summaries of bis lists diff --git a/backend/api/serializers/team_member.py b/backend/api/serializers/team_member.py index 1752307a..4afe8b0d 100644 --- a/backend/api/serializers/team_member.py +++ b/backend/api/serializers/team_member.py @@ -3,6 +3,7 @@ """ from typing import Dict # lib +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers # local from api.models import BISList, Character, TeamMember @@ -26,6 +27,12 @@ class Meta: model = TeamMember exclude = ['team'] + @extend_schema_field( + inline_serializer( + 'TeamMemberPermissions', + {notif: serializers.BooleanField() for notif in TeamMember.PERMISSION_FLAGS}, + ), + ) def get_permissions(self, obj: TeamMember) -> Dict[str, bool]: """ Generate a dictionary of permission classes to a flag stating whether or not this character has that permission diff --git a/backend/api/serializers/user.py b/backend/api/serializers/user.py index 0399cca5..c0f77e12 100644 --- a/backend/api/serializers/user.py +++ b/backend/api/serializers/user.py @@ -4,6 +4,7 @@ # stdlib from typing import Dict # lib +from drf_spectacular.utils import extend_schema_field, inline_serializer from rest_framework import serializers # local from api.models import Settings @@ -39,6 +40,12 @@ def get_loot_manager_version(self, obj) -> str: except (AttributeError, Settings.DoesNotExist): return Settings.LOOT_MANAGER_DEFAULT + @extend_schema_field( + inline_serializer( + 'UserNotifications', + {notif: serializers.BooleanField() for notif in Settings.NOTIFICATIONS}, + ), + ) def get_notifications(self, obj) -> Dict[str, bool]: """ Populate a full dictionary of notifications, filling defaults in as needed diff --git a/backend/api/tests/test_lodestone_gear_import.py b/backend/api/tests/test_lodestone_gear_import.py index a9eaa4d2..600d0e5c 100644 --- a/backend/api/tests/test_lodestone_gear_import.py +++ b/backend/api/tests/test_lodestone_gear_import.py @@ -28,7 +28,7 @@ def test_import(self): Test Plan; - Import Eira and ensure her gear matches what I currently had equipped """ - url = reverse('api:lodestone_gear_import', kwargs={'character_id': '22909725', 'expected_job': 'MNK'}) + url = reverse('api:lodestone_gear_import', kwargs={'character_id': '22909725', 'expected_job': 'GNB'}) user = self._get_user() self.client.force_authenticate(user) response = self.client.get(url) @@ -36,21 +36,21 @@ def test_import(self): # Build an expected data packet expected = { - 'job_id': 'MNK', - 'mainhand': Gear.objects.get(name='Voidcast').pk, - 'offhand': Gear.objects.get(name='Voidcast').pk, - 'head': Gear.objects.get(name='Diadochos', has_armour=True).pk, - 'body': Gear.objects.get(name='Diadochos', has_armour=True).pk, - 'hands': Gear.objects.get(name='Diadochos', has_armour=True).pk, - 'legs': Gear.objects.get(name='Diadochos', has_armour=True).pk, - 'feet': Gear.objects.get(name='Diadochos', has_armour=True).pk, - 'earrings': Gear.objects.get(name='Ascension', has_accessories=True).pk, - 'necklace': Gear.objects.get(name='Augmented Credendum', has_accessories=True).pk, - 'bracelet': Gear.objects.get(name='Ascension', has_accessories=True).pk, - 'right_ring': Gear.objects.get(name='Ascension', has_accessories=True).pk, - 'left_ring': Gear.objects.get(name='Augmented Credendum', has_accessories=True).pk, - 'min_il': 640, - 'max_il': 660, + 'job_id': 'GNB', + 'mainhand': Gear.objects.get(name='Neo Kingdom').pk, + 'offhand': Gear.objects.get(name='Neo Kingdom').pk, + 'head': Gear.objects.get(name='Neo Kingdom', has_armour=True).pk, + 'body': Gear.objects.get(name='Neo Kingdom', has_armour=True).pk, + 'hands': Gear.objects.get(name='Neo Kingdom', has_armour=True).pk, + 'legs': Gear.objects.get(name='Neo Kingdom', has_armour=True).pk, + 'feet': Gear.objects.get(name='Neo Kingdom', has_armour=True).pk, + 'earrings': Gear.objects.get(name='Neo Kingdom', has_accessories=True).pk, + 'necklace': Gear.objects.get(name='Neo Kingdom', has_accessories=True).pk, + 'bracelet': Gear.objects.get(name='Neo Kingdom', has_accessories=True).pk, + 'right_ring': Gear.objects.get(name='Neo Kingdom', has_accessories=True).pk, + 'left_ring': Gear.objects.get(name='Epochal', has_accessories=True).pk, + 'min_il': 690, + 'max_il': 700, } self.maxDiff = None self.assertDictEqual(response.json(), expected) @@ -79,5 +79,5 @@ def test_import_400_and_404(self): self.assertEqual(response.status_code, status.HTTP_406_NOT_ACCEPTABLE) self.assertEqual( response.json()['message'], - 'Couldn\'t import Gear from Lodestone. Gear was expected to be for "SAM", but "PGL MNK" was found.', + 'Couldn\'t import Gear from Lodestone. Gear was expected to be for "SAM", but "GNB" was found.', ) diff --git a/backend/api/tests/test_loot.py b/backend/api/tests/test_loot.py index fe2b3965..81b4efb1 100644 --- a/backend/api/tests/test_loot.py +++ b/backend/api/tests/test_loot.py @@ -1486,7 +1486,7 @@ def test_delete(self): """ user = self._get_user() self.client.force_authenticate(user) - url = reverse('api:loot_collection', kwargs={'team_id': self.team.pk}) + url = reverse('api:loot_delete', kwargs={'team_id': self.team.pk}) # Create some Loot entries for this Team, along with one for a new Team (just to make sure filtering works) other_team = Team.objects.create( @@ -1529,7 +1529,7 @@ def test_delete(self): ) body = {'items': [l1.pk, l3.pk, l4.pk]} - response = self.client.delete(url, body) + response = self.client.post(url, body) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, response.content) with self.assertRaises(Loot.DoesNotExist): Loot.objects.get(pk=l1.pk) @@ -1553,7 +1553,7 @@ def test_404(self): url = reverse('api:loot_collection', kwargs={'team_id': 'abcde'}) self.assertEqual(self.client.get(url).status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(url).status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(self.client.delete(url).status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(self.client.post(f'{url}delete/').status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) # Not having a character in the team @@ -1562,11 +1562,11 @@ def test_404(self): url = reverse('api:loot_collection', kwargs={'team_id': self.team.pk}) self.assertEqual(self.client.get(url).status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(url).status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(self.client.delete(url).status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(self.client.post(f'{url}delete/').status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) # POST while not team lead self.client.force_authenticate(self.main_tank.user) self.assertEqual(self.client.post(url).status_code, status.HTTP_404_NOT_FOUND) - self.assertEqual(self.client.delete(url).status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(self.client.post(f'{url}delete/').status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(self.client.post(f'{url}bis/').status_code, status.HTTP_404_NOT_FOUND) diff --git a/backend/api/tests/test_user.py b/backend/api/tests/test_user.py index 00620426..912a2fb0 100644 --- a/backend/api/tests/test_user.py +++ b/backend/api/tests/test_user.py @@ -54,7 +54,7 @@ def test_update(self): data = {'theme': 'blue', 'notifications': {'verify_fail': False}, 'loot_manager_version': 'fight', 'username': 'abcde'} response = self.client.put(url, data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED, response) + self.assertEqual(response.status_code, status.HTTP_200_OK, response) user.refresh_from_db() self.assertEqual(user.settings.loot_manager_version, 'fight') self.assertEqual(user.settings.theme, 'blue') @@ -64,7 +64,7 @@ def test_update(self): # Run it again to hit the other block data = {'theme': 'purple', 'notifications': {'verify_success': True}, 'username': 'abcde'} response = self.client.put(url, data) - self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(response.status_code, status.HTTP_200_OK) user.refresh_from_db() self.assertEqual(user.settings.theme, 'purple') self.assertFalse(user.settings.notifications['verify_fail']) diff --git a/backend/api/urls.py b/backend/api/urls.py index e859ac96..09549800 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -35,6 +35,7 @@ # Loot path('team//loot/', views.LootCollection.as_view(), name='loot_collection'), + path('team//loot/delete/', views.LootDelete.as_view(), name='loot_delete'), path('team//loot/bis/', views.LootWithBIS.as_view(), name='loot_with_bis'), # LootSolver diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py index e992a614..34509dd9 100644 --- a/backend/api/views/__init__.py +++ b/backend/api/views/__init__.py @@ -4,7 +4,7 @@ from .gear import GearCollection, ItemLevels from .job import JobCollection, JobSolverSortCollection from .lodestone import LodestoneGearImport, LodestoneResource -from .loot import LootCollection, LootWithBIS +from .loot import LootCollection, LootDelete, LootWithBIS from .loot_solver import LootSolver from .notification import NotificationCollection, NotificationResource from .plugin import PluginImport @@ -36,6 +36,7 @@ 'LodestoneResource', 'LootCollection', + 'LootDelete', 'LootWithBIS', 'LootSolver', diff --git a/backend/api/views/bis_list.py b/backend/api/views/bis_list.py index 1994f1c3..a0a3e5ef 100644 --- a/backend/api/views/bis_list.py +++ b/backend/api/views/bis_list.py @@ -7,6 +7,9 @@ from typing import List # lib from django.db.models.deletion import ProtectedError +from rest_framework import serializers +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema from rest_framework.request import Request from rest_framework.response import Response # local @@ -35,10 +38,23 @@ class BISListCollection(BISListBaseView): """ Allows for the creation of new BIS Lists """ - + queryset = BISList + serializer_class = BISListModifySerializer + + @extend_schema( + operation_id='bis_list_create', + tags=['bis_list'], + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.IntegerField()}), + description='The ID of the created BISList', + ), + 404: OpenApiResponse(description='The given Character ID did not belong to a valid Character owned by the requesting User'), + }, + ) def post(self, request: Request, character_id: int) -> Response: """ - Create a BIS List belonging to the specified character + Create a new BIS List belonging to the specified Character. """ try: char = Character.objects.get(pk=character_id, user=request.user) @@ -65,9 +81,22 @@ class BISListResource(BISListBaseView): Allows for the reading and updating of a BISList """ + @extend_schema( + operation_id='bis_list_read', + tags=['bis_list'], + responses={ + 200: BISListSerializer, + 404: OpenApiResponse( + description=( + 'The given Character ID did not belong to a valid Character owned by the requesting User.' + '\nAlternatively the BISList ID does not belong to a valid Character.' + ) + ), + }, + ) def get(self, request: Request, character_id: int, pk: int) -> Response: """ - Read a BISList + Read a specific BISList instance, belonging to a specified Character. """ try: char = Character.objects.get(pk=character_id, user=request.user) @@ -82,9 +111,23 @@ def get(self, request: Request, character_id: int, pk: int) -> Response: data = BISListSerializer(instance=obj).data return Response(data) + @extend_schema( + operation_id='bis_list_update', + tags=['bis_list'], + request=BISListModifySerializer, + responses={ + 204: OpenApiResponse(description='BISList was updated successfully!'), + 404: OpenApiResponse( + description=( + 'The given Character ID did not belong to a valid Character owned by the requesting User.' + '\nAlternatively the BISList ID does not belong to a valid Character.' + ) + ), + }, + ) def put(self, request: Request, character_id: int, pk: int) -> Response: """ - Update an existing BISList + Update the details of a BISList that belongs to a specified Character. """ try: char = Character.objects.get(pk=character_id, user=request.user) @@ -120,9 +163,34 @@ class BISListDelete(APIView): Has a GET request to get information on if we can delete this BISList. """ + @extend_schema( + operation_id='bis_list_delete_check', + tags=['bis_list'], + responses={ + 200: OpenApiResponse( + response=inline_serializer( + 'BISListDeleteReadResponse', + { + 'id': serializers.IntegerField(), + 'member': serializers.IntegerField(), + 'name': serializers.CharField(), + }, + many=True, + ), + description='A list of places where the BIS List is in use. If this list is empty, the BISList is safe to delete!', + ), + 404: OpenApiResponse( + description=( + 'The given Character ID did not belong to a valid Character owned by the requesting User.' + '\nAlternatively the BISList ID does not belong to a valid Character.' + ) + ), + }, + + ) def get(self, request: Request, character_id: int, pk: int) -> Response: """ - Check if we are able to delete a sepcified BIS List. + Check if we are able to delete a specified Character's BIS List. We can only do this if the BIS List is not in use in any Teams. If it is, return names and IDs of the Teams it's in use in so we can provide links in the frontend. @@ -148,9 +216,25 @@ def get(self, request: Request, character_id: int, pk: int) -> Response: return Response(info) + @extend_schema( + operation_id='bis_list_delete', + tags=['bis_list'], + responses={ + 204: OpenApiResponse(description='BISList was deleted successfully!'), + 400: OpenApiResponse(description='BISList could not be deleted.'), + 404: OpenApiResponse( + description=( + 'The given Character ID did not belong to a valid Character owned by the requesting User.' + '\nAlternatively the BISList ID does not belong to a valid Character.' + ) + ), + }, + ) def delete(self, request: Request, character_id: int, pk: int) -> Response: """ - Delete the BISList from the DB, ensuring that we can. + Delete the BISList from the DB. + + If the BISList is not currently deleteable, this method will return a 400 error. """ try: char = Character.objects.get(pk=character_id, user=request.user) diff --git a/backend/api/views/character.py b/backend/api/views/character.py index d4990854..dd5faa9e 100644 --- a/backend/api/views/character.py +++ b/backend/api/views/character.py @@ -5,6 +5,9 @@ """ # lib +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -24,19 +27,37 @@ class CharacterCollection(APIView): Provides list and create methods. """ + @extend_schema( + responses={ + 200: OpenApiResponse( + response=CharacterCollectionSerializer(many=True), + description='List of all the Characters belonging to the User.', + ), + }, + operation_id='character_list', + ) def get(self, request: Request) -> Response: """ - Return a list of Characters belonging to a certain User + Retrieve all of the Characters belonging to the requesting User. """ # Permissions won't allow this method to be run by non-auth'd users objs = Character.objects.filter(user=request.user) data = CharacterCollectionSerializer(objs, many=True).data return Response(data) + @extend_schema( + request=CharacterCollectionSerializer, + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.IntegerField()}), + description='The ID of the created Character', + ), + }, + operation_id='character_create', + ) def post(self, request: Request) -> Response: """ - Characters are verified via celery. - This view will create the data in the DB + Create a new, un-verified Character, that belongs to the requesting User. """ # Put the sent data into the serializer for validation serializer = CharacterCollectionSerializer(data=request.data) @@ -55,11 +76,20 @@ class CharacterResource(APIView): Handling character specific requests """ + @extend_schema( + responses={ + 200: CharacterDetailsSerializer, + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid Character owned by the requesting User.', + ), + }, + operation_id='character_read', + ) def get(self, request: Request, pk: int) -> Response: """ - Read the data of a Character. + Read the data of a specified Character. - This view will return full data, including a list of gearsets and teams and such + This endpoint will return the full data of the Character, including associated BISLists and Teams. """ try: obj = Character.objects.get(pk=pk, user=request.user) @@ -69,9 +99,21 @@ def get(self, request: Request, pk: int) -> Response: data = CharacterDetailsSerializer(instance=obj).data return Response(data) + @extend_schema( + request=CharacterUpdateSerializer, + responses={ + 204: OpenApiResponse(description='Character was successfully updated!'), + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid Character owned by the requesting User.', + ), + }, + operation_id='character_update', + ) def put(self, request: Request, pk: int, partial: bool = False) -> Response: """ - Update certain fields of a Character + Update the information of a Character. + + Requires sending the full object to update. """ try: obj = Character.objects.get(pk=pk, user=request.user) @@ -92,7 +134,22 @@ def put(self, request: Request, pk: int, partial: bool = False) -> Response: return Response(status=204) + @extend_schema( + request=CharacterUpdateSerializer, + responses={ + 204: OpenApiResponse(description='Character was successfully updated!'), + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid Character owned by the requesting User.', + ), + }, + operation_id='character_partial_update', + ) def patch(self, request: Request, pk: int) -> Response: + """ + Update the information of a Character. + + Can handle partial update requests, only changed fields need to be sent to this endpoint. + """ return self.put(request, pk, True) @@ -101,9 +158,21 @@ class CharacterVerification(APIView): A class specifically for triggering the verification process """ + @extend_schema( + request=CharacterUpdateSerializer, + responses={ + 202: OpenApiResponse(description='Character verification has been requested!'), + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid, unverified, Character owned by the requesting User.', + ), + }, + operation_id='request_character_verification', + ) def post(self, request: Request, pk: int) -> Response: """ - On receipt of this request, we add the given character to the queue for verification (if needed) + Trigger the verification process for a given Character. + + The process involves checking that the token associated with the Character is present on the Lodestone profile. """ try: Character.objects.get(pk=pk, user=request.user, verified=False) @@ -122,9 +191,33 @@ class CharacterDelete(APIView): Has a GET request to get what will be affected by the deletion of this Character. """ + @extend_schema( + responses={ + 200: OpenApiResponse( + response=inline_serializer( + 'CharacterDeleteReadResponse', + { + 'lead': serializers.BooleanField(), + 'members': serializers.IntegerField(), + 'name': serializers.CharField(), + }, + many=True, + ), + description='A list of Teams that will be affected by the Character\'s deletion.', + ), + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid Character owned by the requesting User.', + ), + }, + operation_id='character_delete_check', + ) def get(self, request: Request, pk: int) -> Response: """ - Check through the DB for any information regarding the Character in question + Check what will happen if this Character would be deleted. + Returns a list of Teams that the Character is in, including the following fields; + - name: The name of the Team + - lead: Whether the Character is the leader of the Team. + - members: How many members are in the Team. """ try: obj = Character.objects.get(pk=pk, user=request.user) @@ -142,9 +235,22 @@ def get(self, request: Request, pk: int) -> Response: return Response(info) + @extend_schema( + responses={ + 204: OpenApiResponse(description='Character was deleted successfully!'), + 404: OpenApiResponse( + description='The given Character ID did not belong to a valid Character owned by the requesting User.', + ), + }, + operation_id='character_delete', + ) def delete(self, request: Request, pk: int) -> Response: """ - Delete the Character from the DB, doing all the things that are stated will happen + Delete the Character from the system. + This can also trigger the following effects where appropriate; + - Disbanding Teams where the Character is the only Member + - Handing leadership of a Team to another Member if the Character is the leader + - Leaving the Team if the Character is not the leader. """ try: obj = Character.objects.get(pk=pk, user=request.user) diff --git a/backend/api/views/etro.py b/backend/api/views/etro.py index 3dfe0220..490e3ed4 100644 --- a/backend/api/views/etro.py +++ b/backend/api/views/etro.py @@ -5,6 +5,9 @@ from typing import Dict # lib import coreapi +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -34,9 +37,40 @@ class EtroImport(ImportAPIView): Import an etro gearset using coreapi and levenshtein distance """ + @extend_schema( + responses={ + 200: OpenApiResponse( + response=inline_serializer( + 'EtroImportResponse', + { + 'mainhand': serializers.IntegerField(), + 'offhand': serializers.IntegerField(), + 'head': serializers.IntegerField(), + 'body': serializers.IntegerField(), + 'hands': serializers.IntegerField(), + 'legs': serializers.IntegerField(), + 'feet': serializers.IntegerField(), + 'earrings': serializers.IntegerField(), + 'necklace': serializers.IntegerField(), + 'bracelet': serializers.IntegerField(), + 'right_ring': serializers.IntegerField(), + 'left_ring': serializers.IntegerField(), + }, + ), + description='Map of slot name to the IDs of the Gear objects that *should* match the item on the slot of the Etro set.' + ), + 400: OpenApiResponse( + description='An error occurred on Etro\'s end. Error message from Etro will be included in the response', + response=inline_serializer('EtroImport400Response', {'message': serializers.CharField()}), + ), + }, + operation_id='import_gear_from_etro', + ) def get(self, request: Request, id: str) -> Response: """ - Given an Etro Gearset ID, load the equipment from it + Attempt to load the information of an Etro Gearset, and turn it into information SavageAim can use. + + Names are mapped to Gear instances using name similarity, and is not 100% guaranteed to be correct. """ # Instantiate a Client instance for CoreAPI client = coreapi.Client() diff --git a/backend/api/views/gear.py b/backend/api/views/gear.py index 320ab178..a50638ee 100644 --- a/backend/api/views/gear.py +++ b/backend/api/views/gear.py @@ -3,6 +3,8 @@ """ # lib +from drf_spectacular.utils import extend_schema, inline_serializer, OpenApiParameter +from rest_framework import serializers from rest_framework.views import APIView from rest_framework.permissions import AllowAny from rest_framework.request import Request @@ -18,14 +20,21 @@ class GearCollection(APIView): """ Get a list of Gears in the system """ + queryset = Gear + serializer_class = GearSerializer(many=True) permission_classes = [AllowAny] + @extend_schema( + parameters=[ + OpenApiParameter('item_level_min', int, description='Set a minimum item level (inclusive) of Gear returned.'), + OpenApiParameter('item_level_max', int, description='Set a maximum item level (inclusive) of Gear returned.'), + ], + operation_id='gear_list', + ) def get(self, request: Request) -> Response: """ - List the Gear items. - - We should allow filtering by item levels, both gte and lte + Retrieve a list of Gear objects from the system. """ objs = Gear.objects.all() @@ -56,9 +65,15 @@ class ItemLevels(APIView): permission_classes = [AllowAny] + @extend_schema( + responses={ + 200: inline_serializer('ItemLevels', {'min': serializers.IntegerField(), 'max': serializers.IntegerField()}), + }, + operation_id='read_item_level_range', + ) def get(self, request: Request) -> Response: """ - Fetch the min and max item level for the system + Retrieve the minimum and maximum Item Levels in the entire DB. """ objs = Gear.objects.values('item_level') diff --git a/backend/api/views/job.py b/backend/api/views/job.py index 51a19ded..337f0db0 100644 --- a/backend/api/views/job.py +++ b/backend/api/views/job.py @@ -18,7 +18,8 @@ class JobCollection(APIView): """ Get a list of Jobs in the system """ - + queryset = Job + serializer_class = JobSerializer(many=True) permission_classes = [AllowAny] def get(self, request: Request) -> Response: @@ -35,12 +36,16 @@ class JobSolverSortCollection(APIView): """ Get a list of Jobs in the system, ordered by the default order of how jobs are sorted in the solver by default """ - + queryset = Job + serializer_class = JobSerializer(many=True) permission_classes = [AllowAny] def get(self, request: Request) -> Response: """ - Return the full list of Jobs in default ordering (Melee > Ranged > Caster > Tanks > Healer) + Return the full list of Jobs in Loot Solver default order (Melee > Ranged > Caster > Tank > Healer) + + The point of this endpoint is to allow the TeamSettings page to display the ordering of Jobs within the Loot Solver sorting. + Teams only store overrides, so this view is available to retrieve the default, and then overrides are moved around as needed. """ # Permissions won't allow this method to be run by non-auth'd users objs = Job.get_in_solver_order() diff --git a/backend/api/views/lodestone.py b/backend/api/views/lodestone.py index 8ceffccd..a757ddaa 100644 --- a/backend/api/views/lodestone.py +++ b/backend/api/views/lodestone.py @@ -9,6 +9,9 @@ """ # lib +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response @@ -23,9 +26,25 @@ class LodestoneResource(APIView): Retrieve character data for a given Character ID """ + @extend_schema( + responses={ + 200: inline_serializer( + 'LodestoneCharacterScrapeResponse', + { + 'avatar_url': serializers.URLField(), + 'name': serializers.CharField(), + 'world': serializers.CharField(), + 'dc': serializers.CharField(), + }, + ), + 400: OpenApiResponse(description='An error occurred when retrieving info from the Lodestone.'), + 404: OpenApiResponse(description='Character ID was not found on the Lodestone.'), + }, + operation_id='lodestone_character_data_scrape', + ) def get(self, request: Request, character_id: str) -> Response: """ - Scrape the Lodestone and return the found character data + Read the given Character's Lodestone page, and scrape the required information for the system. """ scraper = LodestoneScraper.get_instance() try: @@ -43,9 +62,36 @@ class LodestoneGearImport(ImportAPIView): Given a Character ID, retrieve its current job and gear details, which the view code can turn into valid gear items """ + @extend_schema( + responses={ + 200: inline_serializer( + 'LodestoneGearImportResponse', + { + 'mainhand': serializers.IntegerField(), + 'offhand': serializers.IntegerField(), + 'head': serializers.IntegerField(), + 'body': serializers.IntegerField(), + 'hands': serializers.IntegerField(), + 'legs': serializers.IntegerField(), + 'feet': serializers.IntegerField(), + 'earrings': serializers.IntegerField(), + 'necklace': serializers.IntegerField(), + 'bracelet': serializers.IntegerField(), + 'right_ring': serializers.IntegerField(), + 'left_ring': serializers.IntegerField(), + }, + ), + 400: OpenApiResponse(response=inline_serializer('LodestoneImport400Response', {'message': serializers.CharField()})), + 404: OpenApiResponse(response=inline_serializer('LodestoneImport404Response', {'message': serializers.CharField()})), + 406: OpenApiResponse(response=inline_serializer('LodestoneImport406Response', {'message': serializers.CharField()})), + }, + operation_id='lodestone_scrape_character_current_gear', + ) def get(self, request: Request, character_id: str, expected_job: str) -> Response: """ - Scrape the Lodestone and return the gear and job information + Read the given Character's Lodestone page, and scrape their currently equipped gear. + + If the gear on the site is not useable by the `expected_job`, this view will return an error. """ try: Job.objects.get(id=expected_job) diff --git a/backend/api/views/loot.py b/backend/api/views/loot.py index 0ed283ce..3919c492 100644 --- a/backend/api/views/loot.py +++ b/backend/api/views/loot.py @@ -10,6 +10,9 @@ # lib from django.core.exceptions import ValidationError from django.db.models import QuerySet +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -26,6 +29,52 @@ PERMISSION_NAME = 'loot_manager' +# Define Serializers for the response info. +class GreedItemSerializer(serializers.Serializer): + bis_list_id = serializers.IntegerField() + bis_list_name = serializers.CharField() + current_gear_name = serializers.CharField() + current_gear_il = serializers.IntegerField() + job_icon_name = serializers.CharField() + job_role = serializers.CharField() + + +class TomeGreedItemSerializer(serializers.Serializer): + bis_list_id = serializers.IntegerField() + job_icon_name = serializers.CharField() + job_role = serializers.CharField() + required = serializers.IntegerField() + + +class NeedGearSerializer(serializers.Serializer): + member_id = serializers.IntegerField() + character_name = serializers.CharField() + current_gear_name = serializers.CharField() + current_gear_il = serializers.IntegerField() + job_icon_name = serializers.CharField() + job_role = serializers.CharField() + + +class TomeNeedGearSerializer(serializers.Serializer): + member_id = serializers.IntegerField() + character_name = serializers.CharField() + job_icon_name = serializers.CharField() + job_role = serializers.CharField() + required = serializers.IntegerField() + + +class GreedGearSerializer(serializers.Serializer): + member_id = serializers.IntegerField() + character_name = serializers.CharField() + greed_lists = serializers.ListField(child=GreedItemSerializer(many=True)) + + +class TomeGreedGearSerializer(serializers.Serializer): + member_id = serializers.IntegerField() + character_name = serializers.CharField() + greed_lists = serializers.ListField(child=TomeGreedItemSerializer(many=True)) + + class LootCollection(APIView): """ Management of Team Loot @@ -227,9 +276,151 @@ def _get_history_loot_data(self, obj: Team, loot: QuerySet) -> Dict[str, List[Di response[slot] = slot_data return response + @extend_schema( + tags=['team_loot'], + responses={ + 200: inline_serializer( + 'LootListResponse', + { + 'history': LootSerializer(many=True), + 'received': inline_serializer( + 'LootReceived', + { + '[member_name: str]': inline_serializer( + 'LootReceivedEntry', + { + 'need': serializers.IntegerField(), + 'greed': serializers.IntegerField(), + }, + ), + }, + ), + 'gear': inline_serializer( + 'LootGearRequiredResponse', + { + 'mainhand': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'offhand': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'head': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'body': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'hands': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'legs': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'feet': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'earrings': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'necklace': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'bracelet': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'ring': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'tome-accessory-augment': inline_serializer( + 'TomeAugmentRequiredData', + { + 'need': TomeNeedGearSerializer(many=True), + 'greed': TomeGreedGearSerializer(many=True), + } + ), + 'tome-armour-augment': inline_serializer( + 'TomeAugmentRequiredData', + { + 'need': TomeNeedGearSerializer(many=True), + 'greed': TomeGreedGearSerializer(many=True), + } + ), + 'mount': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'tome-weapon-token': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + 'tome-weapon-augment': inline_serializer( + 'RaidRequiredData', + { + 'need': NeedGearSerializer(many=True), + 'greed': GreedGearSerializer(many=True), + } + ), + }, + ) + } + ), + 404: OpenApiResponse(description='The provided Team ID does not refer to a valid Team that the User has a Character in.'), + }, + operation_id='loot_list', + ) def get(self, request: Request, team_id: str) -> Response: """ - Get loot history and current need/greed status for a team + Get Loot history and current need/greed status for a team """ try: obj = Team.objects.select_related( @@ -292,10 +483,23 @@ def get(self, request: Request, team_id: str) -> Response: team_data = TeamSerializer(obj).data return Response({'team': team_data, 'loot': loot_data}) + @extend_schema( + tags=['team_loot'], + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.IntegerField()}), + description='The ID of the created Loot record', + ), + 404: OpenApiResponse(description='The provided Team ID does not refer to a valid Team that the User has a Character in.'), + }, + request=LootCreateSerializer(), + operation_id='loot_create', + ) def post(self, request: Request, team_id: str) -> Response: """ - Attempt to create new Loot entries. - Any updates sent here will also update Character's BIS Lists + Create new Loot entry for the specified Team. + + This method does not touch the BISLists, only creates Loot records to be displayed in the Loot History. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: @@ -311,10 +515,26 @@ def post(self, request: Request, team_id: str) -> Response: return Response({'id': serializer.instance.pk}, status=201) - def delete(self, request: Request, team_id: str) -> Response: + +class LootDelete(APIView): + + @extend_schema( + tags=['team_loot'], + responses={ + 204: OpenApiResponse( + description='The supplied Loot records were deleted successfully', + ), + 404: OpenApiResponse(description='The provided Team ID does not refer to a valid Team that the User has a Character in.'), + }, + request=inline_serializer('LootDeleteRequest', {'items': serializers.ListField(child=serializers.IntegerField())}), + operation_id='loot_delete', + ) + def post(self, request: Request, team_id: str) -> Response: """ - Remove Loot entries from the Team's history. - Entries to delete are specified in the request body. + Delete Loot records for a Team. + + Will delete any Loot records with valid IDs in the list provided, regardless of Tier they are from. + Any IDs either not in the system, or that don't belong to the specified Team, will be ignored silently. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: @@ -335,10 +555,26 @@ class LootWithBIS(APIView): Has stricter serializer since it affects two models instead of one """ + @extend_schema( + tags=['team_loot'], + responses={ + 201: OpenApiResponse( + response=inline_serializer('LootRecordCreateWithBISResponse', {'id': serializers.IntegerField()}), + description='The ID of the created Loot record', + ), + 404: OpenApiResponse(description='The provided Team ID does not refer to a valid Team that the User has a Character in.'), + }, + request=LootCreateWithBISSerializer(), + operation_id='loot_create_with_bis_update', + ) def post(self, request: Request, team_id: str) -> Response: """ - Attempt to create new Loot entries. - Any updates sent here will also update Character's BIS Lists + Create new Loot entry for the specified Team. + + This method will also update the involved BIS List's Current gear for the slot to be the BIS item. + As a result, this method should only be called with gear that are direct drops from fights, and not obtained via tomes / upgrades. + + The `greed_bis_id` is required if the item is given not for the Character's main BIS List associated with the Team, so that the correct list can be updated. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: diff --git a/backend/api/views/loot_solver.py b/backend/api/views/loot_solver.py index d8cc593f..20ef10ea 100644 --- a/backend/api/views/loot_solver.py +++ b/backend/api/views/loot_solver.py @@ -10,6 +10,9 @@ # lib from django.core.exceptions import ValidationError from django.db.models import QuerySet +from drf_spectacular.utils import inline_serializer +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -404,9 +407,54 @@ def _get_fourth_floor_data(self, history: QuerySet[Loot], team_size: int, non_lo 'mounts': team_size - mounts_obtained, } + @extend_schema( + tags=['team_loot'], + responses={ + 200: inline_serializer( + 'LootSolverResponse', + { + 'first_floor': inline_serializer( + 'LootSolverFirstFloorResponse', + { + slot: serializers.ListField(child=serializers.IntegerField()) + for slot in FIRST_FLOOR_SLOTS + }, + ), + 'second_floor': inline_serializer( + 'LootSolverSecondFloorResponse', + { + slot: serializers.ListField(child=serializers.IntegerField()) + for slot in SECOND_FLOOR_SLOTS + }, + ), + 'third_floor': inline_serializer( + 'LootSolverThirdFloorResponse', + { + slot: serializers.ListField(child=serializers.IntegerField()) + for slot in THIRD_FLOOR_SLOTS + }, + ), + 'fourth_floor': inline_serializer( + 'LootSolverFourthFloorResponse', + { + 'weapons': serializers.IntegerField(), + 'mounts': serializers.IntegerField(), + }, + ), + } + ) + }, + operation_id='run_loot_solver', + ) def get(self, request: Request, team_id: str) -> Response: """ - Fetch the current solver information for the team + Run the Loot Solver for the specified Team. + + The Loot Solver is a system that attempts to generate an ordering for each piece of loot every week, + in order to attempt and finish gearing as fast as possible for each of the first three fights of a Tier. + + For the first three floors, each slot will have a list of TeamMember IDs in the order you should hand them out. + For the final fight, it simply returns the number of Weapons and Mounts required. """ try: obj = Team.objects.select_related( diff --git a/backend/api/views/notification.py b/backend/api/views/notification.py index 8e5b17ea..ec949bdc 100644 --- a/backend/api/views/notification.py +++ b/backend/api/views/notification.py @@ -3,6 +3,8 @@ """ # lib +from drf_spectacular.utils import OpenApiResponse +from drf_spectacular.views import extend_schema from rest_framework.views import APIView from rest_framework.request import Request from rest_framework.response import Response @@ -18,10 +20,12 @@ class NotificationCollection(APIView): Retrieve a list of Notifications Send a post request to mark all your notifications as read """ + queryset = Notification + serializer_class = NotificationSerializer def get(self, request: Request) -> Response: """ - List the Notifications + Retrieve a list of all the Notifications that were sent to the requesting User. """ # Get the filters from the query parameters unread = request.query_params.get('unread', False) @@ -39,9 +43,16 @@ def get(self, request: Request) -> Response: data = NotificationSerializer(objs, many=True).data return Response(data) + @extend_schema( + operation_id='notifications_mark_all_as_read', + request=None, + responses={ + 200: OpenApiResponse(description='All Notifications for the requesting User have been marked as read.'), + }, + ) def post(self, request: Request) -> Response: """ - Mark all your notifications as read + Mark all the requesting User's Notifications as read """ Notification.objects.filter(user=request.user).update(read=True) return Response() @@ -52,9 +63,17 @@ class NotificationResource(APIView): Mark individual notifications as read """ + @extend_schema( + operation_id='notifications_mark_as_read', + request=None, + responses={ + 200: OpenApiResponse(description='The specified Notification for the requesting User has been marked as read.'), + }, + ) def post(self, request: Request, pk: int) -> Response: """ - Mark specific notification as read + Mark a specific Notification as read for the requesting User. + If the ID is invalid, for whatever reason, this method will do nothing instead of returning an error. """ Notification.objects.filter(user=request.user, pk=pk).update(read=True) return Response() diff --git a/backend/api/views/plugin.py b/backend/api/views/plugin.py index 763ce6a3..95539513 100644 --- a/backend/api/views/plugin.py +++ b/backend/api/views/plugin.py @@ -4,6 +4,7 @@ # stdlib from typing import Dict # lib +from drf_spectacular.views import extend_schema from rest_framework.request import Request from rest_framework.response import Response # local @@ -16,10 +17,15 @@ class PluginImport(ImportAPIView): """ Convert names and item levels from in game items to Savage Aim Gear Items. """ + serializer_class = PluginImportResponseSerializer + @extend_schema( + request=PluginImportSerializer, + ) def post(self, request: Request) -> Response: """ - Convert names from in-game into Gear instances. + Given a set of names taken from in-game, turn the in-game names into the names and IDs of Gear records in the DB. + This allows the plugin to send a valid update request using the official in-system Gear IDs. """ # Use Serializer for validating the provided data serializer = PluginImportSerializer(data=request.data) diff --git a/backend/api/views/team.py b/backend/api/views/team.py index eeb33af8..eba934a8 100644 --- a/backend/api/views/team.py +++ b/backend/api/views/team.py @@ -7,6 +7,9 @@ # lib from django.core.exceptions import ValidationError +from drf_spectacular.utils import inline_serializer, OpenApiResponse, OpenApiParameter +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -26,11 +29,20 @@ class TeamCollection(APIView): Methods to interact with a list of Teams that the User has a character in. Provides list and create methods. """ - + queryset = Team + serializer_class = TeamSerializer(many=True) + + @extend_schema( + parameters=[ + OpenApiParameter( + 'char_id', + int, + description='Filter the response to Teams that the specified Character is in.'), + ], + ) def get(self, request: Request) -> Response: """ - Return a list of teams for the User. - Returns a list of all teams the User has characters in, which can be filtered further by query params + Return a list of the Teams that the requesting User has Characters in. """ objs = Team.objects.filter(members__character__user=request.user).order_by('name').distinct() @@ -46,9 +58,22 @@ def get(self, request: Request) -> Response: data = TeamSerializer(objs, many=True).data return Response(data) + @extend_schema( + request=TeamCreateSerializer, + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.UUIDField()}), + description='The ID of the created Team.', + ), + 400: OpenApiResponse( + response=TeamCreateSerializer, + description='Errors occurred during validation of sent data. The values will all be lists of strings for any keys that are present.', + ), + } + ) def post(self, request: Request) -> Response: """ - Create a new team, with the data for the team lead team member + Create a new Team. """ # Ensure the data we were sent is valid serializer = TeamCreateSerializer(data=request.data, context={'user': request.user}) @@ -80,10 +105,18 @@ class TeamResource(APIView): Handling team specific requests """ + @extend_schema( + responses={ + 200: TeamSerializer, + 404: OpenApiResponse( + description='Team ID is invalid, or the requesting User has no Characters in the Team.', + ), + } + ) def get(self, request: Request, pk: str) -> Response: """ - Read the data of a Team. - It must be a Team that the requesting user controls a character in, or the request will fail + Read the data of a specified Team. + The requesting User must have a Character in the Team to be able to read it. """ try: obj = Team.objects.filter(members__character__user=request.user).distinct().get(pk=pk) @@ -93,10 +126,23 @@ def get(self, request: Request, pk: str) -> Response: data = TeamSerializer(instance=obj).data return Response(data) + @extend_schema( + request=TeamUpdateSerializer, + responses={ + 204: OpenApiResponse(description='The Team details have been updated successfully!'), + 400: OpenApiResponse( + response=TeamUpdateSerializer, + description='A map of any errors for the provided Character and BIS data. The values will all be lists of strings for any keys that are present.', + ), + 404: OpenApiResponse( + description='Team ID is invalid, or the requesting User does not own the leader of the Team.', + ), + } + ) def put(self, request: Request, pk: str) -> Response: """ - Update some data about the Team - This request can only be run by the user whose character is the team lead + Update information about a specified Team. + The requesting User must own the Character who leads the Team in order to run this method. """ obj = self._get_team_as_leader(request, pk) if obj is None: @@ -121,9 +167,19 @@ def put(self, request: Request, pk: str) -> Response: self._send_to_user(tm.character.user, {'type': 'character', 'id': tm.character.pk}) return Response(status=204) + @extend_schema( + request=None, + responses={ + 204: OpenApiResponse(description='The `invite_code` was successfully regenerated!'), + 404: OpenApiResponse( + description='Team ID is invalid, or the requesting User does not own the leader of the Team.', + ), + } + ) def patch(self, request: Request, pk: str) -> Response: """ - Regenerate the Team's token + Regenerate the Team's `invite_code`. + The requesting User must own the Character who leads the Team in order to run this method. """ obj = self._get_team_as_leader(request, pk) if obj is None: @@ -133,11 +189,19 @@ def patch(self, request: Request, pk: str) -> Response: obj.save() return Response(status=204) + @extend_schema( + request=None, + responses={ + 204: OpenApiResponse(description='The Team was successfully disbanded!'), + 404: OpenApiResponse( + description='Team ID is invalid, or the requesting User does not own the leader of the Team.', + ), + } + ) def delete(self, request: Request, pk: str) -> Response: """ - Disband a Team - - Notify all non leader members of the Team being disbanded + Disband a Team. + The requesting User must own the Character who leads the Team in order to run this method. """ obj = self._get_team_as_leader(request, pk) if obj is None: @@ -161,10 +225,17 @@ class TeamInvite(APIView): Used to check that an invite code is valid, pull information, and add members to a Team """ + @extend_schema( + request=None, + responses={ + 200: OpenApiResponse(description='The supplied `invite_code` is valid!'), + 404: OpenApiResponse(description='The supplied `invite_code` is invalid.'), + } + ) def head(self, request: Request, invite_code: str) -> Response: """ - Check a Team's existence purely by URL. - Will be used by the frontend to check invite code validity without loading team data + Check that a Team exists using only its `invite_code`. + Used by the frontend to validate a User's supplied `invite_code` without having to load the whole Team's data. """ try: Team.objects.get(invite_code=invite_code) @@ -173,10 +244,16 @@ def head(self, request: Request, invite_code: str) -> Response: return Response(status=200) + @extend_schema( + request=None, + responses={ + 200: TeamSerializer, + 404: OpenApiResponse(description='A Team with the given `invite_code` does not exist.'), + } + ) def get(self, request: Request, invite_code: str) -> Response: """ - Return a list of teams for the User. - Returns a list of all teams the User has characters in, which can be filtered further by query params + Retrieve the data for a Team whose `invite_code` matches the one that is supplied. """ try: obj = Team.objects.get(invite_code=invite_code) @@ -186,10 +263,23 @@ def get(self, request: Request, invite_code: str) -> Response: data = TeamSerializer(obj).data return Response(data) + @extend_schema( + request=TeamMemberModifySerializer, + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.UUIDField()}), + description='The ID of the created Team Member', + ), + 400: OpenApiResponse( + response=TeamMemberModifySerializer, + description='Errors occurred during validation of sent data. The values will all be lists of strings for any keys that are present.', + ), + 404: OpenApiResponse(description='A Team with the given `invite_code` does not exist.'), + } + ) def post(self, request: Request, invite_code: str) -> Response: """ - Add a new team member to the team via the invite link - Characters can only be on a team once + Allow a User to accept an invitation to join a Team by choosing a Character and BISList. """ try: obj = Team.objects.get(invite_code=invite_code) diff --git a/backend/api/views/team_member.py b/backend/api/views/team_member.py index bf1c2ac3..0975258e 100644 --- a/backend/api/views/team_member.py +++ b/backend/api/views/team_member.py @@ -9,6 +9,8 @@ # lib from django.core.exceptions import ValidationError +from drf_spectacular.utils import OpenApiResponse +from drf_spectacular.views import extend_schema from rest_framework.request import Request from rest_framework.response import Response # local @@ -25,10 +27,19 @@ class TeamMemberResource(APIView): """ Management of Team Member Objects """ - + queryset = TeamMember + serializer_class = TeamMemberSerializer + + @extend_schema( + tags=['team_member'], + responses={ + 200: TeamMemberSerializer, + 404: OpenApiResponse(description='The Team ID does not exist or the Member ID is not valid.'), + } + ) def get(self, request: Request, team_id: str, pk: id) -> Response: """ - Get the Data for a single Team Member record + Read the Membership data for a Character that the requesting User owns in a given Team. """ try: obj = TeamMember.objects.get(pk=pk, team_id=team_id, character__user=request.user) @@ -38,9 +49,18 @@ def get(self, request: Request, team_id: str, pk: id) -> Response: data = TeamMemberSerializer(instance=obj).data return Response(data) + @extend_schema( + tags=['team_member'], + request=TeamMemberModifySerializer, + responses={ + 204: OpenApiResponse(description='Membership information updated successfully!'), + 400: OpenApiResponse(description='Sent data is invalid.'), + 404: OpenApiResponse(description='The Team ID does not exist or the Member ID is not valid.'), + } + ) def put(self, request: Request, team_id: str, pk: id) -> Response: """ - Update a pre-existing Team Member object, potentially changing both the linked character and bis list + Update some of the Membership data for a Character that the requesting User owns in a given Team. """ try: obj = TeamMember.objects.get(pk=pk, team_id=team_id, character__user=request.user) @@ -61,10 +81,20 @@ def put(self, request: Request, team_id: str, pk: id) -> Response: return Response(status=204) + @extend_schema( + tags=['team_member'], + responses={ + 204: OpenApiResponse(description='Membership information deleted successfully!'), + 404: OpenApiResponse(description='The Team ID does not exist or the Member ID is not valid.'), + } + ) def delete(self, request: Request, team_id: str, pk: id) -> Response: """ - Team Members can leave a team - Team leaders can kick team members + Delete the Membership record for a Member of a Team. + + This method can be run by two people; + 1. The owner of the Member can run this method to leave the specified Team. + 2. The leader of the Team can run this method to kick the specified Member from the Team. """ try: obj = TeamMember.objects.get(pk=pk, team_id=team_id) @@ -122,9 +152,25 @@ class TeamMemberPermissionsResource(APIView): Allow for the updating of Team Member permissions by the Team Lead """ + @extend_schema( + tags=['team_member'], + request=TeamMemberPermissionsModifySerializer, + responses={ + 204: OpenApiResponse(description='The Member\'s permissions have been updated successfully!'), + 400: OpenApiResponse(description='Sent data is invalid.'), + 404: OpenApiResponse(description='The Team ID does not exist or the Member ID is not valid. Alternatively, the requesting User is not the Team Lead.'), + } + ) def put(self, request: Request, team_id: str, pk: id) -> Response: """ - Update a pre-existing Team Member object, potentially changing both the linked character and bis list + Update the Permissions that a Member of a Team has. + This method can only be run by the Team Leader. + + The permissions use a bitflag approach with the following numbers; + - `loot_manager` = `1` + - `proxy_manager` = `2` + + Sending a value of `3` gives both permissions. """ # Make sure the user in question is the Team Leader team = self._get_team_as_leader(request, team_id) diff --git a/backend/api/views/team_proxy.py b/backend/api/views/team_proxy.py index a0491594..a2425d7b 100644 --- a/backend/api/views/team_proxy.py +++ b/backend/api/views/team_proxy.py @@ -6,6 +6,9 @@ # lib from django.core.exceptions import ValidationError +from drf_spectacular.utils import inline_serializer, OpenApiResponse +from drf_spectacular.views import extend_schema +from rest_framework import serializers from rest_framework.request import Request from rest_framework.response import Response # local @@ -26,10 +29,34 @@ class TeamProxyCollection(APIView): Handle creation of new Proxy Characters for a Team """ + @extend_schema( + tags=['team_proxy'], + request=inline_serializer( + 'TeamProxyMemberCreateRequest', + { + 'character': CharacterCollectionSerializer, + 'bis_data': BISListModifySerializer, + }, + ), + responses={ + 201: OpenApiResponse( + response=inline_serializer('CreateResponse', {'id': serializers.IntegerField()}), + description='The ID of the created Proxy Member', + ), + 400: OpenApiResponse( + response=inline_serializer( + 'ProxyMemberCreateValidationErrors', + {'character': CharacterCollectionSerializer, 'bis': BISListModifySerializer}, + ), + description='A map of any errors for the provided Character and BIS data. The values will all be lists of strings for any keys that are present.', + ), + 404: OpenApiResponse(description='The Team ID does not exist, the Member ID is not valid, or the requesting User does not have permission.'), + }, + ) def post(self, request: Request, team_id: str) -> Response: """ - Create a new Proxy Character in the specified Team. - Can currently only be done by the Team Lead. + Add a new Proxy Member to the Team. + This request can be run by anyone in the Team with the `proxy_manager` permissions. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: @@ -69,10 +96,23 @@ class TeamProxyResource(APIView): Handle a single Proxy Character record (read / update) """ + @extend_schema( + tags=['team_proxy'], + responses={ + 200: inline_serializer( + 'ProxyMemberReadResponse', + { + 'team': TeamSerializer(), + 'member': TeamMemberSerializer(), + }, + ), + 404: OpenApiResponse(description='The Team ID does not exist, the Member ID is not valid, or the requesting User does not have permission.'), + }, + ) def get(self, request: Request, team_id: str, pk: int) -> Response: """ - Read the details of a single Proxy record. - Can only be performed by a Team Lead + Read the details of a specific Proxied Team Member. + This request can be run by anyone in the Team with the `proxy_manager` permissions. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: @@ -88,11 +128,24 @@ def get(self, request: Request, team_id: str, pk: int) -> Response: member_data = TeamMemberSerializer(instance=obj).data return Response({'team': team_data, 'member': member_data}) + @extend_schema( + tags=['team_proxy'], + request=BISListModifySerializer, + responses={ + 204: OpenApiResponse( + description='The BIS data of the Proxy Member was updated successfully!', + ), + 400: OpenApiResponse( + response=BISListModifySerializer, + description='A map of any errors for the provided Character and BIS data. The values will all be lists of strings for any keys that are present.', + ), + 404: OpenApiResponse(description='The Team ID does not exist, the Member ID is not valid, or the requesting User does not have permission.'), + }, + ) def put(self, request: Request, team_id: str, pk: int) -> Response: """ - Update the details of a single Proxy record. - Can only be performed by a Team Lead. - Only really updates the BIS List since there's not much need to update anything else. + Update the BIS information for a Proxy Team Member. + This request can be run by anyone in the Team with the `proxy_manager` permissions. """ team = self._get_team_with_permission(request, team_id, PERMISSION_NAME) if team is None: @@ -122,9 +175,24 @@ class TeamProxyClaim(APIView): For a little security, the view will expect the invite code to be sent in the data. """ + @extend_schema( + tags=['team_proxy'], + request=inline_serializer('ProxyClaimRequest', {'invite_code': serializers.CharField()}), + responses={ + 201: OpenApiResponse( + response=inline_serializer('ProxyMemberClaimResponse', {'id': serializers.IntegerField()}), + description='The ID of the copy of the Proxy Member', + ), + 404: OpenApiResponse(description='The Team ID does not exist, the Member ID is not valid, or the `invite_code` is incorrect.'), + }, + ) def post(self, request: Request, team_id: str, pk: int) -> Response: """ - Make an attempt to claim a Proxy Character + Allow a User to make an attempt to claim ownership of a pre-existing Proxy Character. + This view can be used by anyone who has the `invite_code` for the Team. + + It will create a copy of the Character in the requesting User's account. + When they successfully validate their copy, the system will replace all instances of the proxied Character with their valid one. """ invite_code = request.data.get('invite_code', '') try: diff --git a/backend/api/views/tier.py b/backend/api/views/tier.py index 902645f3..4b12806c 100644 --- a/backend/api/views/tier.py +++ b/backend/api/views/tier.py @@ -18,12 +18,13 @@ class TierCollection(APIView): """ Get a list of Tiers in the system """ - + queryset = Tier + serializer_class = TierSerializer(many=True) permission_classes = [AllowAny] def get(self, request: Request) -> Response: """ - Return a list of Characters belonging to a certain User + Return the list of Tiers tracked in the system. """ # Permissions won't allow this method to be run by non-auth'd users objs = Tier.objects.all() diff --git a/backend/api/views/user.py b/backend/api/views/user.py index 6ccab866..a1904edd 100644 --- a/backend/api/views/user.py +++ b/backend/api/views/user.py @@ -3,6 +3,8 @@ """ # lib +from drf_spectacular.utils import OpenApiResponse +from drf_spectacular.views import extend_schema from rest_framework.authtoken.models import Token from rest_framework.permissions import BasePermission from rest_framework.response import Response @@ -25,18 +27,28 @@ def has_permission(self, request, view): class UserView(APIView): """ - A simple method to get the (useful) information about the current user. - Will return 404 if the request is unauthenticated, which will prompt the UI to display a login button instead + Returns the data of the requesting User. + If there is no authenticated User, this will return a default set of information, including an `id` of `null`. """ permission_classes = [UserPermissions] + serializer_class = UserSerializer + @extend_schema(tags=['user']) def get(self, request) -> Response: data = UserSerializer(request.user).data return Response(data) + @extend_schema( + tags=['user'], + request=SettingsSerializer, + responses={ + 200: OpenApiResponse(description='Settings update was successful!'), + }, + ) def put(self, request) -> Response: """ - Update a User's serializer + Update the Settings of the logged in User. + Also allows the User to update their username. """ try: obj = request.user.settings @@ -59,7 +71,7 @@ def put(self, request) -> Response: # Send websocket packet for updates self._send_to_user(request.user, {'type': 'settings'}) - return Response(status=201) + return Response(status=200) class UserTokenView(APIView): @@ -67,9 +79,16 @@ class UserTokenView(APIView): A view for handling updates to a User's Token. """ + @extend_schema( + tags=['user'], + request=None, + responses={ + 201: OpenApiResponse(description='API Key regenerated successfully!'), + }, + ) def patch(self, request) -> Response: """ - Regenerate a User's Token + Regenerate the User's API Key """ try: obj = request.user.auth_token diff --git a/backend/backend/__init__.py b/backend/backend/__init__.py index 171a200d..fc6feb34 100644 --- a/backend/backend/__init__.py +++ b/backend/backend/__init__.py @@ -1,3 +1,5 @@ # Make sure our celery app is run on load from .celery import app as celery_app + +VERSION = '20240629' diff --git a/backend/backend/settings.py b/backend/backend/settings.py index 031250e2..e3090a29 100644 --- a/backend/backend/settings.py +++ b/backend/backend/settings.py @@ -11,6 +11,7 @@ """ from os import environ from pathlib import Path +from . import VERSION # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -48,6 +49,9 @@ 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.discord', + + # API Schema + 'drf_spectacular', ] MIDDLEWARE = [ @@ -110,8 +114,17 @@ 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', } +SPECTACULAR_SETTINGS = { + 'TITLE': 'SavageAim', + 'DESCRIPTION': 'FFXIV BIS Management Website', + 'VERSION': VERSION, + 'SERVE_INCLUDE_SCHEMA': False, + 'SERVERS': [{'url': 'https://savageaim.com/', 'description': 'Main Site'}], + 'SCHEMA_PATH_PREFIX': '/backend/api', +} # Password validation # https://docs.djangoproject.com/en/3.1/ref/settings/#auth-password-validators diff --git a/backend/backend/settings_live.py b/backend/backend/settings_live.py index e382cc22..48ae5113 100644 --- a/backend/backend/settings_live.py +++ b/backend/backend/settings_live.py @@ -13,6 +13,8 @@ from os import environ from pathlib import Path from sentry_sdk.integrations.django import DjangoIntegration +from . import VERSION + # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -60,6 +62,9 @@ 'allauth.account', 'allauth.socialaccount', 'allauth.socialaccount.providers.discord', + + # API Schema + 'drf_spectacular', ] MIDDLEWARE = [ @@ -110,6 +115,17 @@ 'rest_framework.authentication.SessionAuthentication', 'rest_framework.authentication.TokenAuthentication', ], + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', +} + +SPECTACULAR_SETTINGS = { + 'TITLE': 'SavageAim', + 'DESCRIPTION': 'FFXIV BIS Management Website', + 'VERSION': VERSION, + 'SERVE_INCLUDE_SCHEMA': False, + 'SERVERS': [{'url': 'https://savageaim.com/', 'description': 'Main Site'}], + 'SCHEMA_PATH_PREFIX': '/backend/api', + 'DISABLE_ERRORS_AND_WARNINGS': True, } CSRF_COOKIE_SECURE = True @@ -189,7 +205,7 @@ def sampler(context): # If you wish to associate users to errors (assuming you are using # django.contrib.auth) you may enable sending PII data. send_default_pii=True, - release='savageaim@20240629', + release=f'savageaim@{VERSION}', ) # Channels diff --git a/backend/backend/urls.py b/backend/backend/urls.py index 0fc93847..27cceafd 100644 --- a/backend/backend/urls.py +++ b/backend/backend/urls.py @@ -20,12 +20,15 @@ from django.http import HttpResponse from django.urls import path, include from django.views.generic.base import RedirectView +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView patterns = [ path('admin/', admin.site.urls), path('api/', include(('api.urls', 'api'))), path('health/', lambda _: HttpResponse()), path('logout/', LogoutView.as_view()), + path('schema.yaml', SpectacularAPIView.as_view(), name='schema'), + path('schema/', SpectacularSwaggerView.as_view(url_name='schema'), name='schema-ui'), # Auth stuff (TODO - replace this because it's sorta workaroundy) path('accounts/', include(discord_urls)), diff --git a/backend/backend/urls_live.py b/backend/backend/urls_live.py index a1271c99..76465722 100644 --- a/backend/backend/urls_live.py +++ b/backend/backend/urls_live.py @@ -19,11 +19,14 @@ from django.http import HttpResponse from django.urls import path, include from django.views.generic.base import RedirectView +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView patterns = [ path('api/', include(('api.urls', 'api'))), path('health/', lambda _: HttpResponse()), path('logout/', LogoutView.as_view()), + path('schema.yaml', SpectacularAPIView.as_view(), name='schema'), + path('schema/', SpectacularSwaggerView.as_view(url_name='schema'), name='schema-ui'), # Auth stuff (TODO - replace this because it's sorta workaroundy) path('accounts/', include(discord_urls)), diff --git a/backend/requirements.txt b/backend/requirements.txt index 15e6a0ea..5c0bc843 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -27,6 +27,7 @@ Deprecated==1.2.13 Django==3.2.25 django-allauth==0.47.0 djangorestframework==3.14.0 +drf-spectacular==0.27.2 gunicorn==22.0.0 hiredis==2.0.0 hyperlink==21.0.0 diff --git a/bump.sh b/bump.sh index e6099a5f..67218827 100644 --- a/bump.sh +++ b/bump.sh @@ -3,7 +3,7 @@ # Function to handle doing the bump details do_bump() { # Just use sed to replace the current version with the new one in the files that need it - sed -i "s/$CURRENT_VERSION/$NEW_VERSION/" frontend/.env frontend/src/main.ts backend/backend/settings_live.py + sed -i "s/$CURRENT_VERSION/$NEW_VERSION/" frontend/.env frontend/src/main.ts backend/backend/__init__.py } diff --git a/frontend/src/components/footer.vue b/frontend/src/components/footer.vue index f8fe6f75..7dff74b5 100644 --- a/frontend/src/components/footer.vue +++ b/frontend/src/components/footer.vue @@ -8,9 +8,11 @@ + api menu_book update code + extension Discord Logo

Savage Aim release {{ $store.state.version }}, by Eira Erikawa (Lich)

FINAL FANTASY XIV ©2010 - {{ currentYear }} SQUARE ENIX CO., LTD. All Rights Reserved.

diff --git a/frontend/src/components/modals/changelog.vue b/frontend/src/components/modals/changelog.vue index 9a71474c..0dcc55dc 100644 --- a/frontend/src/components/modals/changelog.vue +++ b/frontend/src/components/modals/changelog.vue @@ -17,10 +17,16 @@

Currently, it must be installed as a custom plugin repo due to Dalamud Team's build systems being on hold

The code and installation instructions are located here!

If there are any questions or issues, please report them as you would anything with the website itself!

- - + +
expand_more API Schema expand_more
+

In the last release, API Keys were added to allow external access for possibly building an ecosystem of tools.

+

However, it only dawned on me recently that the API isn't obvious for anyone other than me.

+

There is now an OpenAPI schema available at https://savageaim.com/backend/schema/.

+

This set of documentation is mostly manually written so if there are any questions, feel free to ask on Discord/Github!

+
expand_more Fixes & Improvements expand_more

Improved fetching of Teams from the database to improve speed and lessen the load on the server.

+

Added "API Schema" and "Plugin" links to the footer icons.

diff --git a/frontend/src/components/modals/confirmations/delete_loot.vue b/frontend/src/components/modals/confirmations/delete_loot.vue index 36f46c0f..354456b8 100644 --- a/frontend/src/components/modals/confirmations/delete_loot.vue +++ b/frontend/src/components/modals/confirmations/delete_loot.vue @@ -54,14 +54,14 @@ export default class DeleteLoot extends Vue { team!: Team get url(): string { - return `/backend/api/team/${this.team.id}/loot/` + return `/backend/api/team/${this.team.id}/loot/delete/` } async deleteLoot(): Promise { const body = JSON.stringify({ items: this.items.map((item: Loot) => item.id) }) try { const response = await fetch(this.url, { - method: 'DELETE', + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-CSRFToken': Vue.$cookies.get('csrftoken'),